diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7f1576df4..9ac527d4a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -20,12 +20,19 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. -**Screenshots** +**Screenshots and Source** If applicable, add screenshots to help explain your problem. +You can help speed up fixing the problem by either + +1. providing a simple reproduction +2. linking to your Quartz repository where the problem can be observed + **Desktop (please complete the following information):** -- Device: [e.g. iPhone6] +- Quartz Version: [e.g. v4.1.2] +- `node` Version: [e.g. v18.16] +- `npm` version: [e.g. v10.1.0] - OS: [e.g. iOS] - Browser [e.g. chrome, safari] diff --git a/docs/features/explorer.md b/docs/features/explorer.md index fd656a888..f4d54faaf 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -179,6 +179,34 @@ Component.Explorer({ ## Advanced examples +> [!tip] +> When writing more complicated functions, the `layout` file can start to look very cramped. +> You can fix this by defining your functions in another file. +> +> ```ts title="functions.ts" +> import { Options } from "./quartz/components/ExplorerNode" +> export const mapFn: Options["mapFn"] = (node) => { +> // implement your function here +> } +> export const filterFn: Options["filterFn"] = (node) => { +> // implement your function here +> } +> export const sortFn: Options["sortFn"] = (a, b) => { +> // implement your function here +> } +> ``` +> +> You can then import them like this: +> +> ```ts title="quartz.layout.ts" +> import { mapFn, filterFn, sortFn } from "./functions.ts" +> Component.Explorer({ +> mapFn: mapFn, +> filterFn: filterFn, +> sortFn: sortFn, +> }) +> ``` + ### Add emoji prefix To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this: @@ -216,30 +244,63 @@ Notice how we customized the `order` array here. This is done because the defaul To fix this, we just changed around the order and apply the `sort` function before changing the display names in the `map` function. -> [!tip] -> When writing more complicated functions, the `layout` file can start to look very cramped. -> You can fix this by defining your functions in another file. -> -> ```ts title="functions.ts" -> import { Options } from "./quartz/components/ExplorerNode" -> export const mapFn: Options["mapFn"] = (node) => { -> // implement your function here -> } -> export const filterFn: Options["filterFn"] = (node) => { -> // implement your function here -> } -> export const sortFn: Options["sortFn"] = (a, b) => { -> // implement your function here -> } -> ``` -> -> You can then import them like this: -> -> ```ts title="quartz.layout.ts" -> import { mapFn, filterFn, sortFn } from "./functions.ts" -> Component.Explorer({ -> mapFn: mapFn, -> filterFn: filterFn, -> sortFn: sortFn, -> }) -> ``` +### Use `sort` with pre-defined sort order + +Here's another example where a map containing file/folder names (as slugs) is used to define the sort order of the explorer in quartz. All files/folders that aren't listed inside of `nameOrderMap` will appear at the top of that folders hierarchy level. + +It's also worth mentioning, that the smaller the number set in `nameOrderMap`, the higher up the entry will be in the explorer. Incrementing every folder/file by 100, makes ordering files in their folders a lot easier. Lastly, this example still allows you to use a `mapFn` or frontmatter titles to change display names, as it uses slugs for `nameOrderMap` (which is unaffected by display name changes). + +```ts title="quartz.layout.ts" +Component.Explorer({ + sortFn: (a, b) => { + const nameOrderMap: Record = { + "poetry-folder": 100, + "essay-folder": 200, + "research-paper-file": 201, + "dinosaur-fossils-file": 300, + "other-folder": 400, + } + + let orderA = 0 + let orderB = 0 + + if (a.file && a.file.slug) { + orderA = nameOrderMap[a.file.slug] || 0 + } else if (a.name) { + orderA = nameOrderMap[a.name] || 0 + } + + if (b.file && b.file.slug) { + orderB = nameOrderMap[b.file.slug] || 0 + } else if (b.name) { + orderB = nameOrderMap[b.name] || 0 + } + + return orderA - orderB + }, +}) +``` + +For reference, this is how the quartz explorer window would look like with that example: + +``` +📖 Poetry Folder +📑 Essay Folder + ⚗ïļ Research Paper File +ðŸĶī Dinosaur Fossils File +ðŸ”Ū Other Folder +``` + +And this is how the file structure would look like: + +``` +index.md +poetry-folder + index.md +essay-folder + index.md + research-paper-file.md +dinosaur-fossils-file.md +other-folder + index.md +``` diff --git a/docs/showcase.md b/docs/showcase.md index 1e2ef56ae..2cd56b306 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -21,5 +21,6 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Pedro MC Fernandes's Topo da Mente](https://www.pmcf.xyz/topo-da-mente/) - [Mau Camargo's Notkesto](https://notes.camargomau.com/) - [Caicai's Novels](https://imoko.cc/blog/caicai/) +- [🌊 Collapsed Wave](https://collapsedwave.com/) If you want to see your own on here, submit a [Pull Request adding yourself to this file](https://github.com/jackyzha0/quartz/blob/v4/docs/showcase.md)! diff --git a/package-lock.json b/package-lock.json index 0bc416311..73531b4b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.1.3", + "version": "4.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.1.3", + "version": "4.1.4", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", @@ -32,7 +32,6 @@ "mdast-util-to-hast": "^13.0.2", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "plausible-tracker": "^0.3.8", "preact": "^10.19.3", "preact-render-to-string": "^6.3.1", "pretty-bytes": "^6.1.1", @@ -52,6 +51,7 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "remark-smartypants": "^2.0.0", + "rfdc": "^1.3.0", "rimraf": "^5.0.5", "serve-handler": "^6.1.5", "shikiji": "^0.9.9", @@ -4450,14 +4450,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/plausible-tracker": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.8.tgz", - "integrity": "sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==", - "engines": { - "node": ">=10" - } - }, "node_modules/preact": { "version": "10.19.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", @@ -5161,6 +5153,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, "node_modules/rimraf": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", diff --git a/package.json b/package.json index 252467e14..bb79280d8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "ðŸŒą publish your digital garden and notes as a website", "private": true, - "version": "4.1.3", + "version": "4.1.4", "type": "module", "author": "jackyzha0 ", "license": "MIT", @@ -57,7 +57,6 @@ "mdast-util-to-hast": "^13.0.2", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "plausible-tracker": "^0.3.8", "preact": "^10.19.3", "preact-render-to-string": "^6.3.1", "pretty-bytes": "^6.1.1", @@ -77,6 +76,7 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "remark-smartypants": "^2.0.0", + "rfdc": "^1.3.0", "rimraf": "^5.0.5", "serve-handler": "^6.1.5", "shikiji": "^0.9.9", diff --git a/quartz.layout.ts b/quartz.layout.ts index 44710c55b..ec3ffffe2 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -37,7 +37,7 @@ export const defaultContentPageLayout: PageLayout = { // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.ArticleTitle()], + beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle()], left: [ Component.PageTitle(), Component.MobileOnly(Component.Spacer()), diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 8371b5e2b..7f0f206e1 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -7,6 +7,7 @@ export type Analytics = | null | { provider: "plausible" + host?: string } | { provider: "google" diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index 586881af9..37762a4fb 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -113,7 +113,10 @@ export async function handleCreate(argv) { } } - await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) + const gitkeepPath = path.join(contentFolder, ".gitkeep") + if (fs.existsSync(gitkeepPath)) { + await fs.promises.unlink(gitkeepPath) + } if (setupStrategy === "copy" || setupStrategy === "symlink") { let originalFolder = sourceDirectory diff --git a/quartz/components/Breadcrumbs.tsx b/quartz/components/Breadcrumbs.tsx index 8998c4064..175f6f39d 100644 --- a/quartz/components/Breadcrumbs.tsx +++ b/quartz/components/Breadcrumbs.tsx @@ -68,8 +68,9 @@ export default ((opts?: Partial) => { // construct the index for the first time for (const file of allFiles) { if (file.slug?.endsWith("index")) { - const folderParts = file.filePath?.split("/") + const folderParts = file.slug?.split("/") if (folderParts) { + // 2nd last to exclude the /index const folderName = folderParts[folderParts?.length - 2] folderIndex.set(folderName, file) } @@ -88,7 +89,10 @@ export default ((opts?: Partial) => { // Try to resolve frontmatter folder title const currentFile = folderIndex?.get(curPathSegment) if (currentFile) { - curPathSegment = currentFile.frontmatter!.title + const title = currentFile.frontmatter!.title + if (title !== "index") { + curPathSegment = title + } } // Add current slug to full path @@ -100,7 +104,7 @@ export default ((opts?: Partial) => { } // Add current file to crumb (can directly use frontmatter title) - if (options.showCurrentPage) { + if (options.showCurrentPage && slugParts.at(-1) === "") { crumbs.push({ displayName: fileData.frontmatter!.title, path: "", diff --git a/quartz/components/Darkmode.tsx b/quartz/components/Darkmode.tsx index a668c5b9a..f1a7d080a 100644 --- a/quartz/components/Darkmode.tsx +++ b/quartz/components/Darkmode.tsx @@ -18,7 +18,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) { x="0px" y="0px" viewBox="0 0 35 35" - style="enable-background:new 0 0 35 35;" + style="enable-background:new 0 0 35 35" xmlSpace="preserve" > Light mode @@ -34,7 +34,7 @@ function Darkmode({ displayClass }: QuartzComponentProps) { x="0px" y="0px" viewBox="0 0 100 100" - style="enable-background='new 0 0 100 100'" + style="enable-background:new 0 0 100 100" xmlSpace="preserve" > Dark mode diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 95eac4304..e3ed9b1cc 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -12,6 +12,9 @@ const defaultOptions = { folderClickBehavior: "collapse", folderDefaultState: "collapsed", useSavedState: true, + mapFn: (node) => { + return node + }, sortFn: (a, b) => { // Sort order: folders first, then files. Sort folders and files alphabetically if ((!a.file && !b.file) || (a.file && b.file)) { @@ -22,6 +25,7 @@ const defaultOptions = { sensitivity: "base", }) } + if (a.file && !b.file) { return 1 } else { @@ -41,46 +45,34 @@ export default ((userOpts?: Partial) => { let jsonTree: string function constructFileTree(allFiles: QuartzPluginData[]) { - if (!fileTree) { - // Construct tree from allFiles - fileTree = new FileNode("") - allFiles.forEach((file) => fileTree.add(file, 1)) + if (fileTree) { + return + } - /** - * Keys of this object must match corresponding function name of `FileNode`, - * while values must be the argument that will be passed to the function. - * - * e.g. entry for FileNode.sort: `sort: opts.sortFn` (value is sort function from options) - */ - const functions = { - map: opts.mapFn, - sort: opts.sortFn, - filter: opts.filterFn, - } + // Construct tree from allFiles + fileTree = new FileNode("") + allFiles.forEach((file) => fileTree.add(file)) - // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) - if (opts.order) { - // Order is important, use loop with index instead of order.map() - for (let i = 0; i < opts.order.length; i++) { - const functionName = opts.order[i] - if (functions[functionName]) { - // for every entry in order, call matching function in FileNode and pass matching argument - // e.g. i = 0; functionName = "filter" - // converted to: (if opts.filterFn) => fileTree.filter(opts.filterFn) - - // @ts-ignore - // typescript cant statically check these dynamic references, so manually make sure reference is valid and ignore warning - fileTree[functionName].call(fileTree, functions[functionName]) - } + // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) + if (opts.order) { + // Order is important, use loop with index instead of order.map() + for (let i = 0; i < opts.order.length; i++) { + const functionName = opts.order[i] + if (functionName === "map") { + fileTree.map(opts.mapFn) + } else if (functionName === "sort") { + fileTree.sort(opts.sortFn) + } else if (functionName === "filter") { + fileTree.filter(opts.filterFn) } } - - // Get all folders of tree. Initialize with collapsed state - const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") - - // Stringify to pass json tree as data attribute ([data-tree]) - jsonTree = JSON.stringify(folders) } + + // Get all folders of tree. Initialize with collapsed state + const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") + + // Stringify to pass json tree as data attribute ([data-tree]) + jsonTree = JSON.stringify(folders) } function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { @@ -120,6 +112,7 @@ export default ((userOpts?: Partial) => { ) } + Explorer.css = explorerStyle Explorer.afterDOMLoaded = script return Explorer diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index e5ceb0bf3..60966b3b1 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -1,6 +1,13 @@ // @ts-ignore import { QuartzPluginData } from "../plugins/vfile" -import { resolveRelative } from "../util/path" +import { + joinSegments, + resolveRelative, + clone, + simplifySlug, + SimpleSlug, + FilePath, +} from "../util/path" type OrderEntries = "sort" | "filter" | "map" @@ -10,9 +17,9 @@ export interface Options { folderClickBehavior: "collapse" | "link" useSavedState: boolean sortFn: (a: FileNode, b: FileNode) => number - filterFn?: (node: FileNode) => boolean - mapFn?: (node: FileNode) => void - order?: OrderEntries[] + filterFn: (node: FileNode) => boolean + mapFn: (node: FileNode) => void + order: OrderEntries[] } type DataWrapper = { @@ -25,59 +32,74 @@ export type FolderState = { collapsed: boolean } +function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined { + if (!fp) { + return undefined + } + + return fp.split("/").at(idx) +} + // Structure to add all files into a tree export class FileNode { - children: FileNode[] - name: string + children: Array + name: string // this is the slug segment displayName: string file: QuartzPluginData | null depth: number - constructor(name: string, file?: QuartzPluginData, depth?: number) { + constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { this.children = [] - this.name = name - this.displayName = name - this.file = file ? structuredClone(file) : null + this.name = slugSegment + this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment + this.file = file ? clone(file) : null this.depth = depth ?? 0 } - private insert(file: DataWrapper) { - if (file.path.length === 1) { - if (file.path[0] !== "index.md") { - this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1)) - } else { - const title = file.file.frontmatter?.title - if (title && title !== "index" && file.path[0] === "index.md") { + private insert(fileData: DataWrapper) { + if (fileData.path.length === 0) { + return + } + + const nextSegment = fileData.path[0] + + // base case, insert here + if (fileData.path.length === 1) { + if (nextSegment === "") { + // index case (we are the root and we just found index.md), set our data appropriately + const title = fileData.file.frontmatter?.title + if (title && title !== "index") { this.displayName = title } - } - } else { - const next = file.path[0] - file.path = file.path.splice(1) - for (const child of this.children) { - if (child.name === next) { - child.insert(file) - return - } + } else { + // direct child + this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1)) } - const newChild = new FileNode(next, undefined, this.depth + 1) - newChild.insert(file) - this.children.push(newChild) + return } + + // find the right child to insert into + fileData.path = fileData.path.splice(1) + const child = this.children.find((c) => c.name === nextSegment) + if (child) { + child.insert(fileData) + return + } + + const newChild = new FileNode( + nextSegment, + getPathSegment(fileData.file.relativePath, this.depth), + undefined, + this.depth + 1, + ) + newChild.insert(fileData) + this.children.push(newChild) } // Add new file to tree - add(file: QuartzPluginData, splice: number = 0) { - this.insert({ file, path: file.filePath!.split("/").splice(splice) }) - } - - // Print tree structure (for debugging) - print(depth: number = 0) { - let folderChar = "" - if (!this.file) folderChar = "|" - console.log("-".repeat(depth), folderChar, this.name, this.depth) - this.children.forEach((e) => e.print(depth + 1)) + add(file: QuartzPluginData) { + this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) } /** @@ -95,7 +117,6 @@ export class FileNode { */ map(mapFn: (node: FileNode) => void) { mapFn(this) - this.children.forEach((child) => child.map(mapFn)) } @@ -110,16 +131,16 @@ export class FileNode { const traverse = (node: FileNode, currentPath: string) => { if (!node.file) { - const folderPath = currentPath + (currentPath ? "/" : "") + node.name + const folderPath = joinSegments(currentPath, node.name) if (folderPath !== "") { folderPaths.push({ path: folderPath, collapsed }) } + node.children.forEach((child) => traverse(child, folderPath)) } } traverse(this, "") - return folderPaths } @@ -147,14 +168,13 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro const isDefaultOpen = opts.folderDefaultState === "open" // Calculate current folderPath - let pathOld = fullPath ? fullPath : "" let folderPath = "" if (node.name !== "") { - folderPath = `${pathOld}/${node.name}` + folderPath = joinSegments(fullPath ?? "", node.name) } return ( -
  • + <> {node.file ? ( // Single file node
  • @@ -163,7 +183,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro
  • ) : ( -
    +
  • {node.name !== "" && ( // Node with entire folder // Render svg button + folder name, then children @@ -185,12 +205,16 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro {/* render tag if folderBehavior is "link", otherwise render )}
  • @@ -217,8 +241,8 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro ))} - + )} - + ) } diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 72404ed23..8e79d200e 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -59,8 +59,7 @@ function toggleFolder(evt: MouseEvent) { // Save folder state to localStorage const clickFolderPath = currentFolderParent.dataset.folderpath as string - // Remove leading "/" - const fullFolderPath = clickFolderPath.substring(1) + const fullFolderPath = clickFolderPath toggleCollapsedByPath(explorerState, fullFolderPath) const stringifiedFileTree = JSON.stringify(explorerState) @@ -108,9 +107,7 @@ function setupExplorer() { explorerState = JSON.parse(storageTree) explorerState.map((folderUl) => { // grab
  • element for matching folder path - const folderLi = document.querySelector( - `[data-folderpath='/${folderUl.path}']`, - ) as HTMLElement + const folderLi = document.querySelector(`[data-folderpath='${folderUl.path}']`) as HTMLElement // Get corresponding content
      tag and set state if (folderLi) { diff --git a/quartz/components/scripts/plausible.inline.ts b/quartz/components/scripts/plausible.inline.ts deleted file mode 100644 index 704f5d5fe..000000000 --- a/quartz/components/scripts/plausible.inline.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Plausible from "plausible-tracker" -const { trackPageview } = Plausible() -document.addEventListener("nav", () => trackPageview()) diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index 28e9f9bb2..ff046a665 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -106,7 +106,7 @@ svg { align-items: center; font-family: var(--headerFont); - & p { + & span { font-size: 0.95rem; display: inline-block; color: var(--secondary); diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 884db4dd1..e8a81bc0b 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -4,8 +4,6 @@ import { QuartzEmitterPlugin } from "../types" // @ts-ignore import spaRouterScript from "../../components/scripts/spa.inline" // @ts-ignore -import plausibleScript from "../../components/scripts/plausible.inline" -// @ts-ignore import popoverScript from "../../components/scripts/popover.inline" import styles from "../../styles/custom.scss" import popoverStyle from "../../components/styles/popover.scss" @@ -14,6 +12,7 @@ import { StaticResources } from "../../util/resources" import { QuartzComponent } from "../../components/types" import { googleFontHref, joinStyles } from "../../util/theme" import { Features, transform } from "lightningcss" +import { transform as transpile } from "esbuild" type ComponentResources = { css: string[] @@ -56,9 +55,16 @@ function getComponentResources(ctx: BuildCtx): ComponentResources { } } -function joinScripts(scripts: string[]): string { +async function joinScripts(scripts: string[]): Promise { // wrap with iife to prevent scope collision - return scripts.map((script) => `(function () {${script}})();`).join("\n") + const script = scripts.map((script) => `(function () {${script}})();`).join("\n") + + // minify with esbuild + const res = await transpile(script, { + minify: true, + }) + + return res.code } function addGlobalPageResources( @@ -95,7 +101,20 @@ function addGlobalPageResources( }); });`) } else if (cfg.analytics?.provider === "plausible") { - componentResources.afterDOMLoaded.push(plausibleScript) + const plausibleHost = cfg.analytics.host ?? "https://plausible.io" + componentResources.afterDOMLoaded.push(` + const plausibleScript = document.createElement("script") + plausibleScript.src = "${plausibleHost}/js/script.manual.js" + plausibleScript.setAttribute("data-domain", location.hostname) + plausibleScript.defer = true + document.head.appendChild(plausibleScript) + + window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) } + + document.addEventListener("nav", () => { + plausible("pageview") + }) + `) } else if (cfg.analytics?.provider === "umami") { componentResources.afterDOMLoaded.push(` const umamiScript = document.createElement("script") @@ -165,8 +184,11 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< addGlobalPageResources(ctx, resources, componentResources) const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles) - const prescript = joinScripts(componentResources.beforeDOMLoaded) - const postscript = joinScripts(componentResources.afterDOMLoaded) + const [prescript, postscript] = await Promise.all([ + joinScripts(componentResources.beforeDOMLoaded), + joinScripts(componentResources.afterDOMLoaded), + ]) + const fps = await Promise.all([ emit({ slug: "index" as FullSlug, diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index c5170c64a..bc4c6c325 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -2,7 +2,7 @@ import { Root } from "hast" import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" +import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import path from "path" @@ -37,7 +37,7 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${encodeURI(slug)} + https://${joinSegments(base, encodeURI(slug))} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -52,8 +52,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` ${escapeHTML(content.title)} - ${root}/${encodeURI(slug)} - ${root}/${encodeURI(slug)} + ${joinSegments(root, encodeURI(slug))} + ${joinSegments(root, encodeURI(slug))} ${content.richContent ?? content.description} ${content.date?.toUTCString()} ` diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index 9753d2ea9..f35d05353 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -30,5 +30,6 @@ declare module "vfile" { interface DataMap { slug: FullSlug filePath: FilePath + relativePath: FilePath } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index d50217ba6..26a665d8f 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -49,11 +49,19 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> data.title = file.stem ?? "Untitled" } - if (data.tags && !Array.isArray(data.tags)) { + if (data.tags) { + // coerce to array + if (!Array.isArray(data.tags)) { + data.tags = data.tags + .toString() + .split(oneLineTagDelim) + .map((tag: string) => tag.trim()) + } + + // remove all non-string tags data.tags = data.tags - .toString() - .split(oneLineTagDelim) - .map((tag: string) => tag.trim()) + .filter((tag: unknown) => typeof tag === "string" || typeof tag === "number") + .map((tag: string | number) => tag.toString()) } // slug them all!! diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 3072959df..50d2d1a0a 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -12,6 +12,7 @@ import { import path from "path" import { visit } from "unist-util-visit" import isAbsoluteUrl from "is-absolute-url" +import { Root } from "hast" interface Options { /** How to resolve Markdown paths */ @@ -19,12 +20,14 @@ interface Options { /** Strips folders from a link so that it looks nice */ prettyLinks: boolean openLinksInNewTab: boolean + lazyLoad: boolean } const defaultOptions: Options = { markdownLinkResolution: "absolute", prettyLinks: true, openLinksInNewTab: false, + lazyLoad: false, } export const CrawlLinks: QuartzTransformerPlugin | undefined> = (userOpts) => { @@ -34,7 +37,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = htmlPlugins(ctx) { return [ () => { - return (tree, file) => { + return (tree: Root, file) => { const curSlug = simplifySlug(file.data.slug!) const outgoing: Set = new Set() @@ -51,8 +54,8 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = typeof node.properties.href === "string" ) { let dest = node.properties.href as RelativeURL - node.properties.className ??= [] - node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") + const classes = (node.properties.className ?? []) as string[] + classes.push(isAbsoluteUrl(dest) ? "external" : "internal") // Check if the link has alias text if ( @@ -61,8 +64,9 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = node.children[0].value !== dest ) { // Add the 'alias' class if the text content is not the same as the href - node.properties.className.push("alias") + classes.push("alias") } + node.properties.className = classes if (opts.openLinksInNewTab) { node.properties.target = "_blank" @@ -111,6 +115,10 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = node.properties && typeof node.properties.src === "string" ) { + if (opts.lazyLoad) { + node.properties.loading = "lazy" + } + if (!isAbsoluteUrl(node.properties.src)) { let dest = node.properties.src as RelativeURL dest = node.properties.src = transformLink( diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index f8a83930a..5aa09c5bb 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -125,7 +125,7 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") // #(...) -> capturing group, tag itself must start with # // (?:[-_\p{L}\d\p{Z}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters and symbols, hyphens and/or underscores // (?:\/[-_\p{L}\d\p{Z}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" -const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") +const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\p{Emoji}\d])+(?:\/[-_\p{L}\p{Emoji}\d]+)*)/, "gu") const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index fab179549..3950fee09 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -91,8 +91,9 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { } // base data properties that plugins may use - file.data.slug = slugifyFilePath(path.posix.relative(argv.directory, file.path) as FilePath) - file.data.filePath = fp + file.data.filePath = file.path as FilePath + file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath + file.data.slug = slugifyFilePath(file.data.relativePath) const ast = processor.parse(file) const newAst = await processor.run(ast, file) diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 92cfabe49..6cedffdb6 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -1,5 +1,9 @@ import { slug as slugAnchor } from "github-slugger" import type { Element as HastElement } from "hast" +import rfdc from "rfdc" + +export const clone = rfdc() + // this file must be isomorphic so it can't use node libs (e.g. path) export const QUARTZ = "quartz" @@ -121,7 +125,8 @@ const _rebaseHastElement = ( } } -export function normalizeHastElement(el: HastElement, curBase: FullSlug, newBase: FullSlug) { +export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) { + const el = clone(rawEl) // clone so we dont modify the original page _rebaseHastElement(el, "src", curBase, newBase) _rebaseHastElement(el, "href", curBase, newBase) if (el.children) {