From 340e3ef5116cd99c8ddfdbb3d9e0bbd914e07825 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 25 Aug 2023 18:03:49 +0200 Subject: [PATCH 001/205] feat(consistency): Add `.obsidian` to ignorePatterns (#420) --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index 64e86dce043dd..31d5bcfea13d9 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -10,7 +10,7 @@ const config: QuartzConfig = { provider: "plausible", }, baseUrl: "quartz.jzhao.xyz", - ignorePatterns: ["private", "templates"], + ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "created", theme: { typography: { From 5c6d1e27baef74a42cc292276c5832b010a38fd5 Mon Sep 17 00:00:00 2001 From: Hrishikesh Barman Date: Fri, 25 Aug 2023 22:55:46 +0530 Subject: [PATCH 002/205] feat(plugins): add toml support for frontmatter (#418) * feat(plugins): add toml support for frontmatter Currently frontmatter is expected to be yaml, with delimiter set to "---". This might not always be the case, for example ox-hugo(a hugo exporter for org-mode files) exports in toml format with the delimiter set to "+++" by default. With this change, the users will be able use frontmatter plugin to support this toml frontmatter format. Example usage: `Plugin.FrontMatter({delims: "+++", language: 'toml'})` - [0] https://ox-hugo.scripter.co/doc/org-meta-data-to-hugo-front-matter/ * fixup! feat(plugins): add toml support for frontmatter --- package-lock.json | 6 ++++++ package.json | 1 + quartz/plugins/transformers/frontmatter.ts | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index d94d6cf7ac5bc..9246cc992a85c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "serve-handler": "^6.1.5", "source-map-support": "^0.5.21", "to-vfile": "^7.2.4", + "toml": "^3.0.0", "unified": "^10.1.2", "unist-util-visit": "^4.1.2", "vfile": "^5.3.7", @@ -5548,6 +5549,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, "node_modules/tough-cookie": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", diff --git a/package.json b/package.json index 25d3d22d7bd51..6ed52d6023723 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "serve-handler": "^6.1.5", "source-map-support": "^0.5.21", "to-vfile": "^7.2.4", + "toml": "^3.0.0", "unified": "^10.1.2", "unist-util-visit": "^4.1.2", "vfile": "^5.3.7", diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 3f55b9cb6bd4f..a7249c19e6c76 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -2,14 +2,17 @@ import matter from "gray-matter" import remarkFrontmatter from "remark-frontmatter" import { QuartzTransformerPlugin } from "../types" import yaml from "js-yaml" +import toml from "toml" import { slugTag } from "../../util/path" export interface Options { delims: string | string[] + language: "yaml" | "toml" } const defaultOptions: Options = { delims: "---", + language: "yaml", } export const FrontMatter: QuartzTransformerPlugin | undefined> = (userOpts) => { @@ -25,6 +28,7 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> ...opts, engines: { yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, + toml: (s) => toml.parse(s) as object, }, }) From bc543f81d9ada5e61cb9690834a5f83c02997d63 Mon Sep 17 00:00:00 2001 From: Hrishikesh Barman Date: Sat, 26 Aug 2023 11:22:23 +0530 Subject: [PATCH 003/205] feat(plugins): add OxHugoFlavouredMarkdown (#419) * feat(plugins): add OxHugoFlavouredMarkdown ox-hugo is an org exporter backend that exports org files to hugo-compatible markdown in an opinionated way. This plugin adds some tweaks to the generated markdown to make it compatible with quartz but the list of changes applied it is not extensive. In the future however, we could leapfrog ox-hugo altogether and create a quartz site directly out of org-roam files. That way we won't have to do all the ritual dancing that this plugin has to perform. See https://github.com/k2052/org-to-markdown * fix: add toml to remarkFrontmatter configuration * docs: add docs for OxHugoFlavouredMarkdown * fixup! docs: add docs for OxHugoFlavouredMarkdown --- docs/features/OxHugo compatibility.md | 38 +++++++++++ quartz/plugins/transformers/frontmatter.ts | 2 +- quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/oxhugofm.ts | 73 ++++++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/features/OxHugo compatibility.md create mode 100644 quartz/plugins/transformers/oxhugofm.ts diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md new file mode 100644 index 0000000000000..12774e7251dde --- /dev/null +++ b/docs/features/OxHugo compatibility.md @@ -0,0 +1,38 @@ +--- +tags: + - plugin/transformer +--- + +Quartz is a static-site generator that transforms markdown content into web pages. [org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible markdown. + +Because the markdown generated by ox-hugo is not pure markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific markdown. + +```typescript title="quartz.config.ts" +plugins: { + transformers: [ + Plugin.FrontMatter({ delims: "+++", language: "toml" }), // if toml frontmatter + // ... + Plugin.OxHugoFlavouredMarkdown(), + Plugin.GitHubFlavoredMarkdown(), + // ... + ], +}, +``` + +## Usage + +Quartz by default doesn't understand `org-roam` files as they aren't Markdown. You're responsible for using an external tool like `ox-hugo` to export the `org-roam` files as Markdown content to Quartz and managing the static assets so that they're available in the final output. + +## Configuration + +- Link resolution + - `wikilinks`: Whether to replace `{{ relref }}` with Quartz [[wikilinks]] + - `removePredefinedAnchor`: Whether to remove [pre-defined anchor set by ox-hugo](https://ox-hugo.scripter.co/doc/anchors/). +- Image handling + - `replaceFigureWithMdImg`: Whether to replace `
` with `![]()` +- Formatting + - `removeHugoShortcode`: Whether to remove hugo shortcode syntax (`{{}}`) + +> [!warning] +> +> While you can use `Plugin.OxHugoFlavoredMarkdown` and `Plugin.ObsidianFlavoredMarkdown` together, it's not recommended because it might mutate the file in unexpected ways. Use with caution. diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index a7249c19e6c76..571aa04d0ec11 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -21,7 +21,7 @@ export const FrontMatter: QuartzTransformerPlugin | undefined> name: "FrontMatter", markdownPlugins() { return [ - remarkFrontmatter, + [remarkFrontmatter, ["yaml", "toml"]], () => { return (_, file) => { const { data } = matter(file.value, { diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8013ab7ccdd2a..d9f2854c04b6f 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -5,5 +5,6 @@ export { Latex } from "./latex" export { Description } from "./description" export { CrawlLinks } from "./links" export { ObsidianFlavoredMarkdown } from "./ofm" +export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" diff --git a/quartz/plugins/transformers/oxhugofm.ts b/quartz/plugins/transformers/oxhugofm.ts new file mode 100644 index 0000000000000..0d7b9199ad7d4 --- /dev/null +++ b/quartz/plugins/transformers/oxhugofm.ts @@ -0,0 +1,73 @@ +import { QuartzTransformerPlugin } from "../types" + +export interface Options { + /** Replace {{ relref }} with quartz wikilinks []() */ + wikilinks: boolean + /** Remove pre-defined anchor (see https://ox-hugo.scripter.co/doc/anchors/) */ + removePredefinedAnchor: boolean + /** Remove hugo shortcode syntax */ + removeHugoShortcode: boolean + /** Replace
with ![]() */ + replaceFigureWithMdImg: boolean +} + +const defaultOptions: Options = { + wikilinks: true, + removePredefinedAnchor: true, + removeHugoShortcode: true, + replaceFigureWithMdImg: true, +} + +const relrefRegex = new RegExp(/\[([^\]]+)\]\(\{\{< relref "([^"]+)" >\}\}\)/, "g") +const predefinedHeadingIdRegex = new RegExp(/(.*) {#(?:.*)}/, "g") +const hugoShortcodeRegex = new RegExp(/{{(.*)}}/, "g") +const figureTagRegex = new RegExp(/< ?figure src="(.*)" ?>/, "g") + +/** + * ox-hugo is an org exporter backend that exports org files to hugo-compatible + * markdown in an opinionated way. This plugin adds some tweaks to the generated + * markdown to make it compatible with quartz but the list of changes applied it + * is not exhaustive. + * */ +export const OxHugoFlavouredMarkdown: QuartzTransformerPlugin | undefined> = ( + userOpts, +) => { + const opts = { ...defaultOptions, ...userOpts } + return { + name: "OxHugoFlavouredMarkdown", + textTransform(_ctx, src) { + if (opts.wikilinks) { + src = src.toString() + src = src.replaceAll(relrefRegex, (value, ...capture) => { + const [text, link] = capture + return `[${text}](${link})` + }) + } + + if (opts.removePredefinedAnchor) { + src = src.toString() + src = src.replaceAll(predefinedHeadingIdRegex, (value, ...capture) => { + const [headingText] = capture + return headingText + }) + } + + if (opts.removeHugoShortcode) { + src = src.toString() + src = src.replaceAll(hugoShortcodeRegex, (value, ...capture) => { + const [scContent] = capture + return scContent + }) + } + + if (opts.replaceFigureWithMdImg) { + src = src.toString() + src = src.replaceAll(figureTagRegex, (value, ...capture) => { + const [src] = capture + return `![](${src})` + }) + } + return src + }, + } +} From e3265f841637de197e5cf4a5471372b5178f1e4d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 26 Aug 2023 10:42:55 -0700 Subject: [PATCH 004/205] docs: simplify oxhugo page --- docs/features/OxHugo compatibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md index 12774e7251dde..7801f0c2571ef 100644 --- a/docs/features/OxHugo compatibility.md +++ b/docs/features/OxHugo compatibility.md @@ -3,9 +3,9 @@ tags: - plugin/transformer --- -Quartz is a static-site generator that transforms markdown content into web pages. [org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible markdown. +[org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. -Because the markdown generated by ox-hugo is not pure markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific markdown. +Because the Markdown generated by ox-hugo is not pure Markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific Markdown. ```typescript title="quartz.config.ts" plugins: { From 74c3ebb7bd7ef126246f8ea03565db73cd5e7f38 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 26 Aug 2023 10:48:34 -0700 Subject: [PATCH 005/205] style: fix mulitline callout styling --- package-lock.json | 1 + quartz/styles/callouts.scss | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9246cc992a85c..09488c42222a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,6 +112,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/quartz/styles/callouts.scss b/quartz/styles/callouts.scss index ad991658d9aa7..703bd67f69da0 100644 --- a/quartz/styles/callouts.scss +++ b/quartz/styles/callouts.scss @@ -82,7 +82,6 @@ .callout-title { display: flex; - align-items: center; gap: 5px; padding: 1rem 0; color: var(--color); @@ -103,6 +102,8 @@ .callout-icon { width: 18px; height: 18px; + flex: 0 0 18px; + padding-top: 4px; } .callout-title-inner { From ad4145fb10dbf32d8f99e1de555339dba0979f72 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sat, 26 Aug 2023 22:21:44 +0200 Subject: [PATCH 006/205] feat: support CLI arguments for `npx quartz create` (#421) * feat(cli): add new args for content + link resolve * feat(cli): validate cmd args * feat(cli): add chalk + error code to errors * feat(cli): support for setup/link via args * refactor(cli): use yargs choices instead of manual Scrap manual check if arguments are valid, use yargs "choices" field instead. * feat(cli): add in-dir argument+ handle errors add new "in-directory" argument, used if "setup" is "copy" or "symlink" to determine source. add error handling for invalid permutations of arguments or non existent path * feat(cli): dynamically use cli or provided args use "in-directory" arg as `originalFolder` if available, otherwise get it from manual cli process * run format * fix: use process.exit instead of return * refactor: split CommonArgv and CreateArgv * refactor(cli): rename create args, use ${} syntax * fix(cli): fix link resolution strategy arg * format * feat(consistency): allow partial cmd args --- quartz/bootstrap-cli.mjs | 188 +++++++++++++++++++++++++++------------ 1 file changed, 133 insertions(+), 55 deletions(-) diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index b191b49c8bc31..1deb18fe60da6 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -43,6 +43,27 @@ const CommonArgv = { }, } +const CreateArgv = { + ...CommonArgv, + source: { + string: true, + alias: ["s"], + describe: "source directory to copy/create symlink from", + }, + strategy: { + string: true, + alias: ["X"], + choices: ["new", "copy", "symlink"], + describe: "strategy for content folder setup", + }, + links: { + string: true, + alias: ["l"], + choices: ["absolute", "shortest", "relative"], + describe: "strategy to resolve links", + }, +} + const SyncArgv = { ...CommonArgv, commit: { @@ -147,24 +168,73 @@ yargs(hideBin(process.argv)) .scriptName("quartz") .version(version) .usage("$0 [args]") - .command("create", "Initialize Quartz", CommonArgv, async (argv) => { + .command("create", "Initialize Quartz", CreateArgv, async (argv) => { console.log() intro(chalk.bgGreen.black(` Quartz v${version} `)) const contentFolder = path.join(cwd, argv.directory) - const setupStrategy = exitIfCancel( - await select({ - message: `Choose how to initialize the content in \`${contentFolder}\``, - options: [ - { value: "new", label: "Empty Quartz" }, - { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, - { - value: "symlink", - label: "Symlink an existing folder", - hint: "don't select this unless you know what you are doing!", - }, - ], - }), - ) + let setupStrategy = argv.strategy?.toLowerCase() + let linkResolutionStrategy = argv.links?.toLowerCase() + const sourceDirectory = argv.source + + // If all cmd arguments were provided, check if theyre valid + if (setupStrategy && linkResolutionStrategy) { + // If setup isn't, "new", source argument is required + if (setupStrategy !== "new") { + // Error handling + if (!sourceDirectory) { + outro( + chalk.red( + `Setup strategies (arg '${chalk.yellow( + `-${CreateArgv.strategy.alias[0]}`, + )}') other than '${chalk.yellow( + "new", + )}' require content folder argument ('${chalk.yellow( + `-${CreateArgv.source.alias[0]}`, + )}') to be set`, + ), + ) + process.exit(1) + } else { + if (!fs.existsSync(sourceDirectory)) { + outro( + chalk.red( + `Input directory to copy/symlink 'content' from not found ('${chalk.yellow( + sourceDirectory, + )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, + ), + ) + process.exit(1) + } else if (!fs.lstatSync(sourceDirectory).isDirectory()) { + outro( + chalk.red( + `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( + sourceDirectory, + )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, + ), + ) + process.exit(1) + } + } + } + } + + // Use cli process if cmd args werent provided + if (!setupStrategy) { + setupStrategy = exitIfCancel( + await select({ + message: `Choose how to initialize the content in \`${contentFolder}\``, + options: [ + { value: "new", label: "Empty Quartz" }, + { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, + { + value: "symlink", + label: "Symlink an existing folder", + hint: "don't select this unless you know what you are doing!", + }, + ], + }), + ) + } async function rmContentFolder() { const contentStat = await fs.promises.lstat(contentFolder) @@ -177,23 +247,28 @@ yargs(hideBin(process.argv)) await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) if (setupStrategy === "copy" || setupStrategy === "symlink") { - const originalFolder = escapePath( - exitIfCancel( - await text({ - message: "Enter the full path to existing content folder", - placeholder: - "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path", - validate(fp) { - const fullPath = escapePath(fp) - if (!fs.existsSync(fullPath)) { - return "The given path doesn't exist" - } else if (!fs.lstatSync(fullPath).isDirectory()) { - return "The given path is not a folder" - } - }, - }), - ), - ) + let originalFolder = sourceDirectory + + // If input directory was not passed, use cli + if (!sourceDirectory) { + originalFolder = escapePath( + exitIfCancel( + await text({ + message: "Enter the full path to existing content folder", + placeholder: + "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path", + validate(fp) { + const fullPath = escapePath(fp) + if (!fs.existsSync(fullPath)) { + return "The given path doesn't exist" + } else if (!fs.lstatSync(fullPath).isDirectory()) { + return "The given path is not a folder" + } + }, + }), + ), + ) + } await rmContentFolder() if (setupStrategy === "copy") { @@ -217,29 +292,32 @@ See the [documentation](https://quartz.jzhao.xyz) for how to get started. ) } - // get a preferred link resolution strategy - const linkResolutionStrategy = exitIfCancel( - await select({ - message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, - options: [ - { - value: "absolute", - label: "Treat links as absolute path", - hint: "for content made for Quartz 3 and Hugo", - }, - { - value: "shortest", - label: "Treat links as shortest path", - hint: "for most Obsidian vaults", - }, - { - value: "relative", - label: "Treat links as relative paths", - hint: "for just normal Markdown files", - }, - ], - }), - ) + // Use cli process if cmd args werent provided + if (!linkResolutionStrategy) { + // get a preferred link resolution strategy + linkResolutionStrategy = exitIfCancel( + await select({ + message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, + options: [ + { + value: "absolute", + label: "Treat links as absolute path", + hint: "for content made for Quartz 3 and Hugo", + }, + { + value: "shortest", + label: "Treat links as shortest path", + hint: "for most Obsidian vaults", + }, + { + value: "relative", + label: "Treat links as relative paths", + hint: "for just normal Markdown files", + }, + ], + }), + ) + } // now, do config changes const configFilePath = path.join(cwd, "quartz.config.ts") From c91e62c376d481534d89084e5c04846878dff6d3 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 27 Aug 2023 02:19:45 +0200 Subject: [PATCH 007/205] Fix search bar after navigate (#424) --- quartz/components/scripts/search.inline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index adcd06abce4de..ef26ba380615c 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -128,6 +128,7 @@ document.addEventListener("nav", async (e: unknown) => { button.addEventListener("click", () => { const targ = resolveRelative(currentSlug, slug) window.spaNavigate(new URL(targ, window.location.toString())) + hideSearch() }) return button } From 52ca312f41ee6da5202cd9632d8501340ada3a67 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sun, 27 Aug 2023 12:27:42 -0700 Subject: [PATCH 008/205] fix: slugify tag on page before adding (closes #411) --- quartz/plugins/transformers/ofm.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index bed6f622c7ca2..4d1586f935a52 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -383,13 +383,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin return (tree: Root, file) => { const base = pathToRoot(file.data.slug!) findAndReplace(tree, tagRegex, (_value: string, tag: string) => { + tag = slugTag(tag) if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { file.data.frontmatter.tags.push(tag) } return { type: "link", - url: base + `/tags/${slugTag(tag)}`, + url: base + `/tags/${tag}`, data: { hProperties: { className: ["tag-link"], From 4b89202f7e834cf8b5c5aa39e8f1778706492085 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 28 Aug 2023 00:59:51 +0200 Subject: [PATCH 009/205] cleanup: rework cli to allow invoking create and build outside of cli (#428) * refactor: move `bootstrap-cli.mjs` tp cli also update reference in docs * refactor(cli): move build handler to `cli-functions` * refactor(cli): move create to handler + helpers * refactor(cli): extract arg definitions * refactor: rename handlers and helpers * refactor(cli): move update, await handlers * refactor(cli): create constants, migrate to helpers * refactor(cli): migrate `restore` * refactor(cli): migrate `sync` * format * refactor(cli): remove old imports/functions * refactor(cli): remove unused imports + format * chore: remove old log statement * fix: fix imports, clean duplicate code * fix: relative import * fix: simplified cacheFile path * fix: update cacheFile import path * refactor: move bootstrap-cli to quartz * format * revert: revert path to bootstrap-cli * ci: re-run * ci: fix execution permission --- package-lock.json | 1 - quartz/bootstrap-cli.mjs | 617 +-------------------------------------- quartz/cli/args.js | 88 ++++++ quartz/cli/constants.js | 15 + quartz/cli/handlers.js | 511 ++++++++++++++++++++++++++++++++ quartz/cli/helpers.js | 52 ++++ 6 files changed, 680 insertions(+), 604 deletions(-) create mode 100644 quartz/cli/args.js create mode 100644 quartz/cli/constants.js create mode 100644 quartz/cli/handlers.js create mode 100644 quartz/cli/helpers.js diff --git a/package-lock.json b/package-lock.json index 09488c42222a0..9246cc992a85c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 1deb18fe60da6..35d06af777b5e 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -1,628 +1,39 @@ #!/usr/bin/env node -import { promises, readFileSync } from "fs" import yargs from "yargs" -import path from "path" import { hideBin } from "yargs/helpers" -import esbuild from "esbuild" -import chalk from "chalk" -import { sassPlugin } from "esbuild-sass-plugin" -import fs from "fs" -import { intro, isCancel, outro, select, text } from "@clack/prompts" -import { rimraf } from "rimraf" -import chokidar from "chokidar" -import prettyBytes from "pretty-bytes" -import { execSync, spawnSync } from "child_process" -import http from "http" -import serveHandler from "serve-handler" -import { WebSocketServer } from "ws" -import { randomUUID } from "crypto" -import { Mutex } from "async-mutex" - -const ORIGIN_NAME = "origin" -const UPSTREAM_NAME = "upstream" -const QUARTZ_SOURCE_BRANCH = "v4" -const cwd = process.cwd() -const cacheDir = path.join(cwd, ".quartz-cache") -const cacheFile = "./.quartz-cache/transpiled-build.mjs" -const fp = "./quartz/build.ts" -const { version } = JSON.parse(readFileSync("./package.json").toString()) -const contentCacheFolder = path.join(cacheDir, "content-cache") - -const CommonArgv = { - directory: { - string: true, - alias: ["d"], - default: "content", - describe: "directory to look for content files", - }, - verbose: { - boolean: true, - alias: ["v"], - default: false, - describe: "print out extra logging information", - }, -} - -const CreateArgv = { - ...CommonArgv, - source: { - string: true, - alias: ["s"], - describe: "source directory to copy/create symlink from", - }, - strategy: { - string: true, - alias: ["X"], - choices: ["new", "copy", "symlink"], - describe: "strategy for content folder setup", - }, - links: { - string: true, - alias: ["l"], - choices: ["absolute", "shortest", "relative"], - describe: "strategy to resolve links", - }, -} - -const SyncArgv = { - ...CommonArgv, - commit: { - boolean: true, - default: true, - describe: "create a git commit for your unsaved changes", - }, - push: { - boolean: true, - default: true, - describe: "push updates to your Quartz fork", - }, - pull: { - boolean: true, - default: true, - describe: "pull updates from your Quartz fork", - }, -} - -const BuildArgv = { - ...CommonArgv, - output: { - string: true, - alias: ["o"], - default: "public", - describe: "output folder for files", - }, - serve: { - boolean: true, - default: false, - describe: "run a local server to live-preview your Quartz", - }, - baseDir: { - string: true, - default: "", - describe: "base path to serve your local server on", - }, - port: { - number: true, - default: 8080, - describe: "port to serve Quartz on", - }, - bundleInfo: { - boolean: true, - default: false, - describe: "show detailed bundle information", - }, - concurrency: { - number: true, - describe: "how many threads to use to parse notes", - }, -} - -function escapePath(fp) { - return fp - .replace(/\\ /g, " ") // unescape spaces - .replace(/^".*"$/, "$1") - .replace(/^'.*"$/, "$1") - .trim() -} - -function exitIfCancel(val) { - if (isCancel(val)) { - outro(chalk.red("Exiting")) - process.exit(0) - } else { - return val - } -} - -async function stashContentFolder(contentFolder) { - await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) - await fs.promises.cp(contentFolder, contentCacheFolder, { - force: true, - recursive: true, - verbatimSymlinks: true, - preserveTimestamps: true, - }) - await fs.promises.rm(contentFolder, { force: true, recursive: true }) -} - -async function popContentFolder(contentFolder) { - await fs.promises.rm(contentFolder, { force: true, recursive: true }) - await fs.promises.cp(contentCacheFolder, contentFolder, { - force: true, - recursive: true, - verbatimSymlinks: true, - preserveTimestamps: true, - }) - await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) -} - -function gitPull(origin, branch) { - const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] - const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) - if (out.stderr) { - throw new Error(`Error while pulling updates: ${out.stderr}`) - } -} +import { + handleBuild, + handleCreate, + handleUpdate, + handleRestore, + handleSync, +} from "./cli/handlers.js" +import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js" +import { version } from "./cli/constants.js" yargs(hideBin(process.argv)) .scriptName("quartz") .version(version) .usage("$0 [args]") .command("create", "Initialize Quartz", CreateArgv, async (argv) => { - console.log() - intro(chalk.bgGreen.black(` Quartz v${version} `)) - const contentFolder = path.join(cwd, argv.directory) - let setupStrategy = argv.strategy?.toLowerCase() - let linkResolutionStrategy = argv.links?.toLowerCase() - const sourceDirectory = argv.source - - // If all cmd arguments were provided, check if theyre valid - if (setupStrategy && linkResolutionStrategy) { - // If setup isn't, "new", source argument is required - if (setupStrategy !== "new") { - // Error handling - if (!sourceDirectory) { - outro( - chalk.red( - `Setup strategies (arg '${chalk.yellow( - `-${CreateArgv.strategy.alias[0]}`, - )}') other than '${chalk.yellow( - "new", - )}' require content folder argument ('${chalk.yellow( - `-${CreateArgv.source.alias[0]}`, - )}') to be set`, - ), - ) - process.exit(1) - } else { - if (!fs.existsSync(sourceDirectory)) { - outro( - chalk.red( - `Input directory to copy/symlink 'content' from not found ('${chalk.yellow( - sourceDirectory, - )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, - ), - ) - process.exit(1) - } else if (!fs.lstatSync(sourceDirectory).isDirectory()) { - outro( - chalk.red( - `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( - sourceDirectory, - )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, - ), - ) - process.exit(1) - } - } - } - } - - // Use cli process if cmd args werent provided - if (!setupStrategy) { - setupStrategy = exitIfCancel( - await select({ - message: `Choose how to initialize the content in \`${contentFolder}\``, - options: [ - { value: "new", label: "Empty Quartz" }, - { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, - { - value: "symlink", - label: "Symlink an existing folder", - hint: "don't select this unless you know what you are doing!", - }, - ], - }), - ) - } - - async function rmContentFolder() { - const contentStat = await fs.promises.lstat(contentFolder) - if (contentStat.isSymbolicLink()) { - await fs.promises.unlink(contentFolder) - } else { - await rimraf(contentFolder) - } - } - - await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) - if (setupStrategy === "copy" || setupStrategy === "symlink") { - let originalFolder = sourceDirectory - - // If input directory was not passed, use cli - if (!sourceDirectory) { - originalFolder = escapePath( - exitIfCancel( - await text({ - message: "Enter the full path to existing content folder", - placeholder: - "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path", - validate(fp) { - const fullPath = escapePath(fp) - if (!fs.existsSync(fullPath)) { - return "The given path doesn't exist" - } else if (!fs.lstatSync(fullPath).isDirectory()) { - return "The given path is not a folder" - } - }, - }), - ), - ) - } - - await rmContentFolder() - if (setupStrategy === "copy") { - await fs.promises.cp(originalFolder, contentFolder, { - recursive: true, - preserveTimestamps: true, - }) - } else if (setupStrategy === "symlink") { - await fs.promises.symlink(originalFolder, contentFolder, "dir") - } - } else if (setupStrategy === "new") { - await fs.promises.writeFile( - path.join(contentFolder, "index.md"), - `--- -title: Welcome to Quartz ---- - -This is a blank Quartz installation. -See the [documentation](https://quartz.jzhao.xyz) for how to get started. -`, - ) - } - - // Use cli process if cmd args werent provided - if (!linkResolutionStrategy) { - // get a preferred link resolution strategy - linkResolutionStrategy = exitIfCancel( - await select({ - message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, - options: [ - { - value: "absolute", - label: "Treat links as absolute path", - hint: "for content made for Quartz 3 and Hugo", - }, - { - value: "shortest", - label: "Treat links as shortest path", - hint: "for most Obsidian vaults", - }, - { - value: "relative", - label: "Treat links as relative paths", - hint: "for just normal Markdown files", - }, - ], - }), - ) - } - - // now, do config changes - const configFilePath = path.join(cwd, "quartz.config.ts") - let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" }) - configContent = configContent.replace( - /markdownLinkResolution: '(.+)'/, - `markdownLinkResolution: '${linkResolutionStrategy}'`, - ) - await fs.promises.writeFile(configFilePath, configContent) - - outro(`You're all set! Not sure what to do next? Try: - • Customizing Quartz a bit more by editing \`quartz.config.ts\` - • Running \`npx quartz build --serve\` to preview your Quartz locally - • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting) -`) + await handleCreate(argv) }) .command("update", "Get the latest Quartz updates", CommonArgv, async (argv) => { - const contentFolder = path.join(cwd, argv.directory) - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) - console.log("Backing up your content") - execSync( - `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, - ) - await stashContentFolder(contentFolder) - console.log( - "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", - ) - gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) - await popContentFolder(contentFolder) - console.log("Ensuring dependencies are up to date") - spawnSync("npm", ["i"], { stdio: "inherit" }) - console.log(chalk.green("Done!")) + await handleUpdate(argv) }) .command( "restore", "Try to restore your content folder from the cache", CommonArgv, async (argv) => { - const contentFolder = path.join(cwd, argv.directory) - await popContentFolder(contentFolder) + await handleRestore(argv) }, ) .command("sync", "Sync your Quartz to and from GitHub.", SyncArgv, async (argv) => { - const contentFolder = path.join(cwd, argv.directory) - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) - console.log("Backing up your content") - - if (argv.commit) { - const contentStat = await fs.promises.lstat(contentFolder) - if (contentStat.isSymbolicLink()) { - const linkTarg = await fs.promises.readlink(contentFolder) - console.log(chalk.yellow("Detected symlink, trying to dereference before committing")) - - // stash symlink file - await stashContentFolder(contentFolder) - - // follow symlink and copy content - await fs.promises.cp(linkTarg, contentFolder, { - recursive: true, - preserveTimestamps: true, - }) - } - - const currentTimestamp = new Date().toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - spawnSync("git", ["add", "."], { stdio: "inherit" }) - spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" }) - - if (contentStat.isSymbolicLink()) { - // put symlink back - await popContentFolder(contentFolder) - } - } - - await stashContentFolder(contentFolder) - - if (argv.pull) { - console.log( - "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.", - ) - gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) - } - - await popContentFolder(contentFolder) - if (argv.push) { - console.log("Pushing your changes") - spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" }) - } - - console.log(chalk.green("Done!")) + await handleSync(argv) }) .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { - console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) - const ctx = await esbuild.context({ - entryPoints: [fp], - outfile: path.join("quartz", cacheFile), - bundle: true, - keepNames: true, - minifyWhitespace: true, - minifySyntax: true, - platform: "node", - format: "esm", - jsx: "automatic", - jsxImportSource: "preact", - packages: "external", - metafile: true, - sourcemap: true, - sourcesContent: false, - plugins: [ - sassPlugin({ - type: "css-text", - cssImports: true, - }), - { - name: "inline-script-loader", - setup(build) { - build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { - let text = await promises.readFile(args.path, "utf8") - - // remove default exports that we manually inserted - text = text.replace("export default", "") - text = text.replace("export", "") - - const sourcefile = path.relative(path.resolve("."), args.path) - const resolveDir = path.dirname(sourcefile) - const transpiled = await esbuild.build({ - stdin: { - contents: text, - loader: "ts", - resolveDir, - sourcefile, - }, - write: false, - bundle: true, - platform: "browser", - format: "esm", - }) - const rawMod = transpiled.outputFiles[0].text - return { - contents: rawMod, - loader: "text", - } - }) - }, - }, - ], - }) - - const buildMutex = new Mutex() - let lastBuildMs = 0 - let cleanupBuild = null - const build = async (clientRefresh) => { - const buildStart = new Date().getTime() - lastBuildMs = buildStart - const release = await buildMutex.acquire() - if (lastBuildMs > buildStart) { - release() - return - } - - if (cleanupBuild) { - await cleanupBuild() - console.log(chalk.yellow("Detected a source code change, doing a hard rebuild...")) - } - - const result = await ctx.rebuild().catch((err) => { - console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) - console.log(`Reason: ${chalk.grey(err)}`) - process.exit(1) - }) - release() - - if (argv.bundleInfo) { - const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" - const meta = result.metafile.outputs[outputFileName] - console.log( - `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( - meta.bytes, - )})`, - ) - console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) - } - - // bypass module cache - // https://github.com/nodejs/modules/issues/307 - const { default: buildQuartz } = await import(cacheFile + `?update=${randomUUID()}`) - cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh) - clientRefresh() - } - - if (argv.serve) { - const connections = [] - const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) - - if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { - argv.baseDir = "/" + argv.baseDir - } - - await build(clientRefresh) - const server = http.createServer(async (req, res) => { - if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) { - console.log( - chalk.red( - `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`, - ), - ) - res.writeHead(404) - res.end() - return - } - - // strip baseDir prefix - req.url = req.url?.slice(argv.baseDir.length) - - const serve = async () => { - const release = await buildMutex.acquire() - await serveHandler(req, res, { - public: argv.output, - directoryListing: false, - headers: [ - { - source: "**/*.html", - headers: [{ key: "Content-Disposition", value: "inline" }], - }, - ], - }) - const status = res.statusCode - const statusString = - status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`) - console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`)) - release() - } - - const redirect = (newFp) => { - newFp = argv.baseDir + newFp - res.writeHead(302, { - Location: newFp, - }) - console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`)) - res.end() - } - - let fp = req.url?.split("?")[0] ?? "/" - - // handle redirects - if (fp.endsWith("/")) { - // /trailing/ - // does /trailing/index.html exist? if so, serve it - const indexFp = path.posix.join(fp, "index.html") - if (fs.existsSync(path.posix.join(argv.output, indexFp))) { - req.url = fp - return serve() - } - - // does /trailing.html exist? if so, redirect to /trailing - let base = fp.slice(0, -1) - if (path.extname(base) === "") { - base += ".html" - } - if (fs.existsSync(path.posix.join(argv.output, base))) { - return redirect(fp.slice(0, -1)) - } - } else { - // /regular - // does /regular.html exist? if so, serve it - let base = fp - if (path.extname(base) === "") { - base += ".html" - } - if (fs.existsSync(path.posix.join(argv.output, base))) { - req.url = fp - return serve() - } - - // does /regular/index.html exist? if so, redirect to /regular/ - let indexFp = path.posix.join(fp, "index.html") - if (fs.existsSync(path.posix.join(argv.output, indexFp))) { - return redirect(fp + "/") - } - } - - return serve() - }) - server.listen(argv.port) - const wss = new WebSocketServer({ port: 3001 }) - wss.on("connection", (ws) => connections.push(ws)) - console.log( - chalk.cyan( - `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, - ), - ) - console.log("hint: exit with ctrl+c") - chokidar - .watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], { - ignoreInitial: true, - }) - .on("all", async () => { - build(clientRefresh) - }) - } else { - await build(() => {}) - ctx.dispose() - } + await handleBuild(argv) }) .showHelpOnFail(false) .help() diff --git a/quartz/cli/args.js b/quartz/cli/args.js new file mode 100644 index 0000000000000..4f330cd9e65b0 --- /dev/null +++ b/quartz/cli/args.js @@ -0,0 +1,88 @@ +export const CommonArgv = { + directory: { + string: true, + alias: ["d"], + default: "content", + describe: "directory to look for content files", + }, + verbose: { + boolean: true, + alias: ["v"], + default: false, + describe: "print out extra logging information", + }, +} + +export const CreateArgv = { + ...CommonArgv, + source: { + string: true, + alias: ["s"], + describe: "source directory to copy/create symlink from", + }, + strategy: { + string: true, + alias: ["X"], + choices: ["new", "copy", "symlink"], + describe: "strategy for content folder setup", + }, + links: { + string: true, + alias: ["l"], + choices: ["absolute", "shortest", "relative"], + describe: "strategy to resolve links", + }, +} + +export const SyncArgv = { + ...CommonArgv, + commit: { + boolean: true, + default: true, + describe: "create a git commit for your unsaved changes", + }, + push: { + boolean: true, + default: true, + describe: "push updates to your Quartz fork", + }, + pull: { + boolean: true, + default: true, + describe: "pull updates from your Quartz fork", + }, +} + +export const BuildArgv = { + ...CommonArgv, + output: { + string: true, + alias: ["o"], + default: "public", + describe: "output folder for files", + }, + serve: { + boolean: true, + default: false, + describe: "run a local server to live-preview your Quartz", + }, + baseDir: { + string: true, + default: "", + describe: "base path to serve your local server on", + }, + port: { + number: true, + default: 8080, + describe: "port to serve Quartz on", + }, + bundleInfo: { + boolean: true, + default: false, + describe: "show detailed bundle information", + }, + concurrency: { + number: true, + describe: "how many threads to use to parse notes", + }, +} diff --git a/quartz/cli/constants.js b/quartz/cli/constants.js new file mode 100644 index 0000000000000..f4a9ce52b3660 --- /dev/null +++ b/quartz/cli/constants.js @@ -0,0 +1,15 @@ +import path from "path" +import { readFileSync } from "fs" + +/** + * All constants relating to helpers or handlers + */ +export const ORIGIN_NAME = "origin" +export const UPSTREAM_NAME = "upstream" +export const QUARTZ_SOURCE_BRANCH = "v4" +export const cwd = process.cwd() +export const cacheDir = path.join(cwd, ".quartz-cache") +export const cacheFile = "./quartz/.quartz-cache/transpiled-build.mjs" +export const fp = "./quartz/build.ts" +export const { version } = JSON.parse(readFileSync("./package.json").toString()) +export const contentCacheFolder = path.join(cacheDir, "content-cache") diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js new file mode 100644 index 0000000000000..cba0ceb268cd4 --- /dev/null +++ b/quartz/cli/handlers.js @@ -0,0 +1,511 @@ +import { promises, readFileSync } from "fs" +import path from "path" +import esbuild from "esbuild" +import chalk from "chalk" +import { sassPlugin } from "esbuild-sass-plugin" +import fs from "fs" +import { intro, outro, select, text } from "@clack/prompts" +import { rimraf } from "rimraf" +import chokidar from "chokidar" +import prettyBytes from "pretty-bytes" +import { execSync, spawnSync } from "child_process" +import http from "http" +import serveHandler from "serve-handler" +import { WebSocketServer } from "ws" +import { randomUUID } from "crypto" +import { Mutex } from "async-mutex" +import { CreateArgv } from "./args.js" +import { + exitIfCancel, + escapePath, + gitPull, + popContentFolder, + stashContentFolder, +} from "./helpers.js" +import { + UPSTREAM_NAME, + QUARTZ_SOURCE_BRANCH, + ORIGIN_NAME, + version, + fp, + cacheFile, + cwd, +} from "./constants.js" + +/** + * Handles `npx quartz create` + * @param {*} argv arguments for `create` + */ +export async function handleCreate(argv) { + console.log() + intro(chalk.bgGreen.black(` Quartz v${version} `)) + const contentFolder = path.join(cwd, argv.directory) + let setupStrategy = argv.strategy?.toLowerCase() + let linkResolutionStrategy = argv.links?.toLowerCase() + const sourceDirectory = argv.source + + // If all cmd arguments were provided, check if theyre valid + if (setupStrategy && linkResolutionStrategy) { + // If setup isn't, "new", source argument is required + if (setupStrategy !== "new") { + // Error handling + if (!sourceDirectory) { + outro( + chalk.red( + `Setup strategies (arg '${chalk.yellow( + `-${CreateArgv.strategy.alias[0]}`, + )}') other than '${chalk.yellow( + "new", + )}' require content folder argument ('${chalk.yellow( + `-${CreateArgv.source.alias[0]}`, + )}') to be set`, + ), + ) + process.exit(1) + } else { + if (!fs.existsSync(sourceDirectory)) { + outro( + chalk.red( + `Input directory to copy/symlink 'content' from not found ('${chalk.yellow( + sourceDirectory, + )}', invalid argument "${chalk.yellow(`-${CreateArgv.source.alias[0]}`)})`, + ), + ) + process.exit(1) + } else if (!fs.lstatSync(sourceDirectory).isDirectory()) { + outro( + chalk.red( + `Source directory to copy/symlink 'content' from is not a directory (found file at '${chalk.yellow( + sourceDirectory, + )}', invalid argument ${chalk.yellow(`-${CreateArgv.source.alias[0]}`)}")`, + ), + ) + process.exit(1) + } + } + } + } + + // Use cli process if cmd args werent provided + if (!setupStrategy) { + setupStrategy = exitIfCancel( + await select({ + message: `Choose how to initialize the content in \`${contentFolder}\``, + options: [ + { value: "new", label: "Empty Quartz" }, + { value: "copy", label: "Copy an existing folder", hint: "overwrites `content`" }, + { + value: "symlink", + label: "Symlink an existing folder", + hint: "don't select this unless you know what you are doing!", + }, + ], + }), + ) + } + + async function rmContentFolder() { + const contentStat = await fs.promises.lstat(contentFolder) + if (contentStat.isSymbolicLink()) { + await fs.promises.unlink(contentFolder) + } else { + await rimraf(contentFolder) + } + } + + await fs.promises.unlink(path.join(contentFolder, ".gitkeep")) + if (setupStrategy === "copy" || setupStrategy === "symlink") { + let originalFolder = sourceDirectory + + // If input directory was not passed, use cli + if (!sourceDirectory) { + originalFolder = escapePath( + exitIfCancel( + await text({ + message: "Enter the full path to existing content folder", + placeholder: + "On most terminal emulators, you can drag and drop a folder into the window and it will paste the full path", + validate(fp) { + const fullPath = escapePath(fp) + if (!fs.existsSync(fullPath)) { + return "The given path doesn't exist" + } else if (!fs.lstatSync(fullPath).isDirectory()) { + return "The given path is not a folder" + } + }, + }), + ), + ) + } + + await rmContentFolder() + if (setupStrategy === "copy") { + await fs.promises.cp(originalFolder, contentFolder, { + recursive: true, + preserveTimestamps: true, + }) + } else if (setupStrategy === "symlink") { + await fs.promises.symlink(originalFolder, contentFolder, "dir") + } + } else if (setupStrategy === "new") { + await fs.promises.writeFile( + path.join(contentFolder, "index.md"), + `--- +title: Welcome to Quartz +--- + +This is a blank Quartz installation. +See the [documentation](https://quartz.jzhao.xyz) for how to get started. +`, + ) + } + + // Use cli process if cmd args werent provided + if (!linkResolutionStrategy) { + // get a preferred link resolution strategy + linkResolutionStrategy = exitIfCancel( + await select({ + message: `Choose how Quartz should resolve links in your content. You can change this later in \`quartz.config.ts\`.`, + options: [ + { + value: "absolute", + label: "Treat links as absolute path", + hint: "for content made for Quartz 3 and Hugo", + }, + { + value: "shortest", + label: "Treat links as shortest path", + hint: "for most Obsidian vaults", + }, + { + value: "relative", + label: "Treat links as relative paths", + hint: "for just normal Markdown files", + }, + ], + }), + ) + } + + // now, do config changes + const configFilePath = path.join(cwd, "quartz.config.ts") + let configContent = await fs.promises.readFile(configFilePath, { encoding: "utf-8" }) + configContent = configContent.replace( + /markdownLinkResolution: '(.+)'/, + `markdownLinkResolution: '${linkResolutionStrategy}'`, + ) + await fs.promises.writeFile(configFilePath, configContent) + + outro(`You're all set! Not sure what to do next? Try: + • Customizing Quartz a bit more by editing \`quartz.config.ts\` + • Running \`npx quartz build --serve\` to preview your Quartz locally + • Hosting your Quartz online (see: https://quartz.jzhao.xyz/hosting) +`) +} + +/** + * Handles `npx quartz build` + * @param {*} argv arguments for `build` + */ +export async function handleBuild(argv) { + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + const ctx = await esbuild.context({ + entryPoints: [fp], + outfile: cacheFile, + bundle: true, + keepNames: true, + minifyWhitespace: true, + minifySyntax: true, + platform: "node", + format: "esm", + jsx: "automatic", + jsxImportSource: "preact", + packages: "external", + metafile: true, + sourcemap: true, + sourcesContent: false, + plugins: [ + sassPlugin({ + type: "css-text", + cssImports: true, + }), + { + name: "inline-script-loader", + setup(build) { + build.onLoad({ filter: /\.inline\.(ts|js)$/ }, async (args) => { + let text = await promises.readFile(args.path, "utf8") + + // remove default exports that we manually inserted + text = text.replace("export default", "") + text = text.replace("export", "") + + const sourcefile = path.relative(path.resolve("."), args.path) + const resolveDir = path.dirname(sourcefile) + const transpiled = await esbuild.build({ + stdin: { + contents: text, + loader: "ts", + resolveDir, + sourcefile, + }, + write: false, + bundle: true, + platform: "browser", + format: "esm", + }) + const rawMod = transpiled.outputFiles[0].text + return { + contents: rawMod, + loader: "text", + } + }) + }, + }, + ], + }) + + const buildMutex = new Mutex() + let lastBuildMs = 0 + let cleanupBuild = null + const build = async (clientRefresh) => { + const buildStart = new Date().getTime() + lastBuildMs = buildStart + const release = await buildMutex.acquire() + if (lastBuildMs > buildStart) { + release() + return + } + + if (cleanupBuild) { + await cleanupBuild() + console.log(chalk.yellow("Detected a source code change, doing a hard rebuild...")) + } + + const result = await ctx.rebuild().catch((err) => { + console.error(`${chalk.red("Couldn't parse Quartz configuration:")} ${fp}`) + console.log(`Reason: ${chalk.grey(err)}`) + process.exit(1) + }) + release() + + if (argv.bundleInfo) { + const outputFileName = "quartz/.quartz-cache/transpiled-build.mjs" + const meta = result.metafile.outputs[outputFileName] + console.log( + `Successfully transpiled ${Object.keys(meta.inputs).length} files (${prettyBytes( + meta.bytes, + )})`, + ) + console.log(await esbuild.analyzeMetafile(result.metafile, { color: true })) + } + + // bypass module cache + // https://github.com/nodejs/modules/issues/307 + const { default: buildQuartz } = await import(`../../${cacheFile}?update=${randomUUID()}`) + // ^ this import is relative, so base "cacheFile" path can't be used + + cleanupBuild = await buildQuartz(argv, buildMutex, clientRefresh) + clientRefresh() + } + + if (argv.serve) { + const connections = [] + const clientRefresh = () => connections.forEach((conn) => conn.send("rebuild")) + + if (argv.baseDir !== "" && !argv.baseDir.startsWith("/")) { + argv.baseDir = "/" + argv.baseDir + } + + await build(clientRefresh) + const server = http.createServer(async (req, res) => { + if (argv.baseDir && !req.url?.startsWith(argv.baseDir)) { + console.log( + chalk.red( + `[404] ${req.url} (warning: link outside of site, this is likely a Quartz bug)`, + ), + ) + res.writeHead(404) + res.end() + return + } + + // strip baseDir prefix + req.url = req.url?.slice(argv.baseDir.length) + + const serve = async () => { + const release = await buildMutex.acquire() + await serveHandler(req, res, { + public: argv.output, + directoryListing: false, + headers: [ + { + source: "**/*.html", + headers: [{ key: "Content-Disposition", value: "inline" }], + }, + ], + }) + const status = res.statusCode + const statusString = + status >= 200 && status < 300 ? chalk.green(`[${status}]`) : chalk.red(`[${status}]`) + console.log(statusString + chalk.grey(` ${argv.baseDir}${req.url}`)) + release() + } + + const redirect = (newFp) => { + newFp = argv.baseDir + newFp + res.writeHead(302, { + Location: newFp, + }) + console.log(chalk.yellow("[302]") + chalk.grey(` ${argv.baseDir}${req.url} -> ${newFp}`)) + res.end() + } + + let fp = req.url?.split("?")[0] ?? "/" + + // handle redirects + if (fp.endsWith("/")) { + // /trailing/ + // does /trailing/index.html exist? if so, serve it + const indexFp = path.posix.join(fp, "index.html") + if (fs.existsSync(path.posix.join(argv.output, indexFp))) { + req.url = fp + return serve() + } + + // does /trailing.html exist? if so, redirect to /trailing + let base = fp.slice(0, -1) + if (path.extname(base) === "") { + base += ".html" + } + if (fs.existsSync(path.posix.join(argv.output, base))) { + return redirect(fp.slice(0, -1)) + } + } else { + // /regular + // does /regular.html exist? if so, serve it + let base = fp + if (path.extname(base) === "") { + base += ".html" + } + if (fs.existsSync(path.posix.join(argv.output, base))) { + req.url = fp + return serve() + } + + // does /regular/index.html exist? if so, redirect to /regular/ + let indexFp = path.posix.join(fp, "index.html") + if (fs.existsSync(path.posix.join(argv.output, indexFp))) { + return redirect(fp + "/") + } + } + + return serve() + }) + server.listen(argv.port) + const wss = new WebSocketServer({ port: 3001 }) + wss.on("connection", (ws) => connections.push(ws)) + console.log( + chalk.cyan( + `Started a Quartz server listening at http://localhost:${argv.port}${argv.baseDir}`, + ), + ) + console.log("hint: exit with ctrl+c") + chokidar + .watch(["**/*.ts", "**/*.tsx", "**/*.scss", "package.json"], { + ignoreInitial: true, + }) + .on("all", async () => { + build(clientRefresh) + }) + } else { + await build(() => {}) + ctx.dispose() + } +} + +/** + * Handles `npx quartz update` + * @param {*} argv arguments for `update` + */ +export async function handleUpdate(argv) { + const contentFolder = path.join(cwd, argv.directory) + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + console.log("Backing up your content") + execSync( + `git remote show upstream || git remote add upstream https://github.com/jackyzha0/quartz.git`, + ) + await stashContentFolder(contentFolder) + console.log( + "Pulling updates... you may need to resolve some `git` conflicts if you've made changes to components or plugins.", + ) + gitPull(UPSTREAM_NAME, QUARTZ_SOURCE_BRANCH) + await popContentFolder(contentFolder) + console.log("Ensuring dependencies are up to date") + spawnSync("npm", ["i"], { stdio: "inherit" }) + console.log(chalk.green("Done!")) +} + +/** + * Handles `npx quartz restore` + * @param {*} argv arguments for `restore` + */ +export async function handleRestore(argv) { + const contentFolder = path.join(cwd, argv.directory) + await popContentFolder(contentFolder) +} + +/** + * Handles `npx quartz sync` + * @param {*} argv arguments for `sync` + */ +export async function handleSync(argv) { + const contentFolder = path.join(cwd, argv.directory) + console.log(chalk.bgGreen.black(`\n Quartz v${version} \n`)) + console.log("Backing up your content") + + if (argv.commit) { + const contentStat = await fs.promises.lstat(contentFolder) + if (contentStat.isSymbolicLink()) { + const linkTarg = await fs.promises.readlink(contentFolder) + console.log(chalk.yellow("Detected symlink, trying to dereference before committing")) + + // stash symlink file + await stashContentFolder(contentFolder) + + // follow symlink and copy content + await fs.promises.cp(linkTarg, contentFolder, { + recursive: true, + preserveTimestamps: true, + }) + } + + const currentTimestamp = new Date().toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + spawnSync("git", ["add", "."], { stdio: "inherit" }) + spawnSync("git", ["commit", "-m", `Quartz sync: ${currentTimestamp}`], { stdio: "inherit" }) + + if (contentStat.isSymbolicLink()) { + // put symlink back + await popContentFolder(contentFolder) + } + } + + await stashContentFolder(contentFolder) + + if (argv.pull) { + console.log( + "Pulling updates from your repository. You may need to resolve some `git` conflicts if you've made changes to components or plugins.", + ) + gitPull(ORIGIN_NAME, QUARTZ_SOURCE_BRANCH) + } + + await popContentFolder(contentFolder) + if (argv.push) { + console.log("Pushing your changes") + spawnSync("git", ["push", "-f", ORIGIN_NAME, QUARTZ_SOURCE_BRANCH], { stdio: "inherit" }) + } + + console.log(chalk.green("Done!")) +} diff --git a/quartz/cli/helpers.js b/quartz/cli/helpers.js new file mode 100644 index 0000000000000..b07d19e3c4d8c --- /dev/null +++ b/quartz/cli/helpers.js @@ -0,0 +1,52 @@ +import { isCancel, outro } from "@clack/prompts" +import chalk from "chalk" +import { contentCacheFolder } from "./constants.js" +import { spawnSync } from "child_process" +import fs from "fs" + +export function escapePath(fp) { + return fp + .replace(/\\ /g, " ") // unescape spaces + .replace(/^".*"$/, "$1") + .replace(/^'.*"$/, "$1") + .trim() +} + +export function exitIfCancel(val) { + if (isCancel(val)) { + outro(chalk.red("Exiting")) + process.exit(0) + } else { + return val + } +} + +export async function stashContentFolder(contentFolder) { + await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) + await fs.promises.cp(contentFolder, contentCacheFolder, { + force: true, + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + }) + await fs.promises.rm(contentFolder, { force: true, recursive: true }) +} + +export function gitPull(origin, branch) { + const flags = ["--no-rebase", "--autostash", "-s", "recursive", "-X", "ours", "--no-edit"] + const out = spawnSync("git", ["pull", ...flags, origin, branch], { stdio: "inherit" }) + if (out.stderr) { + throw new Error(`Error while pulling updates: ${out.stderr}`) + } +} + +export async function popContentFolder(contentFolder) { + await fs.promises.rm(contentFolder, { force: true, recursive: true }) + await fs.promises.cp(contentCacheFolder, contentFolder, { + force: true, + recursive: true, + verbatimSymlinks: true, + preserveTimestamps: true, + }) + await fs.promises.rm(contentCacheFolder, { force: true, recursive: true }) +} From b6b1dabde0f63ca0ae743aa7f4266ca892d7b5e5 Mon Sep 17 00:00:00 2001 From: Jeremy Press Date: Sun, 27 Aug 2023 17:39:42 -0700 Subject: [PATCH 010/205] feat: support configurable ws port and remote development (#429) Co-authored-by: Jeremy Press Co-authored-by: Jacky Zhao --- .gitignore | 2 ++ quartz/cli/args.js | 10 ++++++++++ quartz/cli/handlers.js | 2 +- quartz/plugins/emitters/componentResources.ts | 8 +++++++- quartz/util/ctx.ts | 2 ++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index fd96fec90a05f..25d07db1cb149 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ tsconfig.tsbuildinfo .obsidian .quartz-cache private/ +.replit +replit.nix diff --git a/quartz/cli/args.js b/quartz/cli/args.js index 4f330cd9e65b0..3543e2e89f8b7 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -76,6 +76,16 @@ export const BuildArgv = { default: 8080, describe: "port to serve Quartz on", }, + wsPort: { + number: true, + default: 3001, + describe: "port to use for WebSocket-based hot-reload notifications", + }, + remoteDevHost: { + string: true, + default: "", + describe: "A URL override for the websocket connection if you are not developing on localhost", + }, bundleInfo: { boolean: true, default: false, diff --git a/quartz/cli/handlers.js b/quartz/cli/handlers.js index cba0ceb268cd4..bc3da73f3c0d1 100644 --- a/quartz/cli/handlers.js +++ b/quartz/cli/handlers.js @@ -402,7 +402,7 @@ export async function handleBuild(argv) { return serve() }) server.listen(argv.port) - const wss = new WebSocketServer({ port: 3001 }) + const wss = new WebSocketServer({ port: argv.wsPort }) wss.on("connection", (ws) => connections.push(ws)) console.log( chalk.cyan( diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index a62bc382bb04d..61409cc574bb3 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -107,12 +107,18 @@ function addGlobalPageResources( document.dispatchEvent(event)`) } + let wsUrl = `ws://localhost:${ctx.argv.wsPort}` + + if (ctx.argv.remoteDevHost) { + wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}` + } + if (reloadScript) { staticResources.js.push({ loadTime: "afterDOMReady", contentType: "inline", script: ` - const socket = new WebSocket('ws://localhost:3001') + const socket = new WebSocket('${wsUrl}'') socket.addEventListener('message', () => document.location.reload()) `, }) diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index d3033919012b6..13e0bf8644591 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -7,6 +7,8 @@ export interface Argv { output: string serve: boolean port: number + wsPort: number + remoteDevHost?: string concurrency?: number } From 082fdf2e8098ef6bcb46a7dfabf8c6b9fd096346 Mon Sep 17 00:00:00 2001 From: Jeremy Press Date: Sun, 27 Aug 2023 20:57:19 -0700 Subject: [PATCH 011/205] Fix typo :) (#430) --- quartz/plugins/emitters/componentResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 61409cc574bb3..c52a3a20ef048 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -118,7 +118,7 @@ function addGlobalPageResources( loadTime: "afterDOMReady", contentType: "inline", script: ` - const socket = new WebSocket('${wsUrl}'') + const socket = new WebSocket('${wsUrl}') socket.addEventListener('message', () => document.location.reload()) `, }) From c35cd422c65a58f1069302aad0cf9eef7f93d987 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 28 Aug 2023 19:00:49 +0200 Subject: [PATCH 012/205] fix: correct graph labels for `index.md` nodes (#431) --- quartz/components/scripts/graph.inline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index e589217f22816..d72b297bfe8cd 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -231,7 +231,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) { .attr("dy", (d) => -nodeRadius(d) + "px") .attr("text-anchor", "middle") .text( - (d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "), + (d) => + data[d.id]?.title || + (d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "), ) .style("opacity", (opacityScale - 1) / 3.75) .style("pointer-events", "none") From 1cc09ef76db129fb3670e95560312adeefab913c Mon Sep 17 00:00:00 2001 From: Jeffrey Fabian Date: Tue, 29 Aug 2023 13:14:54 -0400 Subject: [PATCH 013/205] feat: support kebab-case and nested tags in Obsidian-flavored Markdown tag-in-content parsing (#425) * enhancement: support kebab-case and nested tags in ofm transformer * update regex/capture groups to allow for (arbitrarily) nested values and tags of only -/_ * Update quartz/plugins/transformers/ofm.ts --------- Co-authored-by: Jacky Zhao --- quartz/plugins/transformers/ofm.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 4d1586f935a52..2e8fadb227f6e 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -114,9 +114,11 @@ const commentRegex = new RegExp(/%%(.+)%%/, "g") // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") -// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line -// #(\w+) -> tag itself is # followed by a string of alpha-numeric characters -const tagRegex = new RegExp(/(?:^| )#(\p{L}+)/, "gu") +// (?:^| ) -> non-capturing group, tag should start be separated by a space or be the start of the line +// #(...) -> capturing group, tag itself must start with # +// (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores +// (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" +const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -320,7 +322,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const titleHtml: HTML = { type: "html", - value: `
${callouts[calloutType]}
@@ -429,7 +431,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin script: ` import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' - mermaid.initialize({ + mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: darkMode ? 'dark' : 'default' From 5fa6fc97899c905b6fbc14fa1d24334f3e68fa77 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 29 Aug 2023 10:37:00 -0700 Subject: [PATCH 014/205] fix: aliasredirects not using full path, add permalink support --- quartz/plugins/emitters/aliases.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index c7294a34390b3..942412e9de37d 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -12,15 +12,20 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ for (const [_tree, file] of content) { const ogSlug = simplifySlug(file.data.slug!) - const dir = path.posix.relative(argv.directory, file.dirname ?? argv.directory) + const dir = path.posix.relative(argv.directory, path.dirname(file.data.filePath!)) let aliases: FullSlug[] = file.data.frontmatter?.aliases ?? file.data.frontmatter?.alias ?? [] if (typeof aliases === "string") { aliases = [aliases] } - for (const alias of aliases) { - const slug = path.posix.join(dir, alias) as FullSlug + const slugs: FullSlug[] = aliases.map((alias) => path.posix.join(dir, alias) as FullSlug) + const permalink = file.data.frontmatter?.permalink + if (typeof permalink === "string") { + slugs.push(permalink as FullSlug) + } + + for (const slug of slugs) { const redirUrl = resolveRelative(slug, file.data.slug!) const fp = await emit({ content: ` From b213ba45e2e706332e057b131adc946f882f090b Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Thu, 31 Aug 2023 20:55:04 +0200 Subject: [PATCH 015/205] fix: regex for matching highlights (closes #437) (#438) * fix: regex for matching highlights * fix: regex for empty highlights --- quartz/plugins/transformers/ofm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 2e8fadb227f6e..8c8da67bca2b2 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -109,7 +109,7 @@ const capitalize = (s: string): string => { // (#[^\[\]\|\#]+)? -> # then one or more non-special characters (heading link) // (|[^\[\]\|\#]+)? -> | then one or more non-special characters (alias) const wikilinkRegex = new RegExp(/!?\[\[([^\[\]\|\#]+)?(#[^\[\]\|\#]+)?(\|[^\[\]\|\#]+)?\]\]/, "g") -const highlightRegex = new RegExp(/==(.+)==/, "g") +const highlightRegex = new RegExp(/==([^=]+)==/, "g") const commentRegex = new RegExp(/%%(.+)%%/, "g") // from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts const calloutRegex = new RegExp(/^\[\!(\w+)\]([+-]?)/) From 2d6dc176c3e1fbb520a5da1beb60bbb1d8e948ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pelayo=20Arbu=C3=A9s?= Date: Thu, 31 Aug 2023 21:12:06 +0200 Subject: [PATCH 016/205] Adds Pelayo Arbues to showcase (#435) --- docs/showcase.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/showcase.md b/docs/showcase.md index d4a9da2b99eb3..d2282be25928e 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -16,5 +16,6 @@ Want to see what Quartz can do? Here are some cool community gardens: - [Abhijeet's Math Wiki](https://abhmul.github.io/quartz/Math-Wiki/) - [Mike's AI Garden 🤖🪴](https://mwalton.me/) - [Matt Dunn's Second Brain](https://mattdunn.info/) +- [Pelayo Arbues' Notes](https://pelayoarbues.github.io/) 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/content/showcase.md)! From 90dac31216b5d3f59e65ec5778e21a308a744e11 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:09:58 +0200 Subject: [PATCH 017/205] feat: Implement search for tags (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Quartz sync: Aug 29, 2023, 10:17 PM * style: add basic style to tags in search * feat: add SearchType + tags to search preview * feat: support multiple matches * style(search): add style to matching tags * feat(search): add content to preview for tag search * fix: only display tags on tag search * feat: support basic + tag search * refactor: extract common `fillDocument`, format * feat: add hotkey to search for tags * chore: remove logs * fix: dont render empty `
    ` if tags not present * fix(search-tag): make case insensitive * refactor: clean `hideSearch` and `showSearch` * feat: trim content similar to `description.ts` * fix(search-tag): hotkey for windows * perf: re-use main index for tag search --- quartz/components/scripts/search.inline.ts | 163 ++++++++++++++++++--- quartz/components/styles/search.scss | 38 +++++ 2 files changed, 179 insertions(+), 22 deletions(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index ef26ba380615c..806a746e6435c 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -1,4 +1,4 @@ -import { Document } from "flexsearch" +import { Document, SimpleDocumentSearchResultSetUnit } from "flexsearch" import { ContentDetails } from "../../plugins/emitters/contentIndex" import { registerEscapeHandler, removeAllChildren } from "./util" import { FullSlug, resolveRelative } from "../../util/path" @@ -8,12 +8,20 @@ interface Item { slug: FullSlug title: string content: string + tags: string[] } let index: Document | undefined = undefined +// Can be expanded with things like "term" in the future +type SearchType = "basic" | "tags" + +// Current searchType +let searchType: SearchType = "basic" + const contextWindowWords = 30 const numSearchResults = 5 +const numTagResults = 3 function highlight(searchTerm: string, text: string, trim?: boolean) { // try to highlight longest tokens first const tokenizedTerms = searchTerm @@ -87,9 +95,12 @@ document.addEventListener("nav", async (e: unknown) => { if (results) { removeAllChildren(results) } + + searchType = "basic" // reset search type after closing } - function showSearch() { + function showSearch(searchTypeNew: SearchType) { + searchType = searchTypeNew if (sidebar) { sidebar.style.zIndex = "1" } @@ -98,10 +109,18 @@ document.addEventListener("nav", async (e: unknown) => { } function shortcutHandler(e: HTMLElementEventMap["keydown"]) { - if (e.key === "k" && (e.ctrlKey || e.metaKey)) { + if (e.key === "k" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { + e.preventDefault() + const searchBarOpen = container?.classList.contains("active") + searchBarOpen ? hideSearch() : showSearch("basic") + } else if (e.shiftKey && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + // Hotkey to open tag search e.preventDefault() const searchBarOpen = container?.classList.contains("active") - searchBarOpen ? hideSearch() : showSearch() + searchBarOpen ? hideSearch() : showSearch("tags") + + // add "#" prefix for tag search + if (searchBar) searchBar.value = "#" } else if (e.key === "Enter") { const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null if (anchor) { @@ -110,21 +129,77 @@ document.addEventListener("nav", async (e: unknown) => { } } + function trimContent(content: string) { + // works without escaping html like in `description.ts` + const sentences = content.replace(/\s+/g, " ").split(".") + let finalDesc = "" + let sentenceIdx = 0 + + // Roughly estimate characters by (words * 5). Matches description length in `description.ts`. + const len = contextWindowWords * 5 + while (finalDesc.length < len) { + const sentence = sentences[sentenceIdx] + if (!sentence) break + finalDesc += sentence + "." + sentenceIdx++ + } + + // If more content would be available, indicate it by finishing with "..." + if (finalDesc.length < content.length) { + finalDesc += ".." + } + + return finalDesc + } + const formatForDisplay = (term: string, id: number) => { const slug = idDataMap[id] return { id, slug, title: highlight(term, data[slug].title ?? ""), - content: highlight(term, data[slug].content ?? "", true), + // if searchType is tag, display context from start of file and trim, otherwise use regular highlight + content: + searchType === "tags" + ? trimContent(data[slug].content) + : highlight(term, data[slug].content ?? "", true), + tags: highlightTags(term, data[slug].tags), + } + } + + function highlightTags(term: string, tags: string[]) { + if (tags && searchType === "tags") { + // Find matching tags + const termLower = term.toLowerCase() + let matching = tags.filter((str) => str.includes(termLower)) + + // Substract matching from original tags, then push difference + if (matching.length > 0) { + let difference = tags.filter((x) => !matching.includes(x)) + + // Convert to html (cant be done later as matches/term dont get passed to `resultToHTML`) + matching = matching.map((tag) => `
  • #${tag}

  • `) + difference = difference.map((tag) => `
  • #${tag}

  • `) + matching.push(...difference) + } + + // Only allow max of `numTagResults` in preview + if (tags.length > numTagResults) { + matching.splice(numTagResults) + } + + return matching + } else { + return [] } } - const resultToHTML = ({ slug, title, content }: Item) => { + const resultToHTML = ({ slug, title, content, tags }: Item) => { + const htmlTags = tags.length > 0 ? `
      ${tags.join("")}
    ` : `` const button = document.createElement("button") button.classList.add("result-card") button.id = slug - button.innerHTML = `

    ${title}

    ${content}

    ` + button.innerHTML = `

    ${title}

    ${htmlTags}

    ${content}

    ` button.addEventListener("click", () => { const targ = resolveRelative(currentSlug, slug) window.spaNavigate(new URL(targ, window.location.toString())) @@ -148,15 +223,45 @@ document.addEventListener("nav", async (e: unknown) => { } async function onType(e: HTMLElementEventMap["input"]) { - const term = (e.target as HTMLInputElement).value - const searchResults = (await index?.searchAsync(term, numSearchResults)) ?? [] + let term = (e.target as HTMLInputElement).value + let searchResults: SimpleDocumentSearchResultSetUnit[] + + if (term.toLowerCase().startsWith("#")) { + searchType = "tags" + } else { + searchType = "basic" + } + + switch (searchType) { + case "tags": { + term = term.substring(1) + searchResults = + (await index?.searchAsync({ query: term, limit: numSearchResults, index: ["tags"] })) ?? + [] + break + } + case "basic": + default: { + searchResults = + (await index?.searchAsync({ + query: term, + limit: numSearchResults, + index: ["title", "content"], + })) ?? [] + } + } + const getByField = (field: string): number[] => { const results = searchResults.filter((x) => x.field === field) return results.length === 0 ? [] : ([...results[0].result] as number[]) } // order titles ahead of content - const allIds: Set = new Set([...getByField("title"), ...getByField("content")]) + const allIds: Set = new Set([ + ...getByField("title"), + ...getByField("content"), + ...getByField("tags"), + ]) const finalResults = [...allIds].map((id) => formatForDisplay(term, id)) displayResults(finalResults) } @@ -167,8 +272,8 @@ document.addEventListener("nav", async (e: unknown) => { document.addEventListener("keydown", shortcutHandler) prevShortcutHandler = shortcutHandler - searchIcon?.removeEventListener("click", showSearch) - searchIcon?.addEventListener("click", showSearch) + searchIcon?.removeEventListener("click", () => showSearch("basic")) + searchIcon?.addEventListener("click", () => showSearch("basic")) searchBar?.removeEventListener("input", onType) searchBar?.addEventListener("input", onType) @@ -190,22 +295,36 @@ document.addEventListener("nav", async (e: unknown) => { field: "content", tokenize: "reverse", }, + { + field: "tags", + tokenize: "reverse", + }, ], }, }) - let id = 0 - for (const [slug, fileData] of Object.entries(data)) { - await index.addAsync(id, { - id, - slug: slug as FullSlug, - title: fileData.title, - content: fileData.content, - }) - id++ - } + fillDocument(index, data) } // register handlers registerEscapeHandler(container, hideSearch) }) + +/** + * Fills flexsearch document with data + * @param index index to fill + * @param data data to fill index with + */ +async function fillDocument(index: Document, data: any) { + let id = 0 + for (const [slug, fileData] of Object.entries(data)) { + await index.addAsync(id, { + id, + slug: slug as FullSlug, + title: fileData.title, + content: fileData.content, + tags: fileData.tags, + }) + id++ + } +} diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 4d5ad95cd6121..66f809f97f747 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -130,6 +130,44 @@ margin: 0; } + & > ul > li { + margin: 0; + display: inline-block; + white-space: nowrap; + margin: 0; + overflow-wrap: normal; + } + + & > ul { + list-style: none; + display: flex; + padding-left: 0; + gap: 0.4rem; + margin: 0; + margin-top: 0.45rem; + // Offset border radius + margin-left: -2px; + overflow: hidden; + background-clip: border-box; + } + + & > ul > li > p { + border-radius: 8px; + background-color: var(--highlight); + overflow: hidden; + background-clip: border-box; + padding: 0.03rem 0.4rem; + margin: 0; + color: var(--secondary); + opacity: 0.85; + } + + & > ul > li > .match-tag { + color: var(--tertiary); + font-weight: bold; + opacity: 1; + } + & > p { margin-bottom: 0; } From 23f43045c49f17fe5ace480f7026855acf2a30b8 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 1 Sep 2023 23:12:32 +0200 Subject: [PATCH 018/205] fix(search): matches getting highlighted in title (#440) --- quartz/components/scripts/search.inline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 806a746e6435c..4b9e372bc7aed 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -157,7 +157,7 @@ document.addEventListener("nav", async (e: unknown) => { return { id, slug, - title: highlight(term, data[slug].title ?? ""), + title: searchType === "tags" ? data[slug].title : highlight(term, data[slug].title ?? ""), // if searchType is tag, display context from start of file and trim, otherwise use regular highlight content: searchType === "tags" From 505673acd71e6b023abae19c706a736b257cff2a Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 2 Sep 2023 18:07:26 -0700 Subject: [PATCH 019/205] feat: pluralize things in lists --- quartz/components/pages/FolderContent.tsx | 3 ++- quartz/components/pages/TagContent.tsx | 5 +++-- quartz/util/lang.ts | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 quartz/util/lang.ts diff --git a/quartz/components/pages/FolderContent.tsx b/quartz/components/pages/FolderContent.tsx index dc076c4a10333..a766d4b0ba25e 100644 --- a/quartz/components/pages/FolderContent.tsx +++ b/quartz/components/pages/FolderContent.tsx @@ -7,6 +7,7 @@ import style from "../styles/listPage.scss" import { PageList } from "../PageList" import { _stripSlashes, simplifySlug } from "../../util/path" import { Root } from "hast" +import { pluralize } from "../../util/lang" function FolderContent(props: QuartzComponentProps) { const { tree, fileData, allFiles } = props @@ -36,7 +37,7 @@ function FolderContent(props: QuartzComponentProps) {

    {content}

    -

    {allPagesInFolder.length} items under this folder.

    +

    {pluralize(allPagesInFolder.length, "item")} under this folder.

    diff --git a/quartz/components/pages/TagContent.tsx b/quartz/components/pages/TagContent.tsx index fb72e284b1b1e..9907e3fc322ed 100644 --- a/quartz/components/pages/TagContent.tsx +++ b/quartz/components/pages/TagContent.tsx @@ -6,6 +6,7 @@ import { PageList } from "../PageList" import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path" import { QuartzPluginData } from "../../plugins/vfile" import { Root } from "hast" +import { pluralize } from "../../util/lang" const numPages = 10 function TagContent(props: QuartzComponentProps) { @@ -60,7 +61,7 @@ function TagContent(props: QuartzComponentProps) { {content &&

    {content}

    }

    - {pages.length} items with this tag.{" "} + {pluralize(pages.length, "item")} with this tag.{" "} {pages.length > numPages && `Showing first ${numPages}.`}

    @@ -80,7 +81,7 @@ function TagContent(props: QuartzComponentProps) { return (
    {content}
    -

    {pages.length} items with this tag.

    +

    {pluralize(pages.length, "item")} with this tag.

    diff --git a/quartz/util/lang.ts b/quartz/util/lang.ts new file mode 100644 index 0000000000000..eb03a24361bbb --- /dev/null +++ b/quartz/util/lang.ts @@ -0,0 +1,7 @@ +export function pluralize(count: number, s: string): string { + if (count === 1) { + return `1 ${s}` + } else { + return `${count} ${s}s` + } +} From 8c354f6261dda6d9761f594002db53c9d7a8e8e2 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:06:05 +0200 Subject: [PATCH 020/205] fix: clipboard button visible in search (#445) --- quartz/components/styles/clipboard.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/quartz/components/styles/clipboard.scss b/quartz/components/styles/clipboard.scss index 1702a7bb4bddc..a585c7b52d479 100644 --- a/quartz/components/styles/clipboard.scss +++ b/quartz/components/styles/clipboard.scss @@ -10,7 +10,6 @@ background-color: var(--light); border: 1px solid; border-radius: 5px; - z-index: 1; opacity: 0; transition: 0.2s; From 7e42be8e46501c752dda9bd174fb93ea9dccec22 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:32:46 +0200 Subject: [PATCH 021/205] feat(search): add arrow key navigation (#442) * feat(search): add arrow navigation * chore: format * refactor: simplify arrow navigation * chore: remove comment * feat: rework arrow navigation to work without state * feat: make pressing enter work with arrow navigation * fix: remove unused css class * chore: correct comment * refactor(search): use optional chaining --- quartz/components/scripts/search.inline.ts | 29 +++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index 4b9e372bc7aed..a1c3e6ca2fbf2 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -82,6 +82,7 @@ document.addEventListener("nav", async (e: unknown) => { const searchIcon = document.getElementById("search-icon") const searchBar = document.getElementById("search-bar") as HTMLInputElement | null const results = document.getElementById("results-container") + const resultCards = document.getElementsByClassName("result-card") const idDataMap = Object.keys(data) as FullSlug[] function hideSearch() { @@ -122,9 +123,31 @@ document.addEventListener("nav", async (e: unknown) => { // add "#" prefix for tag search if (searchBar) searchBar.value = "#" } else if (e.key === "Enter") { - const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null - if (anchor) { - anchor.click() + // If result has focus, navigate to that one, otherwise pick first result + if (results?.contains(document.activeElement)) { + const active = document.activeElement as HTMLInputElement + active.click() + } else { + const anchor = document.getElementsByClassName("result-card")[0] as HTMLInputElement | null + anchor?.click() + } + } else if (e.key === "ArrowDown") { + e.preventDefault() + // When first pressing ArrowDown, results wont contain the active element, so focus first element + if (!results?.contains(document.activeElement)) { + const firstResult = resultCards[0] as HTMLInputElement | null + firstResult?.focus() + } else { + // If an element in results-container already has focus, focus next one + const nextResult = document.activeElement?.nextElementSibling as HTMLInputElement | null + nextResult?.focus() + } + } else if (e.key === "ArrowUp") { + e.preventDefault() + if (results?.contains(document.activeElement)) { + // If an element in results-container already has focus, focus previous one + const prevResult = document.activeElement?.previousElementSibling as HTMLInputElement | null + prevResult?.focus() } } } From e8a04efaf1b82560cbcf7694ac6c7dda1c82612f Mon Sep 17 00:00:00 2001 From: Adam Brangenberg Date: Mon, 4 Sep 2023 06:28:57 +0200 Subject: [PATCH 022/205] feat(analytics): Support for Umami (#449) --- quartz/cfg.ts | 4 ++++ quartz/plugins/emitters/componentResources.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 21e03016a644a..8371b5e2be713 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -12,6 +12,10 @@ export type Analytics = provider: "google" tagId: string } + | { + provider: "umami" + websiteId: string + } export interface GlobalConfiguration { pageTitle: string diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index c52a3a20ef048..96db8aa81e7c4 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -96,6 +96,15 @@ function addGlobalPageResources( });`) } else if (cfg.analytics?.provider === "plausible") { componentResources.afterDOMLoaded.push(plausibleScript) + } else if (cfg.analytics?.provider === "umami") { + componentResources.afterDOMLoaded.push(` + const umamiScript = document.createElement("script") + umamiScript.src = "https://analytics.umami.is/script.js" + umamiScript["data-website-id"] = "${cfg.analytics.websiteId}" + umamiScript.async = true + + document.head.appendChild(umamiScript) + `) } if (cfg.enableSPA) { From 616a7f148a283b2fd7c5204c60ddf1b08f42d125 Mon Sep 17 00:00:00 2001 From: Dr Kim Foale Date: Mon, 4 Sep 2023 05:29:58 +0100 Subject: [PATCH 023/205] docs: Make it clearer that wikilinks go to paths not page titles (#448) --- docs/features/wikilinks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/wikilinks.md b/docs/features/wikilinks.md index 4d197157d2fbc..704a0d0cfb80b 100644 --- a/docs/features/wikilinks.md +++ b/docs/features/wikilinks.md @@ -10,9 +10,9 @@ This is enabled as a part of [[Obsidian compatibility]] and can be configured an ## Syntax -- `[[Path to file]]`: produces a link to `Path to file` with the text `Path to file` -- `[[Path to file | Here's the title override]]`: produces a link to `Path to file` with the text `Here's the title override` -- `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file` +- `[[Path to file]]`: produces a link to `Path to file.md` (or `Path-to-file.md`) with the text `Path to file` +- `[[Path to file | Here's the title override]]`: produces a link to `Path to file.md` with the text `Here's the title override` +- `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file.md` > [!warning] > Currently, Quartz does not support block references or note embed syntax. From 6ef4246cf186414d1b8ee868cfa9d24314f0bc0a Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 4 Sep 2023 07:36:30 +0200 Subject: [PATCH 024/205] docs: update `full-text-search.md` (#447) --- docs/features/full-text search.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/features/full-text search.md b/docs/features/full-text search.md index ce3d88f93a554..85ec03006ca15 100644 --- a/docs/features/full-text search.md +++ b/docs/features/full-text search.md @@ -6,9 +6,11 @@ tags: Full-text search in Quartz is powered by [Flexsearch](https://github.com/nextapps-de/flexsearch). It's fast enough to return search results in under 10ms for Quartzs as large as half a million words. -It can be opened by either clicking on the search bar or pressing ⌘+K. The top 5 search results are shown on each query. Matching subterms are highlighted and the most relevant 30 words are excerpted. Clicking on a search result will navigate to that page. +It can be opened by either clicking on the search bar or pressing `⌘`/`ctrl` + `K`. The top 5 search results are shown on each query. Matching subterms are highlighted and the most relevant 30 words are excerpted. Clicking on a search result will navigate to that page. -This component is also keyboard accessible: Tab and Shift+Tab will cycle forward and backward through search results and Enter will navigate to the highlighted result (first result by default). +To search content by tags, you can either press `⌘`/`ctrl` + `shift` + `K` or start your query with `#` (e.g. `#components`). + +This component is also keyboard accessible: Tab and Shift+Tab will cycle forward and backward through search results and Enter will navigate to the highlighted result (first result by default). You are also able to navigate search results using `ArrowUp` and `ArrowDown`. > [!info] > Search requires the `ContentIndex` emitter plugin to be present in the [[configuration]]. @@ -17,7 +19,7 @@ This component is also keyboard accessible: Tab and Shift+Tab will cycle forward By default, it indexes every page on the site with **Markdown syntax removed**. This means link URLs for instance are not indexed. -It properly tokenizes Chinese, Korean, and Japenese characters and constructs separate indexes for the title and content, weighing title matches above content matches. +It properly tokenizes Chinese, Korean, and Japenese characters and constructs separate indexes for the title, content and tags, weighing title matches above content matches. ## Customization @@ -25,4 +27,4 @@ It properly tokenizes Chinese, Korean, and Japenese characters and constructs se - Component: `quartz/components/Search.tsx` - Style: `quartz/components/styles/search.scss` - Script: `quartz/components/scripts/search.inline.ts` - - You can edit `contextWindowWords` or `numSearchResults` to suit your needs + - You can edit `contextWindowWords`, `numSearchResults` or `numTagResults` to suit your needs From 2d52eba4133293a27f6df98c252785ba4ddee575 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 20:25:38 -0700 Subject: [PATCH 025/205] fix: dont transform external links --- quartz/plugins/transformers/links.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 26c4a32282910..475a5e92e7613 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -54,7 +54,8 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = node.properties.className.push(isAbsoluteUrl(dest) ? "external" : "internal") // don't process external links or intra-document anchors - if (!(isAbsoluteUrl(dest) || dest.startsWith("#"))) { + const isInternal = !(isAbsoluteUrl(dest) || dest.startsWith("#")) + if (isInternal) { dest = node.properties.href = transformLink( file.data.slug!, dest, @@ -77,6 +78,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = // rewrite link internals if prettylinks is on if ( opts.prettyLinks && + isInternal && node.children.length === 1 && node.children[0].type === "text" && !node.children[0].value.startsWith("#") From 8d6029b7b844044d06fe17de89db6881954a8fec Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:02:21 -0700 Subject: [PATCH 026/205] feat: 404 page emitter --- docs/features/OxHugo compatibility.md | 2 +- docs/features/upcoming features.md | 3 +- quartz.config.ts | 1 + quartz/components/index.ts | 4 +- quartz/components/pages/404.tsx | 12 ++++++ quartz/plugins/emitters/404.tsx | 56 +++++++++++++++++++++++++++ quartz/plugins/emitters/index.ts | 1 + 7 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 quartz/components/pages/404.tsx create mode 100644 quartz/plugins/emitters/404.tsx diff --git a/docs/features/OxHugo compatibility.md b/docs/features/OxHugo compatibility.md index 7801f0c2571ef..b25167f8d9bc0 100644 --- a/docs/features/OxHugo compatibility.md +++ b/docs/features/OxHugo compatibility.md @@ -3,7 +3,7 @@ tags: - plugin/transformer --- -[org-roam](https://www.orgroam.com/) is a plain-text(`org`) personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. +[org-roam](https://www.orgroam.com/) is a plain-text personal knowledge management system for [emacs](https://en.wikipedia.org/wiki/Emacs). [ox-hugo](https://github.com/kaushalmodi/ox-hugo) is org exporter backend that exports `org-mode` files to [Hugo](https://gohugo.io/) compatible Markdown. Because the Markdown generated by ox-hugo is not pure Markdown but Hugo specific, we need to transform it to fit into Quartz. This is done by `Plugin.OxHugoFlavouredMarkdown`. Even though this [[making plugins|plugin]] was written with `ox-hugo` in mind, it should work for any Hugo specific Markdown. diff --git a/docs/features/upcoming features.md b/docs/features/upcoming features.md index fbfdbc947489e..76adda00e637e 100644 --- a/docs/features/upcoming features.md +++ b/docs/features/upcoming features.md @@ -4,15 +4,14 @@ draft: true ## high priority backlog +- static dead link detection - block links: https://help.obsidian.md/Linking+notes+and+files/Internal+links#Link+to+a+block+in+a+note - note/header/block transcludes: https://help.obsidian.md/Linking+notes+and+files/Embedding+files -- static dead link detection - docker support ## misc backlog - breadcrumbs component -- filetree component - cursor chat extension - https://giscus.app/ extension - sidenotes? https://github.com/capnfabs/paperesque diff --git a/quartz.config.ts b/quartz.config.ts index 31d5bcfea13d9..f677a18f9572b 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -69,6 +69,7 @@ const config: QuartzConfig = { }), Plugin.Assets(), Plugin.Static(), + Plugin.NotFoundPage(), ], }, } diff --git a/quartz/components/index.ts b/quartz/components/index.ts index a83f078b08179..10a43acb534b4 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -1,7 +1,8 @@ -import ArticleTitle from "./ArticleTitle" import Content from "./pages/Content" import TagContent from "./pages/TagContent" import FolderContent from "./pages/FolderContent" +import NotFound from "./pages/404" +import ArticleTitle from "./ArticleTitle" import Darkmode from "./Darkmode" import Head from "./Head" import PageTitle from "./PageTitle" @@ -36,4 +37,5 @@ export { DesktopOnly, MobileOnly, RecentNotes, + NotFound, } diff --git a/quartz/components/pages/404.tsx b/quartz/components/pages/404.tsx new file mode 100644 index 0000000000000..c276f568d1026 --- /dev/null +++ b/quartz/components/pages/404.tsx @@ -0,0 +1,12 @@ +import { QuartzComponentConstructor } from "../types" + +function NotFound() { + return ( +
    +

    404

    +

    Either this page is private or doesn't exist.

    +
    + ) +} + +export default (() => NotFound) satisfies QuartzComponentConstructor diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx new file mode 100644 index 0000000000000..785c873da268f --- /dev/null +++ b/quartz/plugins/emitters/404.tsx @@ -0,0 +1,56 @@ +import { QuartzEmitterPlugin } from "../types" +import { QuartzComponentProps } from "../../components/types" +import BodyConstructor from "../../components/Body" +import { pageResources, renderPage } from "../../components/renderPage" +import { FullPageLayout } from "../../cfg" +import { FilePath, FullSlug } from "../../util/path" +import { sharedPageComponents } from "../../../quartz.layout" +import { NotFound } from "../../components" +import { defaultProcessedContent } from "../vfile" + +export const NotFoundPage: QuartzEmitterPlugin = () => { + const opts: FullPageLayout = { + ...sharedPageComponents, + pageBody: NotFound(), + beforeBody: [], + left: [], + right: [], + } + + const { head: Head, pageBody, footer: Footer } = opts + const Body = BodyConstructor() + + return { + name: "404Page", + getQuartzComponents() { + return [Head, Body, pageBody, Footer] + }, + async emit(ctx, _content, resources, emit): Promise { + const cfg = ctx.cfg.configuration + const slug = "404" as FullSlug + const externalResources = pageResources(slug, resources) + const [tree, vfile] = defaultProcessedContent({ + slug, + text: "Not Found", + description: "Not Found", + frontmatter: { title: "Not Found", tags: [] }, + }) + const componentData: QuartzComponentProps = { + fileData: vfile.data, + externalResources, + cfg, + children: [], + tree, + allFiles: [], + } + + return [ + await emit({ + content: renderPage(slug, componentData, opts, externalResources), + slug, + ext: ".html", + }), + ] + }, + } +} diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index da95d4901cdbf..99a2c54d5c1f4 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -6,3 +6,4 @@ export { AliasRedirects } from "./aliases" export { Assets } from "./assets" export { Static } from "./static" export { ComponentResources } from "./componentResources" +export { NotFoundPage } from "./404" From 989bee597987bba2aeae4266cb32ac8e899f638c Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:08:08 -0700 Subject: [PATCH 027/205] docs: correct field for ignorePatterns --- docs/features/private pages.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/private pages.md b/docs/features/private pages.md index 402e52c2c7a7a..1fd6acd22a912 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -12,7 +12,7 @@ There may be some notes you want to avoid publishing as a website. Quartz suppor If you'd like to only publish a select number of notes, you can instead use `Plugin.ExplicitPublish` which will filter out all notes except for any that have `publish: true` in the frontmatter. -## `ignoreFiles` +## `ignorePatterns` This is a field in `quartz.config.ts` under the main [[configuration]] which allows you to specify a list of patterns to effectively exclude from parsing all together. Any valid [glob](https://github.com/mrmlnc/fast-glob#pattern-syntax) pattern works here. @@ -24,4 +24,4 @@ Common examples include: - `**/private`: exclude any files or folders named `private` at any level of nesting > [!warning] -> Marking something as private via either a plugin or through the `ignoreFiles` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information. +> Marking something as private via either a plugin or through the `ignorePatterns` pattern will only prevent a page from being included in the final built site. If your GitHub repository is public, also be sure to include an ignore for those in the `.gitignore` of your Quartz. See the `git` [documentation](https://git-scm.com/docs/gitignore#_pattern_format) for more information. From ef1ead31dccd05f4275405b843ff47fa28a5116d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:31:01 -0700 Subject: [PATCH 028/205] fix: encodeuri for slugs in rss --- quartz/plugins/emitters/contentIndex.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 1c7feaea2eddb..1d0af6d7ea21c 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -29,7 +29,7 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${slug} + https://${base}/${encodeURIComponent(slug)} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -44,8 +44,8 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` ${content.title} - ${root}/${slug} - ${root}/${slug} + ${root}/${encodeURIComponent(slug)} + ${root}/${encodeURIComponent(slug)} ${content.description} ${content.date?.toUTCString()} ` From 828aa71fe34aae675a7552957e8a062c82f595f6 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 21:47:59 -0700 Subject: [PATCH 029/205] fix: escape encoding for titles in rss --- quartz/plugins/emitters/contentIndex.ts | 11 ++++++----- quartz/plugins/transformers/description.ts | 10 +--------- quartz/util/escape.ts | 8 ++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 quartz/util/escape.ts diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 1d0af6d7ea21c..f24ae6dc12516 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,5 +1,6 @@ import { GlobalConfiguration } from "../../cfg" import { getDate } from "../../components/Date" +import { escapeHTML } from "../../util/escape" import { FilePath, FullSlug, SimpleSlug, simplifySlug } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import path from "path" @@ -29,7 +30,7 @@ const defaultOptions: Options = { function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${base}/${encodeURIComponent(slug)} + https://${base}/${encodeURI(slug)} ${content.date?.toISOString()} ` const urls = Array.from(idx) @@ -43,9 +44,9 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const root = `https://${base}` const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${content.title} - ${root}/${encodeURIComponent(slug)} - ${root}/${encodeURIComponent(slug)} + ${escapeHTML(content.title)} + ${root}/${encodeURI(slug)} + ${root}/${encodeURI(slug)} ${content.description} ${content.date?.toUTCString()} ` @@ -56,7 +57,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { return ` - ${cfg.pageTitle} + ${escapeHTML(cfg.pageTitle)} ${root} Recent content on ${cfg.pageTitle} Quartz -- quartz.jzhao.xyz diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 08af5c7887591..884d5b1893041 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -1,6 +1,7 @@ import { Root as HTMLRoot } from "hast" import { toString } from "hast-util-to-string" import { QuartzTransformerPlugin } from "../types" +import { escapeHTML } from "../../util/escape" export interface Options { descriptionLength: number @@ -10,15 +11,6 @@ const defaultOptions: Options = { descriptionLength: 150, } -const escapeHTML = (unsafe: string) => { - return unsafe - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") -} - export const Description: QuartzTransformerPlugin | undefined> = (userOpts) => { const opts = { ...defaultOptions, ...userOpts } return { diff --git a/quartz/util/escape.ts b/quartz/util/escape.ts new file mode 100644 index 0000000000000..197558c7dec75 --- /dev/null +++ b/quartz/util/escape.ts @@ -0,0 +1,8 @@ +export const escapeHTML = (unsafe: string) => { + return unsafe + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} From 2525bfbab5553f970997ea3c60af180cbef1fdd2 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 6 Sep 2023 22:24:15 -0700 Subject: [PATCH 030/205] fix: links to index not showing in graph (closes #450) --- quartz/build.ts | 1 + quartz/components/scripts/graph.inline.ts | 3 ++- quartz/plugins/transformers/links.ts | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/quartz/build.ts b/quartz/build.ts index 22288acc14434..5752caa463962 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -142,6 +142,7 @@ async function startServing( const parsedFiles = [...contentMap.values()] const filteredContent = filterContent(ctx, parsedFiles) + // TODO: we can probably traverse the link graph to figure out what's safe to delete here // instead of just deleting everything await rimraf(argv.output) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index d72b297bfe8cd..dc5c99dc1feef 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -47,11 +47,12 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const data = await fetchData const links: LinkData[] = [] + const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) for (const [src, details] of Object.entries(data)) { const source = simplifySlug(src as FullSlug) const outgoing = details.links ?? [] for (const dest of outgoing) { - if (dest in data) { + if (validLinks.has(dest)) { links.push({ source, target: dest }) } } diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 475a5e92e7613..02ced158def24 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -5,7 +5,6 @@ import { SimpleSlug, TransformOptions, _stripSlashes, - joinSegments, simplifySlug, splitAnchor, transformLink, From 06df00b18621f08a211bec33f566ecb7ef4ec22e Mon Sep 17 00:00:00 2001 From: Stefano Cecere Date: Thu, 7 Sep 2023 17:13:41 +0200 Subject: [PATCH 031/205] typo (it's draft, not drafts) (#456) --- docs/features/private pages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/private pages.md b/docs/features/private pages.md index 1fd6acd22a912..5c3940bc721c1 100644 --- a/docs/features/private pages.md +++ b/docs/features/private pages.md @@ -8,7 +8,7 @@ There may be some notes you want to avoid publishing as a website. Quartz suppor ## Filter Plugins -[[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the `Plugin.RemoveDrafts` plugin which filters out any note that has `drafts: true` in the frontmatter. +[[making plugins#Filters|Filter plugins]] are plugins that filter out content based off of certain criteria. By default, Quartz uses the `Plugin.RemoveDrafts` plugin which filters out any note that has `draft: true` in the frontmatter. If you'd like to only publish a select number of notes, you can instead use `Plugin.ExplicitPublish` which will filter out all notes except for any that have `publish: true` in the frontmatter. From 53f1c88738550eb2646cc0a03469dc230839a258 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 8 Sep 2023 09:29:57 -0700 Subject: [PATCH 032/205] fix: more lenient date parsing for templates --- quartz/plugins/transformers/lastmod.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/transformers/lastmod.ts b/quartz/plugins/transformers/lastmod.ts index 507b585223d3e..015c350a5fc90 100644 --- a/quartz/plugins/transformers/lastmod.ts +++ b/quartz/plugins/transformers/lastmod.ts @@ -11,6 +11,11 @@ const defaultOptions: Options = { priority: ["frontmatter", "git", "filesystem"], } +function coerceDate(d: any): Date { + const dt = new Date(d) + return isNaN(dt.getTime()) ? new Date() : dt +} + type MaybeDate = undefined | string | number export const CreatedModifiedDate: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -49,9 +54,9 @@ export const CreatedModifiedDate: QuartzTransformerPlugin | und } file.data.dates = { - created: created ? new Date(created) : new Date(), - modified: modified ? new Date(modified) : new Date(), - published: published ? new Date(published) : new Date(), + created: coerceDate(created), + modified: coerceDate(modified), + published: coerceDate(published), } } }, From a66c239797e3e80e2dc8b7059eee8c51bcf4ca8f Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sun, 10 Sep 2023 23:07:17 -0700 Subject: [PATCH 033/205] ci: print bundleInfo --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 731395d38a0db..8915143c432ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,5 +43,5 @@ jobs: - name: Test run: npm test - - name: Ensure Quartz builds - run: npx quartz build + - name: Ensure Quartz builds, check bundle info + run: npx quartz build --bundleInfo From 4e23e6724493a8d112c6ff22e14cf4aabd5e9af1 Mon Sep 17 00:00:00 2001 From: Oskar Manhart <52569953+oskardotglobal@users.noreply.github.com> Date: Mon, 11 Sep 2023 08:11:42 +0200 Subject: [PATCH 034/205] feat: plugin for remark-breaks (#467) * feat: plugin for remark-breaks * fix: update package-lock.json * fix: styling Co-authored-by: Jacky Zhao * Update linebreaks.ts * Update index.ts --------- Co-authored-by: Jacky Zhao --- package-lock.json | 28 +++++++++++++++++++++++ package.json | 1 + quartz/plugins/transformers/index.ts | 1 + quartz/plugins/transformers/linebreaks.ts | 11 +++++++++ 4 files changed, 41 insertions(+) create mode 100644 quartz/plugins/transformers/linebreaks.ts diff --git a/package-lock.json b/package-lock.json index 9246cc992a85c..a19d81c118c86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "rehype-raw": "^6.1.1", "rehype-slug": "^5.1.0", "remark": "^14.0.2", + "remark-breaks": "^3.0.3", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", @@ -3810,6 +3811,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-1.0.0.tgz", + "integrity": "sha512-491LcYv3gbGhhCrLoeALncQmega2xPh+m3gbsIhVsOX4sw85+ShLFPvPyibxc1Swx/6GtzxgVodq+cGa/47ULg==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-find-and-replace": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", @@ -4903,6 +4917,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-breaks": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.3.tgz", + "integrity": "sha512-C7VkvcUp1TPUc2eAYzsPdaUh8Xj4FSbQnYA5A9f80diApLZscTDeG7efiWP65W8hV2sEy3JuGVU0i6qr5D8Hug==", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-newline-to-break": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-frontmatter": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz", diff --git a/package.json b/package.json index 6ed52d6023723..95c57cd8de4d1 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "rehype-raw": "^6.1.1", "rehype-slug": "^5.1.0", "remark": "^14.0.2", + "remark-breaks": "^3.0.3", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index d9f2854c04b6f..e340f10e799fe 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -8,3 +8,4 @@ export { ObsidianFlavoredMarkdown } from "./ofm" export { OxHugoFlavouredMarkdown } from "./oxhugofm" export { SyntaxHighlighting } from "./syntax" export { TableOfContents } from "./toc" +export { HardLineBreaks } from "./linebreaks" diff --git a/quartz/plugins/transformers/linebreaks.ts b/quartz/plugins/transformers/linebreaks.ts new file mode 100644 index 0000000000000..a8a066fc19529 --- /dev/null +++ b/quartz/plugins/transformers/linebreaks.ts @@ -0,0 +1,11 @@ +import { QuartzTransformerPlugin } from "../types" +import remarkBreaks from "remark-breaks" + +export const HardLineBreaks: QuartzTransformerPlugin = () => { + return { + name: "HardLineBreaks", + markdownPlugins() { + return [remarkBreaks] + }, + } +} From a19df64be8423063c2484ab35300fb0bef324a14 Mon Sep 17 00:00:00 2001 From: hcplantern <38579760+HCPlantern@users.noreply.github.com> Date: Tue, 12 Sep 2023 14:00:21 +0800 Subject: [PATCH 035/205] fix: callout parsing (#469) --- quartz/plugins/transformers/ofm.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8c8da67bca2b2..8b95126dd40c2 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -69,6 +69,8 @@ const callouts = { const calloutMapping: Record = { note: "note", abstract: "abstract", + summary: "abstract", + tldr: "abstract", info: "info", todo: "todo", tip: "tip", @@ -96,7 +98,7 @@ const calloutMapping: Record = { function canonicalizeCallout(calloutName: string): keyof typeof callouts { let callout = calloutName.toLowerCase() as keyof typeof calloutMapping - return calloutMapping[callout] ?? calloutName + return calloutMapping[callout] ?? "note" } const capitalize = (s: string): string => { From 71d81bde1d12aa386ec70be31cc86a37a7426bce Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 19:18:44 -0700 Subject: [PATCH 036/205] feat: rss limit (closes #459) --- quartz/plugins/emitters/contentIndex.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index f24ae6dc12516..bcb1e307471fe 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -18,12 +18,14 @@ export type ContentDetails = { interface Options { enableSiteMap: boolean enableRSS: boolean + rssLimit?: number includeEmptyFiles: boolean } const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, + rssLimit: 10, includeEmptyFiles: true, } @@ -39,7 +41,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { +function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { const base = cfg.baseUrl ?? "" const root = `https://${base}` @@ -53,13 +55,17 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex): string { const items = Array.from(idx) .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) + .slice(0, limit ?? idx.size) .join("") + return ` ${escapeHTML(cfg.pageTitle)} ${root} - Recent content on ${cfg.pageTitle} + ${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${ + cfg.pageTitle + } Quartz -- quartz.jzhao.xyz ${items} @@ -102,7 +108,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { if (opts?.enableRSS) { emitted.push( await emit({ - content: generateRSSFeed(cfg, linkIndex), + content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), slug: "index" as FullSlug, ext: ".xml", }), From 60a3c543398aed8caf44b411a4dc10e8d1e26fcc Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 21:29:57 -0700 Subject: [PATCH 037/205] fix: 404 page styling for nested pages (closes #458) --- quartz/components/Head.tsx | 8 ++++++-- quartz/components/renderPage.tsx | 9 +++++---- quartz/plugins/emitters/404.tsx | 5 ++++- quartz/plugins/emitters/contentPage.tsx | 4 ++-- quartz/plugins/emitters/folderPage.tsx | 3 ++- quartz/plugins/emitters/tagPage.tsx | 10 ++++++++-- quartz/util/path.ts | 5 ++++- 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 67f0c02457c66..2bf26381795ed 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -1,4 +1,4 @@ -import { joinSegments, pathToRoot } from "../util/path" +import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" import { JSResourceToScriptElement } from "../util/resources" import { QuartzComponentConstructor, QuartzComponentProps } from "./types" @@ -7,7 +7,11 @@ export default (() => { const title = fileData.frontmatter?.title ?? "Untitled" const description = fileData.description?.trim() ?? "No description provided" const { css, js } = externalResources - const baseDir = pathToRoot(fileData.slug!) + + const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) + const path = url.pathname as FullSlug + const baseDir = fileData.slug === "404" ? path : pathToRoot(fileData.slug!) + const iconPath = joinSegments(baseDir, "static/icon.png") const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index eb1291f453d93..25297f289a841 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,7 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { FullSlug, joinSegments, pathToRoot } from "../util/path" +import { FullSlug, RelativeURL, joinSegments } from "../util/path" interface RenderComponents { head: QuartzComponent @@ -15,9 +15,10 @@ interface RenderComponents { footer: QuartzComponent } -export function pageResources(slug: FullSlug, staticResources: StaticResources): StaticResources { - const baseDir = pathToRoot(slug) - +export function pageResources( + baseDir: FullSlug | RelativeURL, + staticResources: StaticResources, +): StaticResources { const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())` diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index 785c873da268f..cd079a06559a4 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -28,7 +28,10 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { async emit(ctx, _content, resources, emit): Promise { const cfg = ctx.cfg.configuration const slug = "404" as FullSlug - const externalResources = pageResources(slug, resources) + + const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) + const path = url.pathname as FullSlug + const externalResources = pageResources(path, resources) const [tree, vfile] = defaultProcessedContent({ slug, text: "Not Found", diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 0e510db894aff..4542446b015fa 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -4,7 +4,7 @@ import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { FilePath } from "../../util/path" +import { FilePath, pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" @@ -31,7 +31,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp const allFiles = content.map((c) => c[1].data) for (const [tree, file] of content) { const slug = file.data.slug! - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const componentData: QuartzComponentProps = { fileData: file.data, externalResources, diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 8d62f7bb4e4a5..8632eceb46b70 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -12,6 +12,7 @@ import { SimpleSlug, _stripSlashes, joinSegments, + pathToRoot, simplifySlug, } from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" @@ -69,7 +70,7 @@ export const FolderPage: QuartzEmitterPlugin = (userOpts) => { for (const folder of folders) { const slug = joinSegments(folder, "index") as FullSlug - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const [tree, file] = folderDescriptions[folder] const componentData: QuartzComponentProps = { fileData: file.data, diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 54ad934f69fa0..6afde2fcacb09 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -5,7 +5,13 @@ import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { ProcessedContent, defaultProcessedContent } from "../vfile" import { FullPageLayout } from "../../cfg" -import { FilePath, FullSlug, getAllSegmentPrefixes, joinSegments } from "../../util/path" +import { + FilePath, + FullSlug, + getAllSegmentPrefixes, + joinSegments, + pathToRoot, +} from "../../util/path" import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout" import { TagContent } from "../../components" @@ -62,7 +68,7 @@ export const TagPage: QuartzEmitterPlugin = (userOpts) => { for (const tag of tags) { const slug = joinSegments("tags", tag) as FullSlug - const externalResources = pageResources(slug, resources) + const externalResources = pageResources(pathToRoot(slug), resources) const [tree, file] = tagDescriptions[tag] const componentData: QuartzComponentProps = { fileData: file.data, diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 1557c1bd561df..1540063746901 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -123,7 +123,10 @@ export function slugTag(tag: string) { } export function joinSegments(...args: string[]): string { - return args.filter((segment) => segment !== "").join("/") + return args + .filter((segment) => segment !== "") + .join("/") + .replace(/\/\/+/g, "/") } export function getAllSegmentPrefixes(tags: string): string[] { From e3b879741b6d32f56e1d1bfd0bac57f0d68c1113 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 21:44:03 -0700 Subject: [PATCH 038/205] feat: rich html rss (closes #460) --- docs/features/RSS Feed.md | 2 ++ quartz/plugins/emitters/contentIndex.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index c519f8771d0a9..bfeb399c98fbd 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -3,3 +3,5 @@ Quartz creates an RSS feed for all the content on your site by generating an `in ## Configuration - Remove RSS feed: set the `enableRSS` field of `Plugin.ContentIndex` in `quartz.config.ts` to be `false`. +- Change number of entries: set the `rssLimit` field of `Plugin.ContentIndex` to be the desired value. It defaults to latest 10 items. +- Use rich HTML output in RSS: set `rssFullHtml` field of `Plugin.ContentIndex` to be `true`. diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index bcb1e307471fe..102394cec04d7 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -1,8 +1,10 @@ +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 { QuartzEmitterPlugin } from "../types" +import { toHtml } from "hast-util-to-html" import path from "path" export type ContentIndex = Map @@ -19,6 +21,7 @@ interface Options { enableSiteMap: boolean enableRSS: boolean rssLimit?: number + rssFullHtml: boolean includeEmptyFiles: boolean } @@ -26,6 +29,7 @@ const defaultOptions: Options = { enableSiteMap: true, enableRSS: true, rssLimit: 10, + rssFullHtml: false, includeEmptyFiles: true, } @@ -49,7 +53,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${escapeHTML(content.title)} ${root}/${encodeURI(slug)} ${root}/${encodeURI(slug)} - ${content.description} + ${content.content} ${content.date?.toUTCString()} ` @@ -80,7 +84,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] const linkIndex: ContentIndex = new Map() - for (const [_tree, file] of content) { + for (const [tree, file] of content) { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { @@ -88,7 +92,9 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], - content: file.data.text ?? "", + content: opts?.rssFullHtml + ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) + : file.data.description ?? "", date: date, description: file.data.description ?? "", }) From 6ecdcb5e24f2783e6fa73de69e848f0f319c4fc4 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 12 Sep 2023 22:55:50 -0700 Subject: [PATCH 039/205] feat: resolve block references in obsidian markdown --- quartz/plugins/transformers/ofm.ts | 91 +++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8b95126dd40c2..b2f1dba30d820 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,6 +1,7 @@ import { PluggableList } from "unified" import { QuartzTransformerPlugin } from "../types" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" +import { Element, Literal } from 'hast' import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { slug as slugAnchor } from "github-slugger" import rehypeRaw from "rehype-raw" @@ -21,6 +22,7 @@ export interface Options { callouts: boolean mermaid: boolean parseTags: boolean + parseBlockReferences: boolean enableInHtmlEmbed: boolean } @@ -31,6 +33,7 @@ const defaultOptions: Options = { callouts: true, mermaid: true, parseTags: true, + parseBlockReferences: true, enableInHtmlEmbed: false, } @@ -121,6 +124,7 @@ const calloutLineRegex = new RegExp(/^> *\[\!\w+\][+-]?.*$/, "gm") // (?:[-_\p{L}])+ -> non-capturing group, non-empty string of (Unicode-aware) alpha-numeric characters, hyphens and/or underscores // (?:\/[-_\p{L}]+)*) -> non-capturing group, matches an arbitrary number of tag strings separated by "/" const tagRegex = new RegExp(/(?:^| )#((?:[-_\p{L}\d])+(?:\/[-_\p{L}\d]+)*)/, "gu") +const blockReferenceRegex = new RegExp(/\^([A-Za-z0-9]+)$/, "g") export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin | undefined> = ( userOpts, @@ -133,29 +137,29 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { - if (replace) { - visit(tree, "html", (node: HTML) => { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replaceAll(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - }) - } - - mdastFindReplace(tree, regex, replace) + if (replace) { + visit(tree, "html", (node: HTML) => { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace) + } else { + node.value = node.value.replaceAll(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args) + if (typeof replaceValue === "string") { + return replaceValue + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join("") + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue) + } else { + return substring + } + }) + } + }) } + + mdastFindReplace(tree, regex, replace) + } : mdastFindReplace return { @@ -353,9 +357,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin node.data = { hProperties: { ...(node.data?.hProperties ?? {}), - className: `callout ${collapse ? "is-collapsible" : ""} ${ - defaultState === "collapsed" ? "is-collapsed" : "" - }`, + className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : "" + }`, "data-callout": calloutType, "data-callout-fold": collapse, }, @@ -411,11 +414,38 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } }) } - return plugins }, htmlPlugins() { - return [rehypeRaw] + const plugins = [rehypeRaw] + + if (opts.parseBlockReferences) { + plugins.push(() => { + return (tree, file) => { + file.data.blocks = {} + const validTagTypes = new Set(["blockquote", "p", "li"]) + visit(tree, "element", (node, _index, _parent) => { + if (validTagTypes.has(node.tagName)) { + const last = node.children.at(-1) as Literal + if (last.value && typeof last.value === 'string') { + const matches = last.value.match(blockReferenceRegex) + if (matches && matches.length >= 1) { + last.value = last.value.slice(0, -matches[0].length) + const block = matches[0].slice(1) + node.properties = { + ...node.properties, + id: block + } + file.data.blocks![block] = node + } + } + } + }) + } + }) + } + + return plugins }, externalResources() { const js: JSResource[] = [] @@ -454,3 +484,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin }, } } + +declare module "vfile" { + interface DataMap { + blocks: Record + } +} + From 4461748a85b8795651d0c02451368dffff607938 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 13 Sep 2023 09:43:14 -0700 Subject: [PATCH 040/205] fix dont show html in search when rssFullHtml is true (closes #474) --- quartz/plugins/emitters/contentIndex.ts | 8 ++-- quartz/plugins/transformers/ofm.ts | 56 ++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 102394cec04d7..911173e1b0a7b 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -13,6 +13,7 @@ export type ContentDetails = { links: SimpleSlug[] tags: string[] content: string + richContent?: string date?: Date description?: string } @@ -53,7 +54,7 @@ function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: nu ${escapeHTML(content.title)} ${root}/${encodeURI(slug)} ${root}/${encodeURI(slug)} - ${content.content} + ${content.richContent ?? content.description} ${content.date?.toUTCString()} ` @@ -92,9 +93,10 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], - content: opts?.rssFullHtml + content: file.data.text ?? "", + richContent: opts?.rssFullHtml ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : file.data.description ?? "", + : undefined, date: date, description: file.data.description ?? "", }) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index b2f1dba30d820..811d659e60c83 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -1,7 +1,7 @@ import { PluggableList } from "unified" import { QuartzTransformerPlugin } from "../types" import { Root, HTML, BlockContent, DefinitionContent, Code, Paragraph } from "mdast" -import { Element, Literal } from 'hast' +import { Element, Literal } from "hast" import { Replace, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace" import { slug as slugAnchor } from "github-slugger" import rehypeRaw from "rehype-raw" @@ -137,29 +137,29 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin } const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { - if (replace) { - visit(tree, "html", (node: HTML) => { - if (typeof replace === "string") { - node.value = node.value.replace(regex, replace) - } else { - node.value = node.value.replaceAll(regex, (substring: string, ...args) => { - const replaceValue = replace(substring, ...args) - if (typeof replaceValue === "string") { - return replaceValue - } else if (Array.isArray(replaceValue)) { - return replaceValue.map(mdastToHtml).join("") - } else if (typeof replaceValue === "object" && replaceValue !== null) { - return mdastToHtml(replaceValue) - } else { - return substring - } - }) - } - }) - } + if (replace) { + visit(tree, "html", (node: HTML) => { + if (typeof replace === "string") { + node.value = node.value.replace(regex, replace) + } else { + node.value = node.value.replaceAll(regex, (substring: string, ...args) => { + const replaceValue = replace(substring, ...args) + if (typeof replaceValue === "string") { + return replaceValue + } else if (Array.isArray(replaceValue)) { + return replaceValue.map(mdastToHtml).join("") + } else if (typeof replaceValue === "object" && replaceValue !== null) { + return mdastToHtml(replaceValue) + } else { + return substring + } + }) + } + }) + } - mdastFindReplace(tree, regex, replace) - } + mdastFindReplace(tree, regex, replace) + } : mdastFindReplace return { @@ -357,8 +357,9 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin node.data = { hProperties: { ...(node.data?.hProperties ?? {}), - className: `callout ${collapse ? "is-collapsible" : ""} ${defaultState === "collapsed" ? "is-collapsed" : "" - }`, + className: `callout ${collapse ? "is-collapsible" : ""} ${ + defaultState === "collapsed" ? "is-collapsed" : "" + }`, "data-callout": calloutType, "data-callout-fold": collapse, }, @@ -427,14 +428,14 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin visit(tree, "element", (node, _index, _parent) => { if (validTagTypes.has(node.tagName)) { const last = node.children.at(-1) as Literal - if (last.value && typeof last.value === 'string') { + if (last.value && typeof last.value === "string") { const matches = last.value.match(blockReferenceRegex) if (matches && matches.length >= 1) { last.value = last.value.slice(0, -matches[0].length) const block = matches[0].slice(1) node.properties = { ...node.properties, - id: block + id: block, } file.data.blocks![block] = node } @@ -490,4 +491,3 @@ declare module "vfile" { blocks: Record } } - From cce389c81d262d1d2a2bd8140c879efd68e3c6dd Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 13 Sep 2023 11:28:53 -0700 Subject: [PATCH 041/205] feat: note transclusion (#475) * basic transclude * feat: note transclusion --- docs/features/wikilinks.md | 4 +-- docs/index.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- quartz/components/renderPage.tsx | 36 +++++++++++++++++++ quartz/plugins/transformers/links.ts | 1 + quartz/plugins/transformers/ofm.ts | 52 +++++++++++++++++++++++----- quartz/styles/base.scss | 6 ++++ 8 files changed, 91 insertions(+), 16 deletions(-) diff --git a/docs/features/wikilinks.md b/docs/features/wikilinks.md index 704a0d0cfb80b..50bbb1bb62d05 100644 --- a/docs/features/wikilinks.md +++ b/docs/features/wikilinks.md @@ -13,6 +13,4 @@ This is enabled as a part of [[Obsidian compatibility]] and can be configured an - `[[Path to file]]`: produces a link to `Path to file.md` (or `Path-to-file.md`) with the text `Path to file` - `[[Path to file | Here's the title override]]`: produces a link to `Path to file.md` with the text `Here's the title override` - `[[Path to file#Anchor]]`: produces a link to the anchor `Anchor` in the file `Path to file.md` - -> [!warning] -> Currently, Quartz does not support block references or note embed syntax. +- `[[Path to file#^block-ref]]`: produces a link to the specific block `block-ref` in the file `Path to file.md` diff --git a/docs/index.md b/docs/index.md index e5b9dfef572a8..05de2bae97d97 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ This will guide you through initializing your Quartz with content. Once you've d ## 🔧 Features -- [[Obsidian compatibility]], [[full-text search]], [[graph view]], [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box +- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box - Hot-reload for both configuration and content - Simple JSX layouts and [[creating components|page components]] - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes diff --git a/package-lock.json b/package-lock.json index a19d81c118c86..a879078977376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackyzha0/quartz", - "version": "4.0.10", + "version": "4.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.0.10", + "version": "4.0.11", "license": "MIT", "dependencies": { "@clack/prompts": "^0.6.3", diff --git a/package.json b/package.json index 95c57cd8de4d1..0a2085cef5c1c 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.0.10", + "version": "4.0.11", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 25297f289a841..451813b5e9a4d 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -4,6 +4,8 @@ import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" import { FullSlug, RelativeURL, joinSegments } from "../util/path" +import { visit } from "unist-util-visit" +import { Root, Element } from "hast" interface RenderComponents { head: QuartzComponent @@ -53,6 +55,40 @@ export function renderPage( components: RenderComponents, pageResources: StaticResources, ): string { + // process transcludes in componentData + visit(componentData.tree as Root, "element", (node, _index, _parent) => { + if (node.tagName === "blockquote") { + const classNames = (node.properties?.className ?? []) as string[] + if (classNames.includes("transclude")) { + const inner = node.children[0] as Element + const blockSlug = inner.properties?.["data-slug"] as FullSlug + const blockRef = node.properties!.dataBlock as string + + // TODO: avoid this expensive find operation and construct an index ahead of time + let blockNode = componentData.allFiles.find((f) => f.slug === blockSlug)?.blocks?.[blockRef] + if (blockNode) { + if (blockNode.tagName === "li") { + blockNode = { + type: "element", + tagName: "ul", + children: [blockNode], + } + } + + node.children = [ + blockNode, + { + type: "element", + tagName: "a", + properties: { href: inner.properties?.href, class: ["internal"] }, + children: [{ type: "text", value: `Link to original` }], + }, + ] + } + } + } + }) + const { head: Head, header, diff --git a/quartz/plugins/transformers/links.ts b/quartz/plugins/transformers/links.ts index 02ced158def24..e050e00ad7e6c 100644 --- a/quartz/plugins/transformers/links.ts +++ b/quartz/plugins/transformers/links.ts @@ -72,6 +72,7 @@ export const CrawlLinks: QuartzTransformerPlugin | undefined> = simplifySlug(destCanonical as FullSlug), ) as SimpleSlug outgoing.add(simple) + node.properties["data-slug"] = simple } // rewrite link internals if prettylinks is on diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 811d659e60c83..8306f40d8b21b 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -135,6 +135,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin const hast = toHast(ast, { allowDangerousHtml: true })! return toHtml(hast, { allowDangerousHtml: true }) } + const findAndReplace = opts.enableInHtmlEmbed ? (tree: Root, regex: RegExp, replace?: Replace | null | undefined) => { if (replace) { @@ -238,8 +239,16 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin value: ``, } } else if (ext === "") { - // TODO: note embed + const block = anchor.slice(1) + return { + type: "html", + data: { hProperties: { transclude: true } }, + value: `
    Transclude of block ${block}
    `, + } } + // otherwise, fall through to regular link } @@ -422,22 +431,47 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin if (opts.parseBlockReferences) { plugins.push(() => { + const inlineTagTypes = new Set(["p", "li"]) + const blockTagTypes = new Set(["blockquote"]) return (tree, file) => { file.data.blocks = {} - const validTagTypes = new Set(["blockquote", "p", "li"]) - visit(tree, "element", (node, _index, _parent) => { - if (validTagTypes.has(node.tagName)) { + + visit(tree, "element", (node, index, parent) => { + if (blockTagTypes.has(node.tagName)) { + const nextChild = parent?.children.at(index! + 2) as Element + if (nextChild && nextChild.tagName === "p") { + const text = nextChild.children.at(0) as Literal + if (text && text.value && text.type === "text") { + const matches = text.value.match(blockReferenceRegex) + if (matches && matches.length >= 1) { + parent!.children.splice(index! + 2, 1) + const block = matches[0].slice(1) + + if (!Object.keys(file.data.blocks!).includes(block)) { + node.properties = { + ...node.properties, + id: block, + } + file.data.blocks![block] = node + } + } + } + } + } else if (inlineTagTypes.has(node.tagName)) { const last = node.children.at(-1) as Literal - if (last.value && typeof last.value === "string") { + if (last && last.value && typeof last.value === "string") { const matches = last.value.match(blockReferenceRegex) if (matches && matches.length >= 1) { last.value = last.value.slice(0, -matches[0].length) const block = matches[0].slice(1) - node.properties = { - ...node.properties, - id: block, + + if (!Object.keys(file.data.blocks!).includes(block)) { + node.properties = { + ...node.properties, + id: block, + } + file.data.blocks![block] = node } - file.data.blocks![block] = node } } } diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 34def8783368b..92c0f84d9b28f 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -470,3 +470,9 @@ ol.overflow { background: linear-gradient(transparent 0px, var(--light)); } } + +.transclude { + ul { + padding-left: 1rem; + } +} From 14cbbdb8a2f69ebc51cd53a82b50206c543778b0 Mon Sep 17 00:00:00 2001 From: Oskar Manhart <52569953+oskardotglobal@users.noreply.github.com> Date: Thu, 14 Sep 2023 05:55:59 +0200 Subject: [PATCH 042/205] feat: display tag in graph view (#466) * feat: tags in graph view * fix: revert changing graph forces * fix: run prettier --- quartz/components/Graph.tsx | 6 ++++ quartz/components/scripts/graph.inline.ts | 40 ++++++++++++++++------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index e159aa5419041..1b8071b938c97 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -13,6 +13,8 @@ export interface D3Config { linkDistance: number fontSize: number opacityScale: number + removeTags: string[] + showTags: boolean } interface GraphOptions { @@ -31,6 +33,8 @@ const defaultOptions: GraphOptions = { linkDistance: 30, fontSize: 0.6, opacityScale: 1, + showTags: true, + removeTags: [], }, globalGraph: { drag: true, @@ -42,6 +46,8 @@ const defaultOptions: GraphOptions = { linkDistance: 30, fontSize: 0.6, opacityScale: 1, + showTags: true, + removeTags: [], }, } diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index dc5c99dc1feef..1aff138f2fcb8 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -42,20 +42,38 @@ async function renderGraph(container: string, fullSlug: FullSlug) { linkDistance, fontSize, opacityScale, + removeTags, + showTags, } = JSON.parse(graph.dataset["cfg"]!) const data = await fetchData const links: LinkData[] = [] + const tags: SimpleSlug[] = [] + const validLinks = new Set(Object.keys(data).map((slug) => simplifySlug(slug as FullSlug))) + for (const [src, details] of Object.entries(data)) { const source = simplifySlug(src as FullSlug) const outgoing = details.links ?? [] + for (const dest of outgoing) { if (validLinks.has(dest)) { links.push({ source, target: dest }) } } + + if (showTags) { + const localTags = details.tags + .filter((tag) => !removeTags.includes(tag)) + .map((tag) => simplifySlug(("tags/" + tag) as FullSlug)) + + tags.push(...localTags.filter((tag) => !tags.includes(tag))) + + for (const tag of localTags) { + links.push({ source, target: tag }) + } + } } const neighbourhood = new Set() @@ -76,14 +94,18 @@ async function renderGraph(container: string, fullSlug: FullSlug) { } } else { Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug))) + if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) } const graphData: { nodes: NodeData[]; links: LinkData[] } = { - nodes: [...neighbourhood].map((url) => ({ - id: url, - text: data[url]?.title ?? url, - tags: data[url]?.tags ?? [], - })), + nodes: [...neighbourhood].map((url) => { + const text = url.startsWith("tags/") ? "#" + url.substring(5) : data[url]?.title ?? url + return { + id: url, + text: text, + tags: data[url]?.tags ?? [], + } + }), links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), } @@ -127,7 +149,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const isCurrent = d.id === slug if (isCurrent) { return "var(--secondary)" - } else if (visited.has(d.id)) { + } else if (visited.has(d.id) || d.id.startsWith("tags/")) { return "var(--tertiary)" } else { return "var(--gray)" @@ -231,11 +253,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { .attr("dx", 0) .attr("dy", (d) => -nodeRadius(d) + "px") .attr("text-anchor", "middle") - .text( - (d) => - data[d.id]?.title || - (d.id.charAt(0).toUpperCase() + d.id.slice(1, d.id.length - 1)).replace("-", " "), - ) + .text((d) => d.text) .style("opacity", (opacityScale - 1) / 3.75) .style("pointer-events", "none") .style("font-size", fontSize + "em") From 91f9ae2d71d5c28ba7d2182eed5a9f77da1fbe8d Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:39:16 +0200 Subject: [PATCH 043/205] feat: implement file explorer component (closes #201) (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add basic explorer structure„ * feat: integrate new component/plugin * feat: add basic explorer structure * feat: add sort to FileNodes * style: improve style for explorer * refactor: remove unused explorer plugin * refactor: clean explorer structure, fix base (toc) * refactor: clean css, respect displayClass * style: add styling to chevron * refactor: clean up debug statements * refactor: remove unused import * fix: clicking folder icon sometimes turns invisible * refactor: clean css * feat(explorer): add config for title * feat: add config for folder click behavior * fix: `no-pointer` not being set for all elements new approach, have one `no-pointer` class, that removes pointer events and one `clickable` class on the svg and button (everything that can normally be clicked). then, find all children with `clickable` and toggle `no-pointer` * fix: bug where nested folders got incorrect height this fixes the bug where nested folders weren't calculating their total height correctly. done by adding class to main container of all children and calculating total * feat: introduce `folderDefaultState` config * feat: store depth for explorer nodes * feat: implement option for collapsed state + bug fixes folderBehavior: "link" still has bad styling, but major bugs with pointers fixed (not clean yet, but working) * fix: default folder icon rotation * fix: hitbox problem with folder links, fix style * fix: redirect url for nested folders * fix: inconsistent behavior with 'collapseFolders' opt * chore: add comments to `ExplorerNode` * feat: save explorer state to local storage (not clean) * feat: rework `getFolders()`, fix localstorage read + write * feat: set folder state from localStorage needs serious refactoring but functional (except folder icon orientation) * fix: folder icon orientation after local storage * feat: add config for `useSavedState` * refactor: clean `explorer.inline.ts` remove unused functions, comments, unused code, add types to EventHandler * refactor: clean explorer merge `isSvg` paths, remove console logs * refactor: add documentation, remove unused funcs * feat: rework folder collapse logic use grids instead of jank scuffed solution with calculating total heights * refactor: remove depth arg from insert * feat: restore collapse functionality to clicks allow folder icon + folder label to collapse folders again * refactor: remove `pointer-event` jank * feat: improve svg viewbox + remove unused props * feat: use css selector to toggle icon rework folder icon to work purely with css instead of JS manipulation * refactor: remove unused cfg * feat: move TOC to right sidebar * refactor: clean css * style: fix overflow + overflow margin * fix: use `resolveRelative` to resolve file paths * fix: `defaultFolderState` config option * refactor: rename import, rename `folderLi` + ul * fix: use `QuartzPluginData` type * docs: add explorer documentation --- docs/features/explorer.md | 41 ++++ quartz.layout.ts | 6 +- quartz/components/Explorer.tsx | 70 +++++++ quartz/components/ExplorerNode.tsx | 196 +++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/scripts/explorer.inline.ts | 141 +++++++++++++ quartz/components/styles/explorer.scss | 133 +++++++++++++ quartz/styles/base.scss | 4 +- 8 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 docs/features/explorer.md create mode 100644 quartz/components/Explorer.tsx create mode 100644 quartz/components/ExplorerNode.tsx create mode 100644 quartz/components/scripts/explorer.inline.ts create mode 100644 quartz/components/styles/explorer.scss diff --git a/docs/features/explorer.md b/docs/features/explorer.md new file mode 100644 index 0000000000000..17647de001248 --- /dev/null +++ b/docs/features/explorer.md @@ -0,0 +1,41 @@ +--- +title: "Explorer" +tags: + - component +--- + +Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and has options for customization. + +By default, it will show all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]]. + +> [!info] +> The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages. +> +> To clear/delete the explorer state from local storage, delete the `fileTree` entry (guide on how to delete a key from local storage in chromium based browsers can be found [here](https://docs.devolutions.net/kb/general-knowledge-base/clear-browser-local-storage/clear-chrome-local-storage/)). You can disable this by passing `useSavedState: false` as an argument. + +## Customization + +Most configuration can be done by passing in options to `Component.Explorer()`. + +For example, here's what the default configuration looks like: + +```typescript title="quartz.layout.ts" +Component.Explorer({ + title: "Explorer", // title of the explorer component + folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) + folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open") + useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer +}) +``` + +When passing in your own options, you can omit any or all of these fields if you'd like to keep the default value for that field. + +Want to customize it even more? + +- Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts` + - (optional): After removing the explorer component, you can move the [[table of contents]] component back to the `left` part of the layout +- Component: + - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx` + - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx` +- Style: `quartz/components/styles/explorer.scss` +- Script: `quartz/components/scripts/explorer.inline.ts` diff --git a/quartz.layout.ts b/quartz.layout.ts index 482aba6e3d8db..8c1c6c114dc5b 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -21,9 +21,13 @@ export const defaultContentPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), + Component.DesktopOnly(Component.Explorer()), + ], + right: [ + Component.Graph(), Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), ], - right: [Component.Graph(), Component.Backlinks()], } // components for pages that display lists of pages (e.g. tags or folders) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx new file mode 100644 index 0000000000000..ce69491e97d74 --- /dev/null +++ b/quartz/components/Explorer.tsx @@ -0,0 +1,70 @@ +import { QuartzComponentConstructor, QuartzComponentProps } from "./types" +import explorerStyle from "./styles/explorer.scss" + +// @ts-ignore +import script from "./scripts/explorer.inline" +import { ExplorerNode, FileNode, Options } from "./ExplorerNode" + +// Options interface defined in `ExplorerNode` to avoid circular dependency +const defaultOptions = (): Options => ({ + title: "Explorer", + folderClickBehavior: "collapse", + folderDefaultState: "collapsed", + useSavedState: true, +}) +export default ((userOpts?: Partial) => { + function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { + // Parse config + const opts: Options = { ...defaultOptions(), ...userOpts } + + // Construct tree from allFiles + const fileTree = new FileNode("") + allFiles.forEach((file) => fileTree.add(file, 1)) + + // Sort tree (folders first, then files (alphabetic)) + fileTree.sort() + + // 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]) + const jsonTree = JSON.stringify(folders) + + return ( +
    + +
    +
      + +
    +
    +
    + ) + } + Explorer.css = explorerStyle + Explorer.afterDOMLoaded = script + return Explorer +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx new file mode 100644 index 0000000000000..6718ec9facf35 --- /dev/null +++ b/quartz/components/ExplorerNode.tsx @@ -0,0 +1,196 @@ +// @ts-ignore +import { QuartzPluginData } from "vfile" +import { resolveRelative } from "../util/path" + +export interface Options { + title: string + folderDefaultState: "collapsed" | "open" + folderClickBehavior: "collapse" | "link" + useSavedState: boolean +} + +type DataWrapper = { + file: QuartzPluginData + path: string[] +} + +export type FolderState = { + path: string + collapsed: boolean +} + +// Structure to add all files into a tree +export class FileNode { + children: FileNode[] + name: string + file: QuartzPluginData | null + depth: number + + constructor(name: string, file?: QuartzPluginData, depth?: number) { + this.children = [] + this.name = name + this.file = file ?? null + this.depth = depth ?? 0 + } + + private insert(file: DataWrapper) { + if (file.path.length === 1) { + this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1)) + } 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 + } + } + + const newChild = new FileNode(next, undefined, this.depth + 1) + newChild.insert(file) + 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)) + } + + /** + * Get folder representation with state of tree. + * Intended to only be called on root node before changes to the tree are made + * @param collapsed default state of folders (collapsed by default or not) + * @returns array containing folder state for tree + */ + getFolderPaths(collapsed: boolean): FolderState[] { + const folderPaths: FolderState[] = [] + + const traverse = (node: FileNode, currentPath: string) => { + if (!node.file) { + const folderPath = currentPath + (currentPath ? "/" : "") + node.name + if (folderPath !== "") { + folderPaths.push({ path: folderPath, collapsed }) + } + node.children.forEach((child) => traverse(child, folderPath)) + } + } + + traverse(this, "") + + return folderPaths + } + + // Sort order: folders first, then files. Sort folders and files alphabetically + sort() { + this.children = this.children.sort((a, b) => { + if ((!a.file && !b.file) || (a.file && b.file)) { + return a.name.localeCompare(b.name) + } + if (a.file && !b.file) { + return 1 + } else { + return -1 + } + }) + + this.children.forEach((e) => e.sort()) + } +} + +type ExplorerNodeProps = { + node: FileNode + opts: Options + fileData: QuartzPluginData + fullPath?: string +} + +export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { + // Get options + const folderBehavior = opts.folderClickBehavior + const isDefaultOpen = opts.folderDefaultState === "open" + + // Calculate current folderPath + let pathOld = fullPath ? fullPath : "" + let folderPath = "" + if (node.name !== "") { + folderPath = `${pathOld}/${node.name}` + } + + return ( +
    + {node.file ? ( + // Single file node +
  • + + {node.file.frontmatter?.title} + +
  • + ) : ( +
    + {node.name !== "" && ( + // Node with entire folder + // Render svg button + folder name, then children + + )} + {/* Recursively render children of folder */} +
    +
      + {node.children.map((childNode, i) => ( + + ))} +
    +
    +
    + )} +
    + ) +} diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 10a43acb534b4..d7b6a1c5e59d9 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -9,6 +9,7 @@ import PageTitle from "./PageTitle" import ContentMeta from "./ContentMeta" import Spacer from "./Spacer" import TableOfContents from "./TableOfContents" +import Explorer from "./Explorer" import TagList from "./TagList" import Graph from "./Graph" import Backlinks from "./Backlinks" @@ -29,6 +30,7 @@ export { ContentMeta, Spacer, TableOfContents, + Explorer, TagList, Graph, Backlinks, diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts new file mode 100644 index 0000000000000..807397998c951 --- /dev/null +++ b/quartz/components/scripts/explorer.inline.ts @@ -0,0 +1,141 @@ +import { FolderState } from "../ExplorerNode" + +// Current state of folders +let explorerState: FolderState[] + +function toggleExplorer(this: HTMLElement) { + // Toggle collapsed state of entire explorer + this.classList.toggle("collapsed") + const content = this.nextElementSibling as HTMLElement + content.classList.toggle("collapsed") + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" +} + +function toggleFolder(evt: MouseEvent) { + evt.stopPropagation() + + // Element that was clicked + const target = evt.target as HTMLElement + + // Check if target was svg icon or button + const isSvg = target.nodeName === "svg" + + // corresponding
      element relative to clicked button/folder + let childFolderContainer: HTMLElement + + //
    • element of folder (stores folder-path dataset) + let currentFolderParent: HTMLElement + + // Get correct relative container and toggle collapsed class + if (isSvg) { + childFolderContainer = target.parentElement?.nextSibling as HTMLElement + currentFolderParent = target.nextElementSibling as HTMLElement + + childFolderContainer.classList.toggle("open") + } else { + childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement + currentFolderParent = target.parentElement as HTMLElement + + childFolderContainer.classList.toggle("open") + } + if (!childFolderContainer) return + + // Collapse folder container + const isCollapsed = childFolderContainer.classList.contains("open") + setFolderState(childFolderContainer, !isCollapsed) + + // Save folder state to localStorage + const clickFolderPath = currentFolderParent.dataset.folderpath as string + + // Remove leading "/" + const fullFolderPath = clickFolderPath.substring(1) + toggleCollapsedByPath(explorerState, fullFolderPath) + + const stringifiedFileTree = JSON.stringify(explorerState) + localStorage.setItem("fileTree", stringifiedFileTree) +} + +function setupExplorer() { + // Set click handler for collapsing entire explorer + const explorer = document.getElementById("explorer") + + // Get folder state from local storage + const storageTree = localStorage.getItem("fileTree") + + // Convert to bool + const useSavedFolderState = explorer?.dataset.savestate === "true" + + if (explorer) { + // Get config + const collapseBehavior = explorer.dataset.behavior + + // Add click handlers for all folders (click handler on folder "label") + if (collapseBehavior === "collapse") { + Array.prototype.forEach.call( + document.getElementsByClassName("folder-button"), + function (item) { + item.removeEventListener("click", toggleFolder) + item.addEventListener("click", toggleFolder) + }, + ) + } + + // Add click handler to main explorer + explorer.removeEventListener("click", toggleExplorer) + explorer.addEventListener("click", toggleExplorer) + } + + // Set up click handlers for each folder (click handler on folder "icon") + Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) { + item.removeEventListener("click", toggleFolder) + item.addEventListener("click", toggleFolder) + }) + + if (storageTree && useSavedFolderState) { + // Get state from localStorage and set folder state + explorerState = JSON.parse(storageTree) + explorerState.map((folderUl) => { + // grab
    • element for matching folder path + const folderLi = document.querySelector( + `[data-folderpath='/${folderUl.path}']`, + ) as HTMLElement + + // Get corresponding content
        tag and set state + const folderUL = folderLi.parentElement?.nextElementSibling as HTMLElement + setFolderState(folderUL, folderUl.collapsed) + }) + } else { + // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset + explorerState = JSON.parse(explorer?.dataset.tree as string) + } +} + +window.addEventListener("resize", setupExplorer) +document.addEventListener("nav", () => { + setupExplorer() +}) + +/** + * Toggles the state of a given folder + * @param folderElement
        Element of folder (parent) + * @param collapsed if folder should be set to collapsed or not + */ +function setFolderState(folderElement: HTMLElement, collapsed: boolean) { + if (collapsed) { + folderElement?.classList.remove("open") + } else { + folderElement?.classList.add("open") + } +} + +/** + * Toggles visibility of a folder + * @param array array of FolderState (`fileTree`, either get from local storage or data attribute) + * @param path path to folder (e.g. 'advanced/more/more2') + */ +function toggleCollapsedByPath(array: FolderState[], path: string) { + const entry = array.find((item) => item.path === path) + if (entry) { + entry.collapsed = !entry.collapsed + } +} diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss new file mode 100644 index 0000000000000..4b25a55f974fb --- /dev/null +++ b/quartz/components/styles/explorer.scss @@ -0,0 +1,133 @@ +button#explorer { + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding: 0; + color: var(--dark); + display: flex; + align-items: center; + + & h3 { + font-size: 1rem; + display: inline-block; + margin: 0; + } + + & .fold { + margin-left: 0.5rem; + transition: transform 0.3s ease; + opacity: 0.8; + } + + &.collapsed .fold { + transform: rotateZ(-90deg); + } +} + +.folder-outer { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-in-out; +} + +.folder-outer.open { + grid-template-rows: 1fr; +} + +.folder-outer > ul { + overflow: hidden; +} + +#explorer-content { + list-style: none; + overflow: hidden; + max-height: none; + transition: max-height 0.35s ease; + margin-top: 0.5rem; + + &.collapsed > .overflow::after { + opacity: 0; + } + + & ul { + list-style: none; + margin: 0.08rem 0; + padding: 0; + transition: + max-height 0.35s ease, + transform 0.35s ease, + opacity 0.2s ease; + & div > li > a { + color: var(--dark); + opacity: 0.75; + pointer-events: all; + } + } +} + +svg { + pointer-events: all; + + & > polyline { + pointer-events: none; + } +} + +.folder-container { + flex-direction: row; + display: flex; + align-items: center; + user-select: none; + + & li > a { + // other selector is more specific, needs important + color: var(--secondary) !important; + opacity: 1 !important; + font-size: 1.05rem !important; + } + + & li > a:hover { + // other selector is more specific, needs important + color: var(--tertiary) !important; + } + + & li > button { + color: var(--dark); + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding-left: 0; + padding-right: 0; + display: flex; + align-items: center; + + & h3 { + font-size: 0.95rem; + display: inline-block; + color: var(--secondary); + font-weight: 600; + margin: 0; + line-height: 1.5rem; + font-weight: bold; + pointer-events: none; + } + } +} + +.folder-icon { + margin-right: 5px; + color: var(--secondary); + cursor: pointer; + transition: transform 0.3s ease; + backface-visibility: visible; +} + +div:has(> .folder-outer:not(.open)) > .folder-container > svg { + transform: rotate(-90deg); +} + +.folder-icon:hover { + color: var(--tertiary); +} diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 92c0f84d9b28f..c6925fbe51919 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -446,7 +446,7 @@ video { ul.overflow, ol.overflow { - height: 300px; + max-height: 300; overflow-y: auto; // clearfix @@ -454,7 +454,7 @@ ol.overflow { clear: both; & > li:last-of-type { - margin-bottom: 50px; + margin-bottom: 30px; } &:after { From 5dcb7e83fc3c8383ebbc84aac4553df4ad3ef59a Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 15 Sep 2023 09:46:06 -0700 Subject: [PATCH 044/205] fix: use git dates by default, @napi/git is fast enough --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index f677a18f9572b..8674bc62f287f 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -47,7 +47,7 @@ const config: QuartzConfig = { Plugin.FrontMatter(), Plugin.TableOfContents(), Plugin.CreatedModifiedDate({ - priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower + priority: ["frontmatter", "git", "filesystem"], }), Plugin.SyntaxHighlighting(), Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), From 9ae6343dd0104d44e6bdf083572f987b70ba50c9 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Fri, 15 Sep 2023 10:33:38 -0700 Subject: [PATCH 045/205] Revert "fix: use git dates by default, @napi/git is fast enough" This reverts commit 5dcb7e83fc3c8383ebbc84aac4553df4ad3ef59a. --- quartz.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz.config.ts b/quartz.config.ts index 8674bc62f287f..f677a18f9572b 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -47,7 +47,7 @@ const config: QuartzConfig = { Plugin.FrontMatter(), Plugin.TableOfContents(), Plugin.CreatedModifiedDate({ - priority: ["frontmatter", "git", "filesystem"], + priority: ["frontmatter", "filesystem"], // you can add 'git' here for last modified from Git but this makes the build slower }), Plugin.SyntaxHighlighting(), Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), From 422ba5c36586c7ebc31141da8bb539b4157aa01a Mon Sep 17 00:00:00 2001 From: Yuto Nagata <38714187+mouse484@users.noreply.github.com> Date: Sat, 16 Sep 2023 11:17:20 +0900 Subject: [PATCH 046/205] fix: umami analytics date attribute (#477) --- quartz/plugins/emitters/componentResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 96db8aa81e7c4..1290a35483921 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -100,7 +100,7 @@ function addGlobalPageResources( componentResources.afterDOMLoaded.push(` const umamiScript = document.createElement("script") umamiScript.src = "https://analytics.umami.is/script.js" - umamiScript["data-website-id"] = "${cfg.analytics.websiteId}" + umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.async = true document.head.appendChild(umamiScript) From c7d3474ba8cb49ab0f1978216d80b08ec2c8e5d7 Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 12:40:19 +0200 Subject: [PATCH 047/205] feat(explorer): add config to support custom sort fn --- quartz/components/Explorer.tsx | 13 ++++++++++++- quartz/components/ExplorerNode.tsx | 21 ++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ce69491e97d74..ee0f96ff3ca56 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -11,6 +11,17 @@ const defaultOptions = (): Options => ({ folderClickBehavior: "collapse", folderDefaultState: "collapsed", useSavedState: true, + // Sort order: folders first, then files. Sort folders and files alphabetically + sortFn: (a, b) => { + if ((!a.file && !b.file) || (a.file && b.file)) { + return a.name.localeCompare(b.name) + } + if (a.file && !b.file) { + return 1 + } else { + return -1 + } + }, }) export default ((userOpts?: Partial) => { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { @@ -22,7 +33,7 @@ export default ((userOpts?: Partial) => { allFiles.forEach((file) => fileTree.add(file, 1)) // Sort tree (folders first, then files (alphabetic)) - fileTree.sort() + fileTree.sort(opts.sortFn!) // Get all folders of tree. Initialize with collapsed state const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index 6718ec9facf35..4d00103d1048a 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -7,6 +7,7 @@ export interface Options { folderDefaultState: "collapsed" | "open" folderClickBehavior: "collapse" | "link" useSavedState: boolean + sortFn: (a: FileNode, b: FileNode) => number } type DataWrapper = { @@ -90,19 +91,13 @@ export class FileNode { } // Sort order: folders first, then files. Sort folders and files alphabetically - sort() { - this.children = this.children.sort((a, b) => { - if ((!a.file && !b.file) || (a.file && b.file)) { - return a.name.localeCompare(b.name) - } - if (a.file && !b.file) { - return 1 - } else { - return -1 - } - }) - - this.children.forEach((e) => e.sort()) + /** + * Sorts tree according to sort/compare function + * @param sortFn compare function used for `.sort()`, also used recursively for children + */ + sort(sortFn: (a: FileNode, b: FileNode) => number) { + this.children = this.children.sort(sortFn) + this.children.forEach((e) => e.sort(sortFn)) } } From 58aea1cb0791e18cd092d88de5374431eba7f1d3 Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 17:28:58 +0200 Subject: [PATCH 048/205] feat: implement filter function for explorer --- quartz/components/ExplorerNode.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index 4d00103d1048a..40e526ac88f4e 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -66,6 +66,21 @@ export class FileNode { this.children.forEach((e) => e.print(depth + 1)) } + filter(filterFn: (node: FileNode) => boolean) { + const filteredNodes: FileNode[] = [] + + const traverse = (node: FileNode) => { + if (filterFn(node)) { + filteredNodes.push(node) + } + node.children.forEach(traverse) + } + + traverse(this) + + this.children = filteredNodes + } + /** * Get folder representation with state of tree. * Intended to only be called on root node before changes to the tree are made From 036a33f70bcabc17469956740847796a5f13b9ab Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 17:47:44 +0200 Subject: [PATCH 049/205] fix: use correct import for `QuartzPluginData` --- quartz/components/ExplorerNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index 40e526ac88f4e..d96242543a966 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -1,5 +1,5 @@ // @ts-ignore -import { QuartzPluginData } from "vfile" +import { QuartzPluginData } from "../plugins/vfile" import { resolveRelative } from "../util/path" export interface Options { From 31d16fbd2c82380af586e458b2c1ff29b90b53ae Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 19:18:59 +0200 Subject: [PATCH 050/205] feat(explorer): integrate filter option --- quartz/components/Explorer.tsx | 5 +++++ quartz/components/ExplorerNode.tsx | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ee0f96ff3ca56..efc9f6aa43fd8 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -35,6 +35,11 @@ export default ((userOpts?: Partial) => { // Sort tree (folders first, then files (alphabetic)) fileTree.sort(opts.sortFn!) + // If provided, apply filter function to fileTree + if (opts.filterFn) { + fileTree.filter(opts.filterFn) + } + // Get all folders of tree. Initialize with collapsed state const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index d96242543a966..5cf3f01543ec3 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -8,6 +8,7 @@ export interface Options { folderClickBehavior: "collapse" | "link" useSavedState: boolean sortFn: (a: FileNode, b: FileNode) => number + filterFn?: (node: FileNode) => boolean } type DataWrapper = { @@ -66,6 +67,10 @@ export class FileNode { this.children.forEach((e) => e.print(depth + 1)) } + /** + * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place + * @param filterFn function to filter tree with + */ filter(filterFn: (node: FileNode) => boolean) { const filteredNodes: FileNode[] = [] From 3d8c470c0d298f720614318fb4c14575e72bbd2e Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 19:35:27 +0200 Subject: [PATCH 051/205] feat(explorer): implement `map` fn argument Add a function for mapping over all FileNodes as an option for `Explorer` --- quartz/components/Explorer.tsx | 5 +++++ quartz/components/ExplorerNode.tsx | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index efc9f6aa43fd8..23c5db2617429 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -40,6 +40,11 @@ export default ((userOpts?: Partial) => { fileTree.filter(opts.filterFn) } + // If provided, apply map function to fileTree + if (opts.mapFn) { + fileTree.map(opts.mapFn) + } + // Get all folders of tree. Initialize with collapsed state const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index 5cf3f01543ec3..b8d8c1401a2af 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -9,6 +9,7 @@ export interface Options { useSavedState: boolean sortFn: (a: FileNode, b: FileNode) => number filterFn?: (node: FileNode) => boolean + mapFn?: (node: FileNode) => void } type DataWrapper = { @@ -86,6 +87,16 @@ export class FileNode { this.children = filteredNodes } + /** + * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place + * @param mapFn function to filter tree with + */ + map(mapFn: (node: FileNode) => void) { + mapFn(this) + + this.children.forEach((child) => child.map(mapFn)) + } + /** * Get folder representation with state of tree. * Intended to only be called on root node before changes to the tree are made From fea352849c6972da4b3b8935eb2e86f6cefc76ed Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 19:45:21 +0200 Subject: [PATCH 052/205] fix: create deep copy of file passed into tree --- quartz/components/ExplorerNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index b8d8c1401a2af..e1c8b8e32ce58 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -32,7 +32,7 @@ export class FileNode { constructor(name: string, file?: QuartzPluginData, depth?: number) { this.children = [] this.name = name - this.file = file ?? null + this.file = file ? structuredClone(file) : null this.depth = depth ?? 0 } From f7029012dfb73ce04405bfe44e4e4d984818bf5f Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sat, 16 Sep 2023 21:58:38 +0200 Subject: [PATCH 053/205] feat: black magic add config for `order` array, which determines the order in which all passed config functions for explorer will get executed in. functions will now dynamically be called on `fileTree` via array accessor (e.g. fileTree["sort"].call(...)) with corresponding function from options being passed to call) --- quartz/components/Explorer.tsx | 35 ++++++++++++++++++++++-------- quartz/components/ExplorerNode.tsx | 3 +++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 23c5db2617429..346bd7587c524 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -22,6 +22,7 @@ const defaultOptions = (): Options => ({ return -1 } }, + order: ["filter", "map", "sort"], }) export default ((userOpts?: Partial) => { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { @@ -32,17 +33,33 @@ export default ((userOpts?: Partial) => { const fileTree = new FileNode("") allFiles.forEach((file) => fileTree.add(file, 1)) - // Sort tree (folders first, then files (alphabetic)) - fileTree.sort(opts.sortFn!) - - // If provided, apply filter function to fileTree - if (opts.filterFn) { - fileTree.filter(opts.filterFn) + /** + * 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, } - // If provided, apply map function to fileTree - if (opts.mapFn) { - fileTree.map(opts.mapFn) + // 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]) + } + } } // Get all folders of tree. Initialize with collapsed state diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index e1c8b8e32ce58..b1817444df0c8 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -2,6 +2,8 @@ import { QuartzPluginData } from "../plugins/vfile" import { resolveRelative } from "../util/path" +type OrderEntries = "sort" | "filter" | "map" + export interface Options { title: string folderDefaultState: "collapsed" | "open" @@ -10,6 +12,7 @@ export interface Options { sortFn: (a: FileNode, b: FileNode) => number filterFn?: (node: FileNode) => boolean mapFn?: (node: FileNode) => void + order?: OrderEntries[] } type DataWrapper = { From 9358f73f1c939ce459d7835457527e35e1bdf857 Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sun, 17 Sep 2023 12:41:06 +0200 Subject: [PATCH 054/205] fix: display name for file nodes --- quartz/components/ExplorerNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index b1817444df0c8..e2d8871f2c1c2 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -160,7 +160,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro // Single file node
      • - {node.file.frontmatter?.title} + {node.name}
      • ) : ( From 94a04ab1c9fd099c808f3f4e6633722e0d13ac85 Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sun, 17 Sep 2023 15:51:08 +0200 Subject: [PATCH 055/205] fix(explorer): filter function in `ExplorerNode` --- quartz/components/ExplorerNode.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index e2d8871f2c1c2..f8b99f015bbeb 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -76,18 +76,8 @@ export class FileNode { * @param filterFn function to filter tree with */ filter(filterFn: (node: FileNode) => boolean) { - const filteredNodes: FileNode[] = [] - - const traverse = (node: FileNode) => { - if (filterFn(node)) { - filteredNodes.push(node) - } - node.children.forEach(traverse) - } - - traverse(this) - - this.children = filteredNodes + this.children = this.children.filter(filterFn) + this.children.forEach((child) => child.filter(filterFn)) } /** From 5cc9253c41fda87ba473df7023567ba66ce3c32b Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sun, 17 Sep 2023 16:41:23 +0200 Subject: [PATCH 056/205] docs(explorer): write docs for new features --- docs/features/explorer.md | 198 +++++++++++++++++++++++++++++++++++++- 1 file changed, 195 insertions(+), 3 deletions(-) diff --git a/docs/features/explorer.md b/docs/features/explorer.md index 17647de001248..8a9f506570d2f 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -4,9 +4,9 @@ tags: - component --- -Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and has options for customization. +Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and is highly customizable. -By default, it will show all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]]. +By default, it shows all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]]. > [!info] > The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages. @@ -25,6 +25,14 @@ Component.Explorer({ folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click) folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open") useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer + // Sort order: folders first, then files. Sort folders and files alphabetically + sortFn: (a, b) => { + ... // default implementation shown later + }, + filterFn: undefined, + mapFn: undefined, + // what order to apply functions in + order: ["filter", "map", "sort"], }) ``` @@ -33,9 +41,193 @@ When passing in your own options, you can omit any or all of these fields if you Want to customize it even more? - Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts` - - (optional): After removing the explorer component, you can move the [[table of contents]] component back to the `left` part of the layout + - (optional): After removing the explorer component, you can move the [[table of contents | Table of Contents]] component back to the `left` part of the layout +- Changing `sort`, `filter` and `map` behavior: explained in [[explorer#Advanced customization]] - Component: - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx` - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx` - Style: `quartz/components/styles/explorer.scss` - Script: `quartz/components/scripts/explorer.inline.ts` + +## Advanced customization + +This component allows you to fully customize all of its behavior. You can pass a custom `sort`, `filter` and `map` function. +All functions you can pass work with the `FileNode` class, which has the following properties: + +```ts title="quartz/components/ExplorerNode.tsx" {2-5} +export class FileNode { + children: FileNode[] // children of current node + name: string // name of node (only useful for folders) + file: QuartzPluginData | null // set if node is a file, see `QuartzPluginData` for more detail + depth: number // depth of current node + + ... // rest of implementation +} +``` + +Every function you can pass is optional. By default, only a `sort` function will be used: + +```ts title="Default sort function" +// Sort order: folders first, then files. Sort folders and files alphabetically +Component.Explorer({ + sortFn: (a, b) => { + if ((!a.file && !b.file) || (a.file && b.file)) { + return a.name.localeCompare(b.name) + } + if (a.file && !b.file) { + return 1 + } else { + return -1 + } + }, +}) +``` + +--- + +You can pass your own functions for `sortFn`, `filterFn` and `mapFn`. All functions will be executed in the order provided by the `order` option (see [[explorer#Customization]]). These functions behave similarly to their `Array.prototype` counterpart, except they modify the entire `FileNode` tree in place instead of returning a new one. + +For more information on how to use `sort`, `filter` and `map`, you can check [Array.prototype.sort()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort), [Array.prototype.filter()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) and [Array.prototype.map()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). + +Type definitions look like this: + +```ts +sortFn: (a: FileNode, b: FileNode) => number +filterFn: (node: FileNode) => boolean +mapFn: (node: FileNode) => void +``` + +> [!tip] +> You can check if a `FileNode` is a folder or a file like this: +> +> ```ts +> if (node.file) { +> // node is a file +> } else { +> // node is a folder +> } +> ``` + +## Basic examples + +These examples show the basic usage of `sort`, `map` and `filter`. + +### Use `sort` to put files first + +Using this example, the explorer will alphabetically sort everything, but put all **files** above all **folders**. + +```ts title="quartz.layout.ts" +Component.Explorer({ + sortFn: (a, b) => { + if ((!a.file && !b.file) || (a.file && b.file)) { + return a.name.localeCompare(b.name) + } + if (a.file && !b.file) { + return -1 + } else { + return 1 + } + }, +}) +``` + +### Change display names (`map`) + +Using this example, the display names of all `FileNodes` (folders + files) will be converted to full upper case. + +```ts title="quartz.layout.ts" +Component.Explorer({ + mapFn: (node) => { + node.name = node.name.toUpperCase() + }, +}) +``` + +### Remove list of elements (`filter`) + +Using this example, you can remove elements from your explorer by providing a list of folders/files using the `list` array. + +```ts title="quartz.layout.ts" +Component.Explorer({ + filterFn: (node) => { + // list containing names of everything you want to filter out + const list = ["authoring content", "building your", "tags", "hosting"] + + for (let listNodeName of list) { + if (listNodeName.toLowerCase() === node.name.toLowerCase()) { + return false // Found a match, so return false to filter out the node + } + } + return true // No match found, so return true to keep the node + }, +}) +``` + +You can customize this by changing the entries of the `list` array. Simply add all folder or file names you want to remove to the array (case insensitive). + +## Advanced examples + +### Add emoji prefix + +To add emoji prefixes (📁 for folders, 📄 for files), you could use a map function like this: + +```ts title="quartz.layout.ts" +Component.Explorer({ + mapFn: (node) => { + // dont change name of root node + if (node.depth > 0) { + // set emoji for file/folder + if (node.file) { + node.name = "📄 " + node.name + } else { + node.name = "📁 " + node.name + } + } + }, +}}) +``` + +### Putting it all together + +In this example, we're going to customize the explorer by using functions from examples above to [[explorer#Add emoji prefix | add emoji prefixes]], [[explorer#remove-list-of-elements-filter| filter out some folders]] and [[explorer#use-sort-to-put-files-first | sort with files above folders]]. + +```ts title="quartz.layout.ts" +Component.Explorer({ + filterFn: sampleFilterFn, + mapFn: sampleMapFn, + sortFn: sampleSortFn, + order: ["filter", "sort", "map"], +}) +``` + +Notice how we customized the `order` array here. This is done because the default order applies the `sort` function last. While this normally works well, it would cause unintended behavior here, since we changed the first characters of all display names. In our example, `sort` would be applied based off the emoji prefix instead of the first _real_ character. + +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 "./path/to/your/functions" +> Component.Explorer({ +> mapFn: mapFn, +> filterFn: filterFn, +> sortFn: sortFn, +> }) +> ``` From 7ac772fca8bf26c1023f905cdb77e6972a0d4b61 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:29:20 +0200 Subject: [PATCH 057/205] fix: darkmode scroll bars (#480) --- quartz/components/styles/darkmode.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss index 10cbc72a5ed1c..348c6f79373f0 100644 --- a/quartz/components/styles/darkmode.scss +++ b/quartz/components/styles/darkmode.scss @@ -21,6 +21,14 @@ } } +:root[saved-theme="dark"] { + color-scheme: dark; +} + +:root[saved-theme="light"] { + color-scheme: light; +} + :root[saved-theme="dark"] .toggle ~ label { & > #dayIcon { opacity: 0; From af41f34bfd4126756e594ce4d6a46d4f4907754b Mon Sep 17 00:00:00 2001 From: Christian Gill Date: Sun, 17 Sep 2023 20:02:00 +0200 Subject: [PATCH 058/205] fix(slug): Handle question mark (#481) --- quartz/util/path.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 1540063746901..173eb2ece952b 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -52,7 +52,7 @@ export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug { let slug = withoutFileExt .split("/") - .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent")) // slugify all segments + .map((segment) => segment.replace(/\s/g, "-").replace(/%/g, "-percent").replace(/\?/g, "-q")) // slugify all segments .join("/") // always use / as sep .replace(/\/$/, "") // remove trailing slash From 6914d4b40caff901ccf3e9d9113c15129a68a80c Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sun, 17 Sep 2023 21:20:09 +0200 Subject: [PATCH 059/205] docs: fix intra page links --- docs/features/explorer.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/explorer.md b/docs/features/explorer.md index 8a9f506570d2f..76d04c675bc6b 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -42,7 +42,7 @@ Want to customize it even more? - Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts` - (optional): After removing the explorer component, you can move the [[table of contents | Table of Contents]] component back to the `left` part of the layout -- Changing `sort`, `filter` and `map` behavior: explained in [[explorer#Advanced customization]] +- Changing `sort`, `filter` and `map` behavior: explained in [[#Advanced customization]] - Component: - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx` - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx` @@ -85,7 +85,7 @@ Component.Explorer({ --- -You can pass your own functions for `sortFn`, `filterFn` and `mapFn`. All functions will be executed in the order provided by the `order` option (see [[explorer#Customization]]). These functions behave similarly to their `Array.prototype` counterpart, except they modify the entire `FileNode` tree in place instead of returning a new one. +You can pass your own functions for `sortFn`, `filterFn` and `mapFn`. All functions will be executed in the order provided by the `order` option (see [[#Customization]]). These functions behave similarly to their `Array.prototype` counterpart, except they modify the entire `FileNode` tree in place instead of returning a new one. For more information on how to use `sort`, `filter` and `map`, you can check [Array.prototype.sort()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort), [Array.prototype.filter()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter) and [Array.prototype.map()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map). @@ -189,7 +189,7 @@ Component.Explorer({ ### Putting it all together -In this example, we're going to customize the explorer by using functions from examples above to [[explorer#Add emoji prefix | add emoji prefixes]], [[explorer#remove-list-of-elements-filter| filter out some folders]] and [[explorer#use-sort-to-put-files-first | sort with files above folders]]. +In this example, we're going to customize the explorer by using functions from examples above to [[#Add emoji prefix | add emoji prefixes]], [[#remove-list-of-elements-filter| filter out some folders]] and [[#use-sort-to-put-files-first | sort with files above folders]]. ```ts title="quartz.layout.ts" Component.Explorer({ From 4afb099bf3ec96e5d795e871ecb19575271c0714 Mon Sep 17 00:00:00 2001 From: Ben Schlegel Date: Sun, 17 Sep 2023 21:32:23 +0200 Subject: [PATCH 060/205] docs: fix examples --- docs/features/explorer.md | 18 ++++++------------ quartz/components/ExplorerNode.tsx | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/docs/features/explorer.md b/docs/features/explorer.md index 76d04c675bc6b..cb63e403ab0d1 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -145,25 +145,19 @@ Component.Explorer({ ### Remove list of elements (`filter`) -Using this example, you can remove elements from your explorer by providing a list of folders/files using the `list` array. +Using this example, you can remove elements from your explorer by providing an array of folders/files using the `omit` set. ```ts title="quartz.layout.ts" Component.Explorer({ filterFn: (node) => { - // list containing names of everything you want to filter out - const list = ["authoring content", "building your", "tags", "hosting"] - - for (let listNodeName of list) { - if (listNodeName.toLowerCase() === node.name.toLowerCase()) { - return false // Found a match, so return false to filter out the node - } - } - return true // No match found, so return true to keep the node + // set containing names of everything you want to filter out + const omit = new Set(["authoring content", "tags", "hosting"]) + return omit.has(node.name.toLowerCase()) }, }) ``` -You can customize this by changing the entries of the `list` array. Simply add all folder or file names you want to remove to the array (case insensitive). +You can customize this by changing the entries of the `omit` set. Simply add all folder or file names you want to remove. ## Advanced examples @@ -224,7 +218,7 @@ To fix this, we just changed around the order and apply the `sort` function befo > You can then import them like this: > > ```ts title="quartz.layout.ts" -> import { mapFn, filterFn, sortFn } from "./path/to/your/functions" +> import { mapFn, filterFn, sortFn } from "./functions.ts" > Component.Explorer({ > mapFn: mapFn, > filterFn: filterFn, diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index f8b99f015bbeb..fd0c0823d6e7c 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -82,7 +82,7 @@ export class FileNode { /** * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place - * @param mapFn function to filter tree with + * @param mapFn function to use for mapping over tree */ map(mapFn: (node: FileNode) => void) { mapFn(this) From 6a2e0b3ad3a928247a03a76817d239e61cce0fe0 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Sun, 17 Sep 2023 22:04:44 +0200 Subject: [PATCH 061/205] fix: bad visibility for last explorer item (#478) * fix: bad visibility for last explorer item * feat(explorer): add pseudo element for observer --- quartz/components/Explorer.tsx | 3 ++- quartz/components/scripts/explorer.inline.ts | 25 ++++++++++++++++++-- quartz/components/styles/explorer.scss | 9 +++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 346bd7587c524..0bdb5a65073d2 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -95,8 +95,9 @@ export default ((userOpts?: Partial) => {
        -
          +
            +
        diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 807397998c951..2b7df7d354916 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -3,6 +3,18 @@ import { FolderState } from "../ExplorerNode" // Current state of folders let explorerState: FolderState[] +const observer = new IntersectionObserver((entries) => { + // If last element is observed, remove gradient of "overflow" class so element is visible + const explorer = document.getElementById("explorer-ul") + for (const entry of entries) { + if (entry.isIntersecting) { + explorer?.classList.add("no-background") + } else { + explorer?.classList.remove("no-background") + } + } +}) + function toggleExplorer(this: HTMLElement) { // Toggle collapsed state of entire explorer this.classList.toggle("collapsed") @@ -101,8 +113,10 @@ function setupExplorer() { ) as HTMLElement // Get corresponding content
          tag and set state - const folderUL = folderLi.parentElement?.nextElementSibling as HTMLElement - setFolderState(folderUL, folderUl.collapsed) + const folderUL = folderLi.parentElement?.nextElementSibling + if (folderUL) { + setFolderState(folderUL as HTMLElement, folderUl.collapsed) + } }) } else { // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset @@ -113,6 +127,13 @@ function setupExplorer() { window.addEventListener("resize", setupExplorer) document.addEventListener("nav", () => { setupExplorer() + + const explorerContent = document.getElementById("explorer-ul") + // select pseudo element at end of list + const lastItem = document.getElementById("explorer-end") + + observer.disconnect() + observer.observe(lastItem as Element) }) /** diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index 4b25a55f974fb..776a5ae6e48ee 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -131,3 +131,12 @@ div:has(> .folder-outer:not(.open)) > .folder-container > svg { .folder-icon:hover { color: var(--tertiary); } + +.no-background::after { + background: none !important; +} + +#explorer-end { + // needs height so IntersectionObserver gets triggered + height: 1px; +} From 0d3cf2922618774fc397dca8cb92fcf76fb0db02 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Mon, 18 Sep 2023 23:32:00 +0200 Subject: [PATCH 062/205] docs: fix explorer example (#483) --- docs/features/explorer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/explorer.md b/docs/features/explorer.md index cb63e403ab0d1..6f941b87168a4 100644 --- a/docs/features/explorer.md +++ b/docs/features/explorer.md @@ -152,7 +152,7 @@ Component.Explorer({ filterFn: (node) => { // set containing names of everything you want to filter out const omit = new Set(["authoring content", "tags", "hosting"]) - return omit.has(node.name.toLowerCase()) + return !omit.has(node.name.toLowerCase()) }, }) ``` From cc31a40b0cb53cba7f51187cb6d68076c3f54c0f Mon Sep 17 00:00:00 2001 From: David Fischer Date: Tue, 19 Sep 2023 18:25:51 +0200 Subject: [PATCH 063/205] feat: support changes in system theme (#484) * feat: support changes in system theme * fix: run prettier * fix: add content/.gitkeep --- quartz/components/scripts/darkmode.inline.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts index e16f4f845d4b4..c42a367c9103f 100644 --- a/quartz/components/scripts/darkmode.inline.ts +++ b/quartz/components/scripts/darkmode.inline.ts @@ -20,4 +20,13 @@ document.addEventListener("nav", () => { if (currentTheme === "dark") { toggleSwitch.checked = true } + + // Listen for changes in prefers-color-scheme + const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + colorSchemeMediaQuery.addEventListener("change", (e) => { + const newTheme = e.matches ? "dark" : "light" + document.documentElement.setAttribute("saved-theme", newTheme) + localStorage.setItem("theme", newTheme) + toggleSwitch.checked = e.matches + }) }) From 1bf7e3d8b3966590ebfa3418d6fb2ce6a520c846 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Tue, 19 Sep 2023 10:22:39 -0700 Subject: [PATCH 064/205] fix(nit): make defaultOptions on explorer not a function --- quartz/components/Explorer.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 0bdb5a65073d2..8597075d2dcbb 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -6,7 +6,7 @@ import script from "./scripts/explorer.inline" import { ExplorerNode, FileNode, Options } from "./ExplorerNode" // Options interface defined in `ExplorerNode` to avoid circular dependency -const defaultOptions = (): Options => ({ +const defaultOptions = { title: "Explorer", folderClickBehavior: "collapse", folderDefaultState: "collapsed", @@ -23,11 +23,12 @@ const defaultOptions = (): Options => ({ } }, order: ["filter", "map", "sort"], -}) +} satisfies Options + export default ((userOpts?: Partial) => { function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) { // Parse config - const opts: Options = { ...defaultOptions(), ...userOpts } + const opts: Options = { ...defaultOptions, ...userOpts } // Construct tree from allFiles const fileTree = new FileNode("") From 27a6087dd5a25dd5031b86b9917adde6ef4b211a Mon Sep 17 00:00:00 2001 From: rwutscher Date: Tue, 19 Sep 2023 21:26:30 +0200 Subject: [PATCH 065/205] fix: tag regex no longer includes purely numerical 'tags' (#485) * fix: tag regex no longer includes purely numerical 'tags' * fix: formatting * fix: use guard in findAndReplace() instead of expanding the regex --- quartz/plugins/transformers/ofm.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 8306f40d8b21b..4d55edad8c38c 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -400,6 +400,10 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin return (tree: Root, file) => { const base = pathToRoot(file.data.slug!) findAndReplace(tree, tagRegex, (_value: string, tag: string) => { + // Check if the tag only includes numbers + if (/^\d+$/.test(tag)) { + return false + } tag = slugTag(tag) if (file.data.frontmatter && !file.data.frontmatter.tags.includes(tag)) { file.data.frontmatter.tags.push(tag) From d6301fae90d9f922618bf0f413e273156731eef7 Mon Sep 17 00:00:00 2001 From: Adam Brangenberg Date: Wed, 20 Sep 2023 20:38:13 +0200 Subject: [PATCH 066/205] feat: Making Quartz available offline by making it a PWA (#465) * Adding PWA and chaching for offline aviability * renamed workbox config to fit Quartz' scheme * Documenting new configuration * Added missig umami documentation * Fixed formatting so the build passes, thank you prettier :) * specified caching strategies to improve performance * formatting... * fixing "404 manifest.json not found" on subdirectories by adding a / to manifestpath * turning it into a plugin * Removed Workbox-cli and updated @types/node * Added Serviceworkercode to offline.ts * formatting * Removing workbox from docs * applied suggestions * Removed path.join for sw path Co-authored-by: Jacky Zhao * Removed path.join for manifest path Co-authored-by: Jacky Zhao * Removing path module import * Added absolute path to manifests start_url and manifest "import" using baseUrl * Adding protocol to baseurl Co-authored-by: Jacky Zhao * Adding protocol to start_url too then * formatting... * Adding fallback page * Documenting offline plugin * formatting... * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * merge suggestion Co-authored-by: Jacky Zhao * formatting... * Fixing manifest path, all these nits hiding the actual issues .-. * Offline fallback page through plugins, most things taken from 404 Plugin * adding Offline Plugin to config * formatting... * Turned offline off as default and removed offline.md --------- Co-authored-by: Jacky Zhao --- docs/configuration.md | 2 + docs/features/offline access.md | 31 ++++++ docs/index.md | 2 +- package-lock.json | 9 +- package.json | 2 +- quartz.config.ts | 1 + quartz/cfg.ts | 1 + quartz/components/Head.tsx | 4 + .../components/pages/OfflineFallbackPage.tsx | 12 +++ quartz/plugins/emitters/componentResources.ts | 5 + quartz/plugins/emitters/index.ts | 1 + quartz/plugins/emitters/offline.ts | 97 +++++++++++++++++++ quartz/static/icon.svg | 74 ++++++++++++++ 13 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 docs/features/offline access.md create mode 100644 quartz/components/pages/OfflineFallbackPage.tsx create mode 100644 quartz/plugins/emitters/offline.ts create mode 100644 quartz/static/icon.svg diff --git a/docs/configuration.md b/docs/configuration.md index 047f6ca6badc5..35e0b9d958e5c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,10 +21,12 @@ const config: QuartzConfig = { This part of the configuration concerns anything that can affect the whole site. The following is a list breaking down all the things you can configure: - `pageTitle`: title of the site. This is also used when generating the [[RSS Feed]] for your site. +- `description`: description of the site. This will be used when someone installs your site as an App. - `enableSPA`: whether to enable [[SPA Routing]] on your site. - `enablePopovers`: whether to enable [[popover previews]] on your site. - `analytics`: what to use for analytics on your site. Values can be - `null`: don't use analytics; + - `{ provider: "umami", websiteId: }`: easy, privacy-friendly, open source, GDPR Compliant analytics; - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or - `{ provider: 'google', tagId: }`: use Google Analytics - `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes. diff --git a/docs/features/offline access.md b/docs/features/offline access.md new file mode 100644 index 0000000000000..dcffdcd26b03c --- /dev/null +++ b/docs/features/offline access.md @@ -0,0 +1,31 @@ +--- +title: "Offline Access (PWA)" +tags: + - plugin/emitter +--- + +This plugin allows your website to be accessible offline and be installed as an app. You can use it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` + +## Offline Capability + +Whenever you visit a page it gets cached for offline use. Depending on the kind of content, the process for caching is diffent: + +- **Pages** (HTML, your converted Markdown files): Quartz first tries to get them over the Network. If that fails, your browser attempts to fetch it from the cache. +- **Static Resources** (Fonts, CSS Styling, JavaScript): Quartz uses cached resources by default and updates the cache over the network in the background. +- **Images**: Images are saved once and then served from cache. Quartz uses a limited cache of 60 images and images remain in the cache for 30 days + +You can edit the fallback page by changing the `offline.md` file in the root of your `content` directory + +## Progressive Web App (PWA) + +Progressive Web Apps can have [many properties](https://developer.mozilla.org/en-US/docs/Web/Manifest). We're only going to mention the ones Quartz supports by default, however you can edit the offline plugins file to add more in case required. + +- **icons**: the `icon.svg` file in the `quartz/static` directory is used for all the icons. This makes it easier to scale the image since you don't need to provide an png for every size +- **name**, **short_name**: Uses the `pageTitle` configured in `quartz.config.ts` +- **description**: Uses the `description` configured in `quartz.config.ts` +- **background_color**, **theme_color**: Uses the `lightMode.light` color configured in `quartz.config.ts`. +- **start_url**: Uses the `baseUrl` configured in `quartz.config.ts` + +### Default values + +- **display**: this is set to `minimal-ui` diff --git a/docs/index.md b/docs/index.md index 05de2bae97d97..570d5b364c980 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ This will guide you through initializing your Quartz with content. Once you've d ## 🔧 Features -- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box +- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[offline access]] and [many more](./features) right out of the box - Hot-reload for both configuration and content - Simple JSX layouts and [[creating components|page components]] - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes diff --git a/package-lock.json b/package-lock.json index a879078977376..8ff94245da299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,7 @@ "@types/flexsearch": "^0.7.3", "@types/hast": "^2.3.4", "@types/js-yaml": "^4.0.5", - "@types/node": "^20.1.2", + "@types/node": "^20.6.2", "@types/pretty-time": "^1.1.2", "@types/source-map-support": "^0.5.6", "@types/workerpool": "^6.4.0", @@ -113,6 +113,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1463,9 +1464,9 @@ } }, "node_modules/@types/node": { - "version": "20.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", - "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==", + "version": "20.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.2.tgz", + "integrity": "sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==", "dev": true }, "node_modules/@types/parse5": { diff --git a/package.json b/package.json index 0a2085cef5c1c..e514edfbd2de9 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "@types/flexsearch": "^0.7.3", "@types/hast": "^2.3.4", "@types/js-yaml": "^4.0.5", - "@types/node": "^20.1.2", + "@types/node": "^20.6.2", "@types/pretty-time": "^1.1.2", "@types/source-map-support": "^0.5.6", "@types/workerpool": "^6.4.0", diff --git a/quartz.config.ts b/quartz.config.ts index f677a18f9572b..5a1f643aaf70a 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -4,6 +4,7 @@ import * as Plugin from "./quartz/plugins" const config: QuartzConfig = { configuration: { pageTitle: "🪴 Quartz 4.0", + description: "Quartz Documentation Page and Demo", enableSPA: true, enablePopovers: true, analytics: { diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 8371b5e2be713..73e959fb78e87 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -19,6 +19,7 @@ export type Analytics = export interface GlobalConfiguration { pageTitle: string + description: string /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ enableSPA: boolean /** Whether to display Wikipedia-style popovers when hovering over links */ diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 2bf26381795ed..972f7497e626f 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -14,6 +14,8 @@ export default (() => { const iconPath = joinSegments(baseDir, "static/icon.png") const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` + const manifest = + cfg.baseUrl == undefined ? "/manifest.json" : `https://${cfg.baseUrl}/manifest.json` return ( @@ -25,7 +27,9 @@ export default (() => { {cfg.baseUrl && } + + diff --git a/quartz/components/pages/OfflineFallbackPage.tsx b/quartz/components/pages/OfflineFallbackPage.tsx new file mode 100644 index 0000000000000..14d4f5e9e51cd --- /dev/null +++ b/quartz/components/pages/OfflineFallbackPage.tsx @@ -0,0 +1,12 @@ +import { QuartzComponentConstructor } from "../types" + +function OfflineFallbackPage() { + return ( +
          +

          Offline

          +

          This page isn't offline available yet.

          +
          + ) +} + +export default (() => OfflineFallbackPage) satisfies QuartzComponentConstructor diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 1290a35483921..a82e7c12acdc5 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -116,6 +116,11 @@ function addGlobalPageResources( document.dispatchEvent(event)`) } + componentResources.afterDOMLoaded.push(` + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js'); + }`) + let wsUrl = `ws://localhost:${ctx.argv.wsPort}` if (ctx.argv.remoteDevHost) { diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index 99a2c54d5c1f4..6de824d5f7c01 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -7,3 +7,4 @@ export { Assets } from "./assets" export { Static } from "./static" export { ComponentResources } from "./componentResources" export { NotFoundPage } from "./404" +export { Offline } from "./offline" diff --git a/quartz/plugins/emitters/offline.ts b/quartz/plugins/emitters/offline.ts new file mode 100644 index 0000000000000..e3c654b691368 --- /dev/null +++ b/quartz/plugins/emitters/offline.ts @@ -0,0 +1,97 @@ +import { QuartzEmitterPlugin } from "../types" +import { FilePath, FullSlug } from "../../util/path" +import { FullPageLayout } from "../../cfg" +import { sharedPageComponents } from "../../../quartz.layout" +import OfflineFallbackPage from "../../components/pages/OfflineFallbackPage" +import BodyConstructor from "../../components/Body" +import { pageResources, renderPage } from "../../components/renderPage" +import { defaultProcessedContent } from "../vfile" +import { QuartzComponentProps } from "../../components/types" + +export const Offline: QuartzEmitterPlugin = () => { + const opts: FullPageLayout = { + ...sharedPageComponents, + pageBody: OfflineFallbackPage(), + beforeBody: [], + left: [], + right: [], + } + + const { head: Head, pageBody, footer: Footer } = opts + const Body = BodyConstructor() + + return { + name: "OfflineSupport", + getQuartzComponents() { + return [Head, Body, pageBody, Footer] + }, + async emit({ cfg }, _content, resources, emit): Promise { + const manifest = { + short_name: cfg.configuration.pageTitle, + name: cfg.configuration.pageTitle, + description: cfg.configuration.description, + background_color: cfg.configuration.theme.colors.lightMode.light, + theme_color: cfg.configuration.theme.colors.lightMode.light, + display: "minimal-ui", + icons: [ + { + src: "static/icon.svg", + sizes: "any", + purpose: "maskable", + }, + { + src: "static/icon.svg", + sizes: "any", + purpose: "any", + }, + ], + start_url: + cfg.configuration.baseUrl == undefined ? "/" : `https://${cfg.configuration.baseUrl}/`, + } + + const serviceWorker = + "importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');" + + "const {pageCache, imageCache, staticResourceCache, googleFontsCache, offlineFallback} = workbox.recipes;" + + "pageCache(); googleFontsCache(); staticResourceCache(); imageCache(); offlineFallback();" + + const slug = "offline" as FullSlug + + const url = new URL(`https://${cfg.configuration.baseUrl ?? "example.com"}`) + const path = url.pathname as FullSlug + const externalResources = pageResources(path, resources) + const [tree, vfile] = defaultProcessedContent({ + slug, + text: "Offline", + description: "This page isn't offline available yet.", + frontmatter: { title: "Offline", tags: [] }, + }) + + const componentData: QuartzComponentProps = { + fileData: vfile.data, + externalResources, + cfg: cfg.configuration, + children: [], + tree, + allFiles: [], + } + + return Promise.all([ + emit({ + content: JSON.stringify(manifest), + slug: "manifest" as FullSlug, + ext: ".json", + }), + emit({ + content: serviceWorker, + slug: "sw" as FullSlug, + ext: ".js", + }), + emit({ + content: renderPage(slug, componentData, opts, externalResources), + slug, + ext: ".html", + }), + ]) + }, + } +} diff --git a/quartz/static/icon.svg b/quartz/static/icon.svg new file mode 100644 index 0000000000000..c6ecfa2db9733 --- /dev/null +++ b/quartz/static/icon.svg @@ -0,0 +1,74 @@ + + + + + + + + From 52a172d1a4911080444ff797183e29ba8175741e Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 20 Sep 2023 11:40:36 -0700 Subject: [PATCH 067/205] docs: wording changes for offline support --- docs/features/offline access.md | 2 +- quartz/components/Explorer.tsx | 1 + quartz/components/pages/OfflineFallbackPage.tsx | 2 +- quartz/plugins/emitters/offline.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/features/offline access.md b/docs/features/offline access.md index dcffdcd26b03c..885bbd5017625 100644 --- a/docs/features/offline access.md +++ b/docs/features/offline access.md @@ -4,7 +4,7 @@ tags: - plugin/emitter --- -This plugin allows your website to be accessible offline and be installed as an app. You can use it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` +This plugin allows your website to be accessible offline and be installed as an app. You can enable it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` ## Offline Capability diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 8597075d2dcbb..c33d375420a2a 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -22,6 +22,7 @@ const defaultOptions = { return -1 } }, + filterFn: (node) => node.name !== "tags", order: ["filter", "map", "sort"], } satisfies Options diff --git a/quartz/components/pages/OfflineFallbackPage.tsx b/quartz/components/pages/OfflineFallbackPage.tsx index 14d4f5e9e51cd..d2fede3ceadae 100644 --- a/quartz/components/pages/OfflineFallbackPage.tsx +++ b/quartz/components/pages/OfflineFallbackPage.tsx @@ -4,7 +4,7 @@ function OfflineFallbackPage() { return (

          Offline

          -

          This page isn't offline available yet.

          +

          You're offline and this page hasn't been cached yet.

          ) } diff --git a/quartz/plugins/emitters/offline.ts b/quartz/plugins/emitters/offline.ts index e3c654b691368..b17771a221639 100644 --- a/quartz/plugins/emitters/offline.ts +++ b/quartz/plugins/emitters/offline.ts @@ -62,7 +62,7 @@ export const Offline: QuartzEmitterPlugin = () => { const [tree, vfile] = defaultProcessedContent({ slug, text: "Offline", - description: "This page isn't offline available yet.", + description: "You're offline and this page hasn't been cached yet.", frontmatter: { title: "Offline", tags: [] }, }) From 0bad3ce7990aa4ef417128f9d74c2947fe5117fd Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 20 Sep 2023 11:58:52 -0700 Subject: [PATCH 068/205] docs: document enableToc --- docs/features/table of contents.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/features/table of contents.md b/docs/features/table of contents.md index f058573681bbb..a66c8501705f9 100644 --- a/docs/features/table of contents.md +++ b/docs/features/table of contents.md @@ -8,6 +8,7 @@ tags: Quartz can automatically generate a table of contents from a list of headings on each page. It will also show you your current scroll position on the site by marking headings you've scrolled through with a different colour. By default, it will show all headers from H1 (`# Title`) all the way to H3 (`### Title`) and will only show the table of contents if there is more than 1 header on the page. +You can also hide the table of contents on a page by adding `showToc: false` to the frontmatter for that page. > [!info] > This feature requires both `Plugin.TableOfContents` in your `quartz.config.ts` and `Component.TableOfContents` in your `quartz.layout.ts` to function correctly. From 70e029d151ccbb9aeab30a0f811b9f529b7f8818 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 20 Sep 2023 13:52:29 -0700 Subject: [PATCH 069/205] Revert "docs: wording changes for offline support" This reverts commit 52a172d1a4911080444ff797183e29ba8175741e. --- docs/features/offline access.md | 2 +- quartz/components/Explorer.tsx | 1 - quartz/components/pages/OfflineFallbackPage.tsx | 2 +- quartz/plugins/emitters/offline.ts | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/features/offline access.md b/docs/features/offline access.md index 885bbd5017625..dcffdcd26b03c 100644 --- a/docs/features/offline access.md +++ b/docs/features/offline access.md @@ -4,7 +4,7 @@ tags: - plugin/emitter --- -This plugin allows your website to be accessible offline and be installed as an app. You can enable it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` +This plugin allows your website to be accessible offline and be installed as an app. You can use it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` ## Offline Capability diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index c33d375420a2a..8597075d2dcbb 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -22,7 +22,6 @@ const defaultOptions = { return -1 } }, - filterFn: (node) => node.name !== "tags", order: ["filter", "map", "sort"], } satisfies Options diff --git a/quartz/components/pages/OfflineFallbackPage.tsx b/quartz/components/pages/OfflineFallbackPage.tsx index d2fede3ceadae..14d4f5e9e51cd 100644 --- a/quartz/components/pages/OfflineFallbackPage.tsx +++ b/quartz/components/pages/OfflineFallbackPage.tsx @@ -4,7 +4,7 @@ function OfflineFallbackPage() { return (

          Offline

          -

          You're offline and this page hasn't been cached yet.

          +

          This page isn't offline available yet.

          ) } diff --git a/quartz/plugins/emitters/offline.ts b/quartz/plugins/emitters/offline.ts index b17771a221639..e3c654b691368 100644 --- a/quartz/plugins/emitters/offline.ts +++ b/quartz/plugins/emitters/offline.ts @@ -62,7 +62,7 @@ export const Offline: QuartzEmitterPlugin = () => { const [tree, vfile] = defaultProcessedContent({ slug, text: "Offline", - description: "You're offline and this page hasn't been cached yet.", + description: "This page isn't offline available yet.", frontmatter: { title: "Offline", tags: [] }, }) From 6a9e6352e88aa9ff18e5b33cf2de442a250bd960 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 20 Sep 2023 13:52:45 -0700 Subject: [PATCH 070/205] Revert "feat: Making Quartz available offline by making it a PWA (#465)" This reverts commit d6301fae90d9f922618bf0f413e273156731eef7. --- docs/configuration.md | 2 - docs/features/offline access.md | 31 ------ docs/index.md | 2 +- package-lock.json | 9 +- package.json | 2 +- quartz.config.ts | 1 - quartz/cfg.ts | 1 - quartz/components/Head.tsx | 4 - .../components/pages/OfflineFallbackPage.tsx | 12 --- quartz/plugins/emitters/componentResources.ts | 5 - quartz/plugins/emitters/index.ts | 1 - quartz/plugins/emitters/offline.ts | 97 ------------------- quartz/static/icon.svg | 74 -------------- 13 files changed, 6 insertions(+), 235 deletions(-) delete mode 100644 docs/features/offline access.md delete mode 100644 quartz/components/pages/OfflineFallbackPage.tsx delete mode 100644 quartz/plugins/emitters/offline.ts delete mode 100644 quartz/static/icon.svg diff --git a/docs/configuration.md b/docs/configuration.md index 35e0b9d958e5c..047f6ca6badc5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,12 +21,10 @@ const config: QuartzConfig = { This part of the configuration concerns anything that can affect the whole site. The following is a list breaking down all the things you can configure: - `pageTitle`: title of the site. This is also used when generating the [[RSS Feed]] for your site. -- `description`: description of the site. This will be used when someone installs your site as an App. - `enableSPA`: whether to enable [[SPA Routing]] on your site. - `enablePopovers`: whether to enable [[popover previews]] on your site. - `analytics`: what to use for analytics on your site. Values can be - `null`: don't use analytics; - - `{ provider: "umami", websiteId: }`: easy, privacy-friendly, open source, GDPR Compliant analytics; - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or - `{ provider: 'google', tagId: }`: use Google Analytics - `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes. diff --git a/docs/features/offline access.md b/docs/features/offline access.md deleted file mode 100644 index dcffdcd26b03c..0000000000000 --- a/docs/features/offline access.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: "Offline Access (PWA)" -tags: - - plugin/emitter ---- - -This plugin allows your website to be accessible offline and be installed as an app. You can use it by adding `Plugin.Offline(),` to the `emitters` in `quartz.config.ts` - -## Offline Capability - -Whenever you visit a page it gets cached for offline use. Depending on the kind of content, the process for caching is diffent: - -- **Pages** (HTML, your converted Markdown files): Quartz first tries to get them over the Network. If that fails, your browser attempts to fetch it from the cache. -- **Static Resources** (Fonts, CSS Styling, JavaScript): Quartz uses cached resources by default and updates the cache over the network in the background. -- **Images**: Images are saved once and then served from cache. Quartz uses a limited cache of 60 images and images remain in the cache for 30 days - -You can edit the fallback page by changing the `offline.md` file in the root of your `content` directory - -## Progressive Web App (PWA) - -Progressive Web Apps can have [many properties](https://developer.mozilla.org/en-US/docs/Web/Manifest). We're only going to mention the ones Quartz supports by default, however you can edit the offline plugins file to add more in case required. - -- **icons**: the `icon.svg` file in the `quartz/static` directory is used for all the icons. This makes it easier to scale the image since you don't need to provide an png for every size -- **name**, **short_name**: Uses the `pageTitle` configured in `quartz.config.ts` -- **description**: Uses the `description` configured in `quartz.config.ts` -- **background_color**, **theme_color**: Uses the `lightMode.light` color configured in `quartz.config.ts`. -- **start_url**: Uses the `baseUrl` configured in `quartz.config.ts` - -### Default values - -- **display**: this is set to `minimal-ui` diff --git a/docs/index.md b/docs/index.md index 570d5b364c980..05de2bae97d97 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,7 @@ This will guide you through initializing your Quartz with content. Once you've d ## 🔧 Features -- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[offline access]] and [many more](./features) right out of the box +- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], and [many more](./features) right out of the box - Hot-reload for both configuration and content - Simple JSX layouts and [[creating components|page components]] - [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes diff --git a/package-lock.json b/package-lock.json index 8ff94245da299..a879078977376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,7 @@ "@types/flexsearch": "^0.7.3", "@types/hast": "^2.3.4", "@types/js-yaml": "^4.0.5", - "@types/node": "^20.6.2", + "@types/node": "^20.1.2", "@types/pretty-time": "^1.1.2", "@types/source-map-support": "^0.5.6", "@types/workerpool": "^6.4.0", @@ -113,7 +113,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1464,9 +1463,9 @@ } }, "node_modules/@types/node": { - "version": "20.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.2.tgz", - "integrity": "sha512-Y+/1vGBHV/cYk6OI1Na/LHzwnlNCAfU3ZNGrc1LdRe/LAIbdDPTTv/HU3M7yXN448aTVDq3eKRm2cg7iKLb8gw==", + "version": "20.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", + "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==", "dev": true }, "node_modules/@types/parse5": { diff --git a/package.json b/package.json index e514edfbd2de9..0a2085cef5c1c 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "@types/flexsearch": "^0.7.3", "@types/hast": "^2.3.4", "@types/js-yaml": "^4.0.5", - "@types/node": "^20.6.2", + "@types/node": "^20.1.2", "@types/pretty-time": "^1.1.2", "@types/source-map-support": "^0.5.6", "@types/workerpool": "^6.4.0", diff --git a/quartz.config.ts b/quartz.config.ts index 5a1f643aaf70a..f677a18f9572b 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -4,7 +4,6 @@ import * as Plugin from "./quartz/plugins" const config: QuartzConfig = { configuration: { pageTitle: "🪴 Quartz 4.0", - description: "Quartz Documentation Page and Demo", enableSPA: true, enablePopovers: true, analytics: { diff --git a/quartz/cfg.ts b/quartz/cfg.ts index 73e959fb78e87..8371b5e2be713 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -19,7 +19,6 @@ export type Analytics = export interface GlobalConfiguration { pageTitle: string - description: string /** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */ enableSPA: boolean /** Whether to display Wikipedia-style popovers when hovering over links */ diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 972f7497e626f..2bf26381795ed 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -14,8 +14,6 @@ export default (() => { const iconPath = joinSegments(baseDir, "static/icon.png") const ogImagePath = `https://${cfg.baseUrl}/static/og-image.png` - const manifest = - cfg.baseUrl == undefined ? "/manifest.json" : `https://${cfg.baseUrl}/manifest.json` return ( @@ -27,9 +25,7 @@ export default (() => { {cfg.baseUrl && } - - diff --git a/quartz/components/pages/OfflineFallbackPage.tsx b/quartz/components/pages/OfflineFallbackPage.tsx deleted file mode 100644 index 14d4f5e9e51cd..0000000000000 --- a/quartz/components/pages/OfflineFallbackPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { QuartzComponentConstructor } from "../types" - -function OfflineFallbackPage() { - return ( -
          -

          Offline

          -

          This page isn't offline available yet.

          -
          - ) -} - -export default (() => OfflineFallbackPage) satisfies QuartzComponentConstructor diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index a82e7c12acdc5..1290a35483921 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -116,11 +116,6 @@ function addGlobalPageResources( document.dispatchEvent(event)`) } - componentResources.afterDOMLoaded.push(` - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js'); - }`) - let wsUrl = `ws://localhost:${ctx.argv.wsPort}` if (ctx.argv.remoteDevHost) { diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index 6de824d5f7c01..99a2c54d5c1f4 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -7,4 +7,3 @@ export { Assets } from "./assets" export { Static } from "./static" export { ComponentResources } from "./componentResources" export { NotFoundPage } from "./404" -export { Offline } from "./offline" diff --git a/quartz/plugins/emitters/offline.ts b/quartz/plugins/emitters/offline.ts deleted file mode 100644 index e3c654b691368..0000000000000 --- a/quartz/plugins/emitters/offline.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { QuartzEmitterPlugin } from "../types" -import { FilePath, FullSlug } from "../../util/path" -import { FullPageLayout } from "../../cfg" -import { sharedPageComponents } from "../../../quartz.layout" -import OfflineFallbackPage from "../../components/pages/OfflineFallbackPage" -import BodyConstructor from "../../components/Body" -import { pageResources, renderPage } from "../../components/renderPage" -import { defaultProcessedContent } from "../vfile" -import { QuartzComponentProps } from "../../components/types" - -export const Offline: QuartzEmitterPlugin = () => { - const opts: FullPageLayout = { - ...sharedPageComponents, - pageBody: OfflineFallbackPage(), - beforeBody: [], - left: [], - right: [], - } - - const { head: Head, pageBody, footer: Footer } = opts - const Body = BodyConstructor() - - return { - name: "OfflineSupport", - getQuartzComponents() { - return [Head, Body, pageBody, Footer] - }, - async emit({ cfg }, _content, resources, emit): Promise { - const manifest = { - short_name: cfg.configuration.pageTitle, - name: cfg.configuration.pageTitle, - description: cfg.configuration.description, - background_color: cfg.configuration.theme.colors.lightMode.light, - theme_color: cfg.configuration.theme.colors.lightMode.light, - display: "minimal-ui", - icons: [ - { - src: "static/icon.svg", - sizes: "any", - purpose: "maskable", - }, - { - src: "static/icon.svg", - sizes: "any", - purpose: "any", - }, - ], - start_url: - cfg.configuration.baseUrl == undefined ? "/" : `https://${cfg.configuration.baseUrl}/`, - } - - const serviceWorker = - "importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');" + - "const {pageCache, imageCache, staticResourceCache, googleFontsCache, offlineFallback} = workbox.recipes;" + - "pageCache(); googleFontsCache(); staticResourceCache(); imageCache(); offlineFallback();" - - const slug = "offline" as FullSlug - - const url = new URL(`https://${cfg.configuration.baseUrl ?? "example.com"}`) - const path = url.pathname as FullSlug - const externalResources = pageResources(path, resources) - const [tree, vfile] = defaultProcessedContent({ - slug, - text: "Offline", - description: "This page isn't offline available yet.", - frontmatter: { title: "Offline", tags: [] }, - }) - - const componentData: QuartzComponentProps = { - fileData: vfile.data, - externalResources, - cfg: cfg.configuration, - children: [], - tree, - allFiles: [], - } - - return Promise.all([ - emit({ - content: JSON.stringify(manifest), - slug: "manifest" as FullSlug, - ext: ".json", - }), - emit({ - content: serviceWorker, - slug: "sw" as FullSlug, - ext: ".js", - }), - emit({ - content: renderPage(slug, componentData, opts, externalResources), - slug, - ext: ".html", - }), - ]) - }, - } -} diff --git a/quartz/static/icon.svg b/quartz/static/icon.svg deleted file mode 100644 index c6ecfa2db9733..0000000000000 --- a/quartz/static/icon.svg +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - From b029eeadabe0877df6ec11443c68743f1494bc40 Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Wed, 20 Sep 2023 22:55:29 +0200 Subject: [PATCH 071/205] feat(explorer): improve accessibility and consistency (+ bug fix) (#488) * feat(consistency): use `all: unset` on button * style: improve accessibility and consistency for explorer * fix: localStorage bug with folder name changes * chore: bump quartz version --- package.json | 2 +- quartz/components/Explorer.tsx | 4 ++-- quartz/components/ExplorerNode.tsx | 10 +++++----- quartz/components/scripts/explorer.inline.ts | 8 +++++--- quartz/components/styles/explorer.scss | 13 ++++++++----- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 0a2085cef5c1c..11a68d3ad294c 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.0.11", + "version": "4.1.0", "type": "module", "author": "jackyzha0 ", "license": "MIT", diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index 8597075d2dcbb..bc4855edaea08 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -79,7 +79,7 @@ export default ((userOpts?: Partial) => { data-savestate={opts.useSavedState} data-tree={jsonTree} > -

          {opts.title}

          +

          {opts.title}

          ) => {
            -
            +
    diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index fd0c0823d6e7c..c55a7a0a20f99 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -145,7 +145,7 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro } return ( -
    +
  • {node.file ? ( // Single file node
  • @@ -174,17 +174,17 @@ export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodePro {/* render tag if folderBehavior is "link", otherwise render )} -
  • +
)} {/* Recursively render children of folder */} @@ -210,6 +210,6 @@ 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 2b7df7d354916..9fe18654fd1b0 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -113,9 +113,11 @@ function setupExplorer() { ) as HTMLElement // Get corresponding content