From c6f10b44f6878e76a416332f14e3681de8df40db Mon Sep 17 00:00:00 2001 From: Emile Bangma Date: Thu, 6 Mar 2025 00:54:11 +0100 Subject: [PATCH 001/133] feat(rss): configurable RSS feed URL (#1806) * feat(rss): configurable RSS feed URL * Update docs/features/RSS Feed.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/features/RSS Feed.md --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jacky Zhao --- docs/features/RSS Feed.md | 5 +++++ docs/plugins/ContentIndex.md | 1 + quartz/plugins/emitters/contentIndex.ts | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/features/RSS Feed.md b/docs/features/RSS Feed.md index ed4138dfc..4b1a1bb3e 100644 --- a/docs/features/RSS Feed.md +++ b/docs/features/RSS Feed.md @@ -1,5 +1,10 @@ Quartz emits an RSS feed for all the content on your site by generating an `index.xml` file that RSS readers can subscribe to. Because of the RSS spec, this requires the `baseUrl` property in your [[configuration]] to be set properly for RSS readers to pick it up properly. +> [!info] +> After deploying, the generated RSS link will be available at `https://${baseUrl}/index.xml` by default. +> +> The `index.xml` path can be customized by passing the `rssSlug` option to the [[ContentIndex]] plugin. + ## Configuration This functionality is provided by the [[ContentIndex]] plugin. See the plugin page for customization options. diff --git a/docs/plugins/ContentIndex.md b/docs/plugins/ContentIndex.md index eb7265d47..037f723bf 100644 --- a/docs/plugins/ContentIndex.md +++ b/docs/plugins/ContentIndex.md @@ -17,6 +17,7 @@ This plugin accepts the following configuration options: - `enableRSS`: If `true` (default), produces an RSS feed (`index.xml`) with recent content updates. - `rssLimit`: Defines the maximum number of entries to include in the RSS feed, helping to focus on the most recent or relevant content. Defaults to `10`. - `rssFullHtml`: If `true`, the RSS feed includes full HTML content. Otherwise it includes just summaries. +- `rssSlug`: Slug to the generated RSS feed XML file. Defaults to `"index"`. - `includeEmptyFiles`: If `true` (default), content files with no body text are included in the generated index and resources. ## API diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index c0fef86d2..f4a1a915b 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -25,6 +25,7 @@ interface Options { enableRSS: boolean rssLimit?: number rssFullHtml: boolean + rssSlug: string includeEmptyFiles: boolean } @@ -33,6 +34,7 @@ const defaultOptions: Options = { enableRSS: true, rssLimit: 10, rssFullHtml: false, + rssSlug: "index", includeEmptyFiles: true, } @@ -151,7 +153,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { await write({ ctx, content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), - slug: "index" as FullSlug, + slug: (opts?.rssSlug ?? "index") as FullSlug, ext: ".xml", }), ) From a1162b978aa1dc1003567485d02c2bc001dd79d4 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 16:27:08 -0800 Subject: [PATCH 002/133] fix(analytics): spa tracking for other providers --- quartz.config.ts | 4 +-- quartz/plugins/emitters/componentResources.ts | 36 ++++++++++++++----- quartz/plugins/emitters/contentIndex.ts | 8 ++--- quartz/plugins/emitters/index.ts | 2 +- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/quartz.config.ts b/quartz.config.ts index dc339d987..6c8921543 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -2,13 +2,13 @@ import { QuartzConfig } from "./quartz/cfg" import * as Plugin from "./quartz/plugins" /** - * Quartz 4.0 Configuration + * Quartz 4 Configuration * * See https://quartz.jzhao.xyz/configuration for more information. */ const config: QuartzConfig = { configuration: { - pageTitle: "🪴 Quartz 4.0", + pageTitle: "🪴 Quartz 4", pageTitleSuffix: "", enableSPA: true, enablePopovers: true, diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 49e281540..b307aad41 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -116,35 +116,53 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso const umamiScript = document.createElement("script") umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js" umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") + umamiScript.setAttribute("data-auto-track", "false") umamiScript.async = true - document.head.appendChild(umamiScript) + + document.addEventListener("nav", () => { + umami.track(); + }) `) } else if (cfg.analytics?.provider === "goatcounter") { componentResources.afterDOMLoaded.push(` + const goatcounterScript = document.createElement("script") + goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}" + goatcounterScript.async = true + goatcounterScript.setAttribute("data-goatcounter", + "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count") + document.head.appendChild(goatcounterScript) + + window.goatcounter = { no_onload: true } document.addEventListener("nav", () => { - const goatcounterScript = document.createElement("script") - goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}" - goatcounterScript.async = true - goatcounterScript.setAttribute("data-goatcounter", - "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count") - document.head.appendChild(goatcounterScript) + goatcounter.count({ path: location.pathname }) }) `) } else if (cfg.analytics?.provider === "posthog") { componentResources.afterDOMLoaded.push(` const posthogScript = document.createElement("script") posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n { + posthog.capture('$pageview', { path: location.pathname }) + }) `) } else if (cfg.analytics?.provider === "tinylytics") { const siteId = cfg.analytics.siteId componentResources.afterDOMLoaded.push(` const tinylyticsScript = document.createElement("script") - tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js" + tinylyticsScript.src = "https://tinylytics.app/embed/${siteId}.js?spa" tinylyticsScript.defer = true document.head.appendChild(tinylyticsScript) + + document.addEventListener("nav", () => { + window.tinylytics.triggerUpdate() + }) `) } else if (cfg.analytics?.provider === "cabin") { componentResources.afterDOMLoaded.push(` diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index f4a1a915b..5d76e087b 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -9,7 +9,7 @@ import { write } from "./helpers" import { i18n } from "../../i18n" import DepGraph from "../../depgraph" -export type ContentIndex = Map +export type ContentIndexMap = Map export type ContentDetails = { title: string links: SimpleSlug[] @@ -38,7 +38,7 @@ const defaultOptions: Options = { includeEmptyFiles: true, } -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { +function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` https://${joinSegments(base, encodeURI(slug))} @@ -50,7 +50,7 @@ function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndex): string { return `${urls}` } -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndex, limit?: number): string { +function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string { const base = cfg.baseUrl ?? "" const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` @@ -118,7 +118,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { async emit(ctx, content, _resources) { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] - const linkIndex: ContentIndex = new Map() + const linkIndex: ContentIndexMap = new Map() for (const [tree, file] of content) { const slug = file.data.slug! const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index bc378c47b..60f47fe01 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -1,7 +1,7 @@ export { ContentPage } from "./contentPage" export { TagPage } from "./tagPage" export { FolderPage } from "./folderPage" -export { ContentIndex } from "./contentIndex" +export { ContentIndex as ContentIndex } from "./contentIndex" export { AliasRedirects } from "./aliases" export { Assets } from "./assets" export { Static } from "./static" From a3b62013650f09afd11c4e58675f495bbc085569 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:45:02 -0800 Subject: [PATCH 003/133] chore(deps): bump the production-dependencies group with 6 updates (#1804) * chore(deps): bump the production-dependencies group with 6 updates Bumps the production-dependencies group with 6 updates: | Package | From | To | | --- | --- | --- | | [hast-util-to-jsx-runtime](https://github.com/syntax-tree/hast-util-to-jsx-runtime) | `2.3.4` | `2.3.5` | | [pixi.js](https://github.com/pixijs/pixijs) | `8.8.0` | `8.8.1` | | [preact](https://github.com/preactjs/preact) | `10.26.2` | `10.26.4` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.13.5` | `22.13.9` | | [prettier](https://github.com/prettier/prettier) | `3.5.2` | `3.5.3` | | [typescript](https://github.com/microsoft/TypeScript) | `5.7.3` | `5.8.2` | Updates `hast-util-to-jsx-runtime` from 2.3.4 to 2.3.5 - [Release notes](https://github.com/syntax-tree/hast-util-to-jsx-runtime/releases) - [Commits](https://github.com/syntax-tree/hast-util-to-jsx-runtime/compare/2.3.4...2.3.5) Updates `pixi.js` from 8.8.0 to 8.8.1 - [Release notes](https://github.com/pixijs/pixijs/releases) - [Commits](https://github.com/pixijs/pixijs/compare/v8.8.0...v8.8.1) Updates `preact` from 10.26.2 to 10.26.4 - [Release notes](https://github.com/preactjs/preact/releases) - [Commits](https://github.com/preactjs/preact/compare/10.26.2...10.26.4) Updates `@types/node` from 22.13.5 to 22.13.9 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `prettier` from 3.5.2 to 3.5.3 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.5.2...3.5.3) Updates `typescript` from 5.7.3 to 5.8.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v5.8.2) --- updated-dependencies: - dependency-name: hast-util-to-jsx-runtime dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-dependencies - dependency-name: pixi.js dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-dependencies - dependency-name: preact dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-dependencies - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: production-dependencies - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-patch dependency-group: production-dependencies - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor dependency-group: production-dependencies ... Signed-off-by: dependabot[bot] * type fixes * fix more types --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jacky Zhao --- docs/advanced/making plugins.md | 2 +- package-lock.json | 65 ++++++++++++++-------- package.json | 12 ++-- quartz/plugins/transformers/frontmatter.ts | 3 +- quartz/plugins/transformers/ofm.ts | 20 ++----- quartz/plugins/types.ts | 2 +- 6 files changed, 55 insertions(+), 49 deletions(-) diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md index 015e1953a..3042737a2 100644 --- a/docs/advanced/making plugins.md +++ b/docs/advanced/making plugins.md @@ -37,7 +37,7 @@ Transformers **map** over content, taking a Markdown file and outputting modifie ```ts export type QuartzTransformerPluginInstance = { name: string - textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer + textTransform?: (ctx: BuildCtx, src: string) => string markdownPlugins?: (ctx: BuildCtx) => PluggableList htmlPlugins?: (ctx: BuildCtx) => PluggableList externalResources?: (ctx: BuildCtx) => Partial diff --git a/package-lock.json b/package-lock.json index 7f8944cba..1ca894a08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "globby": "^14.1.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.5", - "hast-util-to-jsx-runtime": "^2.3.4", + "hast-util-to-jsx-runtime": "^2.3.5", "hast-util-to-string": "^3.0.1", "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", @@ -34,8 +34,8 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.8.0", - "preact": "^10.26.2", + "pixi.js": "^8.8.1", + "preact": "^10.26.4", "preact-render-to-string": "^6.5.13", "pretty-bytes": "^6.1.1", "pretty-time": "^1.1.0", @@ -79,15 +79,15 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.13.5", + "@types/node": "^22.13.9", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", "esbuild": "^0.25.0", - "prettier": "^3.5.2", + "prettier": "^3.5.3", "tsx": "^4.19.3", - "typescript": "^5.7.3" + "typescript": "^5.8.2" }, "engines": { "node": "20 || >=22", @@ -1916,9 +1916,9 @@ } }, "node_modules/@types/node": { - "version": "22.13.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", - "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", "dev": true, "license": "MIT", "dependencies": { @@ -3224,6 +3224,15 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -3541,9 +3550,9 @@ } }, "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.4.tgz", - "integrity": "sha512-2GSifZSlBD35z6/+sp+btB333wHFPck/rrlKZMc9IOUJk6anHuQuqC/oNI80Pj717wo8JCPdXjjasVqQu3UH8Q==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.5.tgz", + "integrity": "sha512-gHD+HoFxOMmmXLuq9f2dZDMQHVcplCVpMfBNRpJsF03yyLZvJGzsFORe8orVuYDX9k2w0VH0uF8oryFd1whqKQ==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -3928,6 +3937,12 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -5468,9 +5483,9 @@ } }, "node_modules/pixi.js": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.8.0.tgz", - "integrity": "sha512-0xW8tKa+uF28mi1SwvnNscMpYJSQrqLN7jJs6Ore37FZoXmIRzQNrGA6drpHDVTuEmoqJlSiGLCk5cUgz3ODgQ==", + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.8.1.tgz", + "integrity": "sha512-Zmox3Vy52Kl6X/uxknKlxJWPVEFiP63nsX8soqB4butTkIOK3y7c9C204wcDfAgkwO1OlwYxscWtHv+ef4gqgA==", "license": "MIT", "dependencies": { "@pixi/colord": "^2.9.6", @@ -5480,6 +5495,7 @@ "@xmldom/xmldom": "^0.8.10", "earcut": "^2.2.4", "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", "ismobilejs": "^1.1.1", "parse-svg-path": "^0.1.2" } @@ -5491,9 +5507,9 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.26.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.2.tgz", - "integrity": "sha512-0gNmv4qpS9HaN3+40CLBAnKe0ZfyE4ZWo5xKlC1rVrr0ckkEvJvAQqKaHANdFKsGstoxrY4AItZ7kZSGVoVjgg==", + "version": "10.26.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.4.tgz", + "integrity": "sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w==", "license": "MIT", "funding": { "type": "opencollective", @@ -5509,9 +5525,9 @@ } }, "node_modules/prettier": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", - "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", "bin": { @@ -6968,10 +6984,11 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index acdbcd896..e6c7d4d20 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "globby": "^14.1.0", "gray-matter": "^4.0.3", "hast-util-to-html": "^9.0.5", - "hast-util-to-jsx-runtime": "^2.3.4", + "hast-util-to-jsx-runtime": "^2.3.5", "hast-util-to-string": "^3.0.1", "is-absolute-url": "^4.0.1", "js-yaml": "^4.1.0", @@ -60,8 +60,8 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.8.0", - "preact": "^10.26.2", + "pixi.js": "^8.8.1", + "preact": "^10.26.4", "preact-render-to-string": "^6.5.13", "pretty-bytes": "^6.1.1", "pretty-time": "^1.1.0", @@ -102,14 +102,14 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.13.5", + "@types/node": "^22.13.9", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", "esbuild": "^0.25.0", - "prettier": "^3.5.2", + "prettier": "^3.5.3", "tsx": "^4.19.3", - "typescript": "^5.7.3" + "typescript": "^5.8.2" } } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 625cf607a..9679bd1ec 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -67,7 +67,8 @@ export const FrontMatter: QuartzTransformerPlugin> = (userOpts) [remarkFrontmatter, ["yaml", "toml"]], () => { return (_, file) => { - const { data } = matter(Buffer.from(file.value), { + const fileData = Buffer.from(file.value as Uint8Array) + const { data } = matter(fileData, { ...opts, engines: { yaml: (s) => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object, diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index b0b0a42ef..a39a4db0e 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -156,20 +156,12 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> textTransform(_ctx, src) { // do comments at text level if (opts.comments) { - if (src instanceof Buffer) { - src = src.toString() - } - - src = (src as string).replace(commentRegex, "") + src = src.replace(commentRegex, "") } // pre-transform blockquotes if (opts.callouts) { - if (src instanceof Buffer) { - src = src.toString() - } - - src = (src as string).replace(calloutLineRegex, (value) => { + src = src.replace(calloutLineRegex, (value) => { // force newline after title of callout return value + "\n> " }) @@ -177,12 +169,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> // pre-transform wikilinks (fix anchors to things that may contain illegal syntax e.g. codeblocks, latex) if (opts.wikilinks) { - if (src instanceof Buffer) { - src = src.toString() - } - // replace all wikilinks inside a table first - src = (src as string).replace(tableRegex, (value) => { + src = src.replace(tableRegex, (value) => { // escape all aliases and headers in wikilinks inside a table return value.replace(tableWikilinkRegex, (_value, raw) => { // const [raw]: (string | undefined)[] = capture @@ -196,7 +184,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin> }) // replace all other wikilinks - src = (src as string).replace(wikilinkRegex, (value, ...capture) => { + src = src.replace(wikilinkRegex, (value, ...capture) => { const [rawFp, rawHeader, rawAlias]: (string | undefined)[] = capture const [fp, anchor] = splitAnchor(`${rawFp ?? ""}${rawHeader ?? ""}`) diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index a23f5d6f4..667799f4b 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -18,7 +18,7 @@ export type QuartzTransformerPlugin = ( ) => QuartzTransformerPluginInstance export type QuartzTransformerPluginInstance = { name: string - textTransform?: (ctx: BuildCtx, src: string | Buffer) => string | Buffer + textTransform?: (ctx: BuildCtx, src: string) => string markdownPlugins?: (ctx: BuildCtx) => PluggableList htmlPlugins?: (ctx: BuildCtx) => PluggableList externalResources?: (ctx: BuildCtx) => Partial From f6f417a505a813c098e40d1e1d660e19ad37110b Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 16:30:30 -0800 Subject: [PATCH 004/133] fix: engine reqiurements --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6c7d4d20..92872d792 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "engines": { "npm": ">=9.3.1", - "node": "20 || >=22" + "node": ">=20" }, "keywords": [ "site generator", From 2acfa0fa238f193332db4605a5f1ce666334717b Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 17:13:19 -0800 Subject: [PATCH 005/133] fix(og-image): overflow ellipses in title and description --- quartz.config.ts | 2 +- quartz/components/Head.tsx | 2 +- quartz/util/og.tsx | 51 +++++++++++++++++++++++++++----------- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/quartz.config.ts b/quartz.config.ts index 6c8921543..0cd7e946c 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -19,7 +19,7 @@ const config: QuartzConfig = { baseUrl: "quartz.jzhao.xyz", ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "created", - generateSocialImages: false, + generateSocialImages: true, theme: { fontOrigin: "googleFonts", cdnCaching: true, diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 3a4db10de..983dc50a5 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -98,7 +98,7 @@ export default (() => { if (fileName) { // Generate social image (happens async) - generateSocialImage( + void generateSocialImage( { title, description, diff --git a/quartz/util/og.tsx b/quartz/util/og.tsx index 42b9b27be..4d675cbd4 100644 --- a/quartz/util/og.tsx +++ b/quartz/util/og.tsx @@ -143,12 +143,10 @@ export const defaultImage: SocialImageOptions["imageStructure"] = ( fonts: SatoriOptions["fonts"], _fileData: QuartzPluginData, ) => { - // How many characters are allowed before switching to smaller font const fontBreakPoint = 22 const useSmallerFont = title.length > fontBreakPoint - - // Setup to access image const iconPath = `https://${cfg.baseUrl}/static/icon.png` + return (
-

- {title} -

+

+ {title} +

+
-

- {description} -

+

+ {description} +

+ ) } From c97fd7089ad372537114ab469f1f9d6e95e5237a Mon Sep 17 00:00:00 2001 From: Stephen Tse Date: Thu, 6 Mar 2025 09:14:06 +0800 Subject: [PATCH 006/133] Added emoji support to Satori when generating OG images (#1593) --- quartz/components/Head.tsx | 17 +++++++++- quartz/util/emoji.ts | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 quartz/util/emoji.ts diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 983dc50a5..1aa8cbe00 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -4,6 +4,7 @@ import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/re import { googleFontHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import satori, { SatoriOptions } from "satori" +import { loadEmoji, getIconCode } from "../util/emoji" import fs from "fs" import sharp from "sharp" import { ImageOptions, SocialImageOptions, getSatoriFont, defaultImage } from "../util/og" @@ -24,7 +25,21 @@ async function generateSocialImage( // JSX that will be used to generate satori svg const imageComponent = userOpts.imageStructure(cfg, userOpts, title, description, fonts, fileData) - const svg = await satori(imageComponent, { width, height, fonts }) + const svg = await satori(imageComponent, { + width, + height, + fonts, + // `code` will be the detected language code, `emoji` if it's an Emoji, or `unknown` if not able to tell. + // `segment` will be the content to render. + loadAdditionalAsset: async (code: string, segment: string) => { + if (code === "emoji") { + // if segment is an emoji, load the image. + return `data:image/svg+xml;base64,${btoa(await loadEmoji("twemoji", getIconCode(segment)))}` + } + // if segment is normal text + return code + }, + }) // Convert svg directly to webp (with additional compression) const compressed = await sharp(Buffer.from(svg)).webp({ quality: 40 }).toBuffer() diff --git a/quartz/util/emoji.ts b/quartz/util/emoji.ts new file mode 100644 index 000000000..231294348 --- /dev/null +++ b/quartz/util/emoji.ts @@ -0,0 +1,66 @@ +/** + * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. + * Ported from https://github.com/vercel/satori/blob/48aea6f812365959c2888a25261c72ce17992c6d/playground/utils/twemoji.ts. + */ + +/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ + +const U200D = String.fromCharCode(8205) +const UFE0Fg = /\uFE0F/g + +export function getIconCode(char: string) { + return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char) +} + +function toCodePoint(unicodeSurrogates: string) { + const r = [] + let c = 0, + p = 0, + i = 0 + + while (i < unicodeSurrogates.length) { + c = unicodeSurrogates.charCodeAt(i++) + if (p) { + r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)) + p = 0 + } else if (55296 <= c && c <= 56319) { + p = c + } else { + r.push(c.toString(16)) + } + } + return r.join("-") +} + +export const apis = { + twemoji: (code: string) => + "https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/" + code.toLowerCase() + ".svg", + openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@3.2.0/svg/", + blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@3.2.0/svg/", + noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/", + fluent: (code: string) => + "https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + + code.toLowerCase() + + "_color.svg", + fluentFlat: (code: string) => + "https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + + code.toLowerCase() + + "_flat.svg", +} + +const emojiCache: Record> = {} + +export function loadEmoji(type: keyof typeof apis, code: string) { + const key = type + ":" + code + if (key in emojiCache) return emojiCache[key] + + if (!type || !apis[type]) { + type = "twemoji" + } + + const api = apis[type] + if (typeof api === "function") { + return (emojiCache[key] = fetch(api(code)).then((r) => r.text())) + } + return (emojiCache[key] = fetch(`${api}${code.toUpperCase()}.svg`).then((r) => r.text())) +} From 3c8ccde62431321c4ad35093a780a1585fd424dc Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 17:21:19 -0800 Subject: [PATCH 007/133] chore(og-image): force twemoji for emoji util --- quartz/components/Head.tsx | 13 +++++-------- quartz/util/emoji.ts | 38 +++++--------------------------------- 2 files changed, 10 insertions(+), 41 deletions(-) diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 1aa8cbe00..a1fb0f63c 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -29,15 +29,12 @@ async function generateSocialImage( width, height, fonts, - // `code` will be the detected language code, `emoji` if it's an Emoji, or `unknown` if not able to tell. - // `segment` will be the content to render. - loadAdditionalAsset: async (code: string, segment: string) => { - if (code === "emoji") { - // if segment is an emoji, load the image. - return `data:image/svg+xml;base64,${btoa(await loadEmoji("twemoji", getIconCode(segment)))}` + loadAdditionalAsset: async (languageCode: string, segment: string) => { + if (languageCode === "emoji") { + return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}` } - // if segment is normal text - return code + + return languageCode }, }) diff --git a/quartz/util/emoji.ts b/quartz/util/emoji.ts index 231294348..e38618d1d 100644 --- a/quartz/util/emoji.ts +++ b/quartz/util/emoji.ts @@ -1,10 +1,3 @@ -/** - * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. - * Ported from https://github.com/vercel/satori/blob/48aea6f812365959c2888a25261c72ce17992c6d/playground/utils/twemoji.ts. - */ - -/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ - const U200D = String.fromCharCode(8205) const UFE0Fg = /\uFE0F/g @@ -32,35 +25,14 @@ function toCodePoint(unicodeSurrogates: string) { return r.join("-") } -export const apis = { - twemoji: (code: string) => - "https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/" + code.toLowerCase() + ".svg", - openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@3.2.0/svg/", - blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@3.2.0/svg/", - noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/", - fluent: (code: string) => - "https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + - code.toLowerCase() + - "_color.svg", - fluentFlat: (code: string) => - "https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" + - code.toLowerCase() + - "_flat.svg", -} - +const twemoji = (code: string) => + `https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/svg/${code.toLowerCase()}.svg` const emojiCache: Record> = {} -export function loadEmoji(type: keyof typeof apis, code: string) { +export function loadEmoji(code: string) { + const type = "twemoji" const key = type + ":" + code if (key in emojiCache) return emojiCache[key] - if (!type || !apis[type]) { - type = "twemoji" - } - - const api = apis[type] - if (typeof api === "function") { - return (emojiCache[key] = fetch(api(code)).then((r) => r.text())) - } - return (emojiCache[key] = fetch(`${api}${code.toUpperCase()}.svg`).then((r) => r.text())) + return (emojiCache[key] = fetch(twemoji(code)).then((r) => r.text())) } From 5a39719898fe486994750ec24fa430f332fa67eb Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Wed, 5 Mar 2025 20:33:16 -0500 Subject: [PATCH 008/133] fix(graph): set container as renderGroup to avoid redrawing multiple times (#1736) Signed-off-by: Aaron Pham --- quartz/components/scripts/graph.inline.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 16ee33f64..83424607b 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -370,9 +370,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const stage = app.stage stage.interactive = false - const labelsContainer = new Container({ zIndex: 3 }) - const nodesContainer = new Container({ zIndex: 2 }) - const linkContainer = new Container({ zIndex: 1 }) + const labelsContainer = new Container({ zIndex: 3, isRenderGroup: true }) + const nodesContainer = new Container({ zIndex: 2, isRenderGroup: true }) + const linkContainer = new Container({ zIndex: 1, isRenderGroup: true }) stage.addChild(nodesContainer, labelsContainer, linkContainer) for (const n of graphData.nodes) { From 5b13ff21992a61eb8b03670ae1742a72703c2afe Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 18:16:17 -0800 Subject: [PATCH 009/133] feat: support emitters defining external resources, emit link from contentindex directly --- docs/advanced/making plugins.md | 2 -- quartz/components/Head.tsx | 11 +++++++++-- quartz/components/renderPage.tsx | 1 + .../emitters/{contentIndex.ts => contentIndex.tsx} | 14 ++++++++++++++ quartz/plugins/index.ts | 6 +++++- quartz/plugins/transformers/latex.ts | 2 -- quartz/plugins/types.ts | 4 +++- quartz/util/resources.tsx | 2 ++ 8 files changed, 34 insertions(+), 8 deletions(-) rename quartz/plugins/emitters/{contentIndex.ts => contentIndex.tsx} (94%) diff --git a/docs/advanced/making plugins.md b/docs/advanced/making plugins.md index 3042737a2..8ed533f88 100644 --- a/docs/advanced/making plugins.md +++ b/docs/advanced/making plugins.md @@ -99,8 +99,6 @@ export const Latex: QuartzTransformerPlugin = (opts?: Options) => { }, ], } - } else { - return {} } }, } diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index a1fb0f63c..09156c9ee 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -127,7 +127,7 @@ export default (() => { } } - const { css, js } = externalResources + const { css, js, additionalHead } = externalResources const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) const path = url.pathname as FullSlug @@ -177,7 +177,7 @@ export default (() => { )} - + {/* OG/Twitter meta tags */} @@ -213,6 +213,13 @@ export default (() => { {js .filter((resource) => resource.loadTime === "beforeDOMReady") .map((res) => JSResourceToScriptElement(res, true))} + {additionalHead.map((resource) => { + if (typeof resource === "function") { + return resource(fileData) + } else { + return resource + } + })} ) } diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 3914411ac..9cebaa849 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -54,6 +54,7 @@ export function pageResources( }, ...staticResources.js, ], + additionalHead: staticResources.additionalHead, } if (fileData.hasMermaidDiagram) { diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.tsx similarity index 94% rename from quartz/plugins/emitters/contentIndex.ts rename to quartz/plugins/emitters/contentIndex.tsx index 5d76e087b..bd609b411 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -182,6 +182,20 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { return emitted }, + externalResources: (ctx) => { + if (opts?.enableRSS) { + return { + additionalHead: [ + , + ], + } + } + }, getQuartzComponents: () => [], } } diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index df9fd1d24..c41157c2b 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -6,9 +6,10 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) { const staticResources: StaticResources = { css: [], js: [], + additionalHead: [], } - for (const transformer of ctx.cfg.plugins.transformers) { + for (const transformer of [...ctx.cfg.plugins.transformers, ...ctx.cfg.plugins.emitters]) { const res = transformer.externalResources ? transformer.externalResources(ctx) : {} if (res?.js) { staticResources.js.push(...res.js) @@ -16,6 +17,9 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) { if (res?.css) { staticResources.css.push(...res.css) } + if (res?.additionalHead) { + staticResources.additionalHead.push(...res.additionalHead) + } } // if serving locally, listen for rebuilds and reload the page diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts index 26913bac3..40939d5e9 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/latex.ts @@ -59,8 +59,6 @@ export const Latex: QuartzTransformerPlugin> = (opts) => { }, ], } - default: - return { css: [], js: [] } } }, } diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index 667799f4b..283a9999c 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -13,6 +13,7 @@ export interface PluginTypes { } type OptionType = object | undefined +type ExternalResourcesFn = (ctx: BuildCtx) => Partial | undefined export type QuartzTransformerPlugin = ( opts?: Options, ) => QuartzTransformerPluginInstance @@ -21,7 +22,7 @@ export type QuartzTransformerPluginInstance = { textTransform?: (ctx: BuildCtx, src: string) => string markdownPlugins?: (ctx: BuildCtx) => PluggableList htmlPlugins?: (ctx: BuildCtx) => PluggableList - externalResources?: (ctx: BuildCtx) => Partial + externalResources?: ExternalResourcesFn } export type QuartzFilterPlugin = ( @@ -44,4 +45,5 @@ export type QuartzEmitterPluginInstance = { content: ProcessedContent[], resources: StaticResources, ): Promise> + externalResources?: ExternalResourcesFn } diff --git a/quartz/util/resources.tsx b/quartz/util/resources.tsx index 72ae9e63e..2ec856191 100644 --- a/quartz/util/resources.tsx +++ b/quartz/util/resources.tsx @@ -1,5 +1,6 @@ import { randomUUID } from "crypto" import { JSX } from "preact/jsx-runtime" +import { QuartzPluginData } from "../plugins/vfile" export type JSResource = { loadTime: "beforeDOMReady" | "afterDOMReady" @@ -62,4 +63,5 @@ export function CSSResourceToStyleElement(resource: CSSResource, preserve?: bool export interface StaticResources { css: CSSResource[] js: JSResource[] + additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[] } From 2213424195b6ba761a6bf3343afca43b102d06b3 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Wed, 5 Mar 2025 18:34:02 -0800 Subject: [PATCH 010/133] docs: make role of getQuartzComponents more clear and also make it optional --- quartz/plugins/emitters/aliases.ts | 4 ---- quartz/plugins/emitters/assets.ts | 3 --- quartz/plugins/emitters/cname.ts | 3 --- quartz/plugins/emitters/componentResources.ts | 5 +---- quartz/plugins/emitters/contentIndex.tsx | 1 - quartz/plugins/emitters/static.ts | 3 --- quartz/plugins/types.ts | 7 ++++++- 7 files changed, 7 insertions(+), 19 deletions(-) diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index b5bfff061..9d12a990c 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -6,9 +6,6 @@ import { getAliasSlugs } from "../transformers/frontmatter" export const AliasRedirects: QuartzEmitterPlugin = () => ({ name: "AliasRedirects", - getQuartzComponents() { - return [] - }, async getDependencyGraph(ctx, content, _resources) { const graph = new DepGraph() @@ -22,7 +19,6 @@ export const AliasRedirects: QuartzEmitterPlugin = () => ({ return graph }, async emit(ctx, content, _resources): Promise { - const { argv } = ctx const fps: FilePath[] = [] for (const [_tree, file] of content) { diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index 036b27da4..bb85080c4 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -15,9 +15,6 @@ const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => { export const Assets: QuartzEmitterPlugin = () => { return { name: "Assets", - getQuartzComponents() { - return [] - }, async getDependencyGraph(ctx, _content, _resources) { const { argv, cfg } = ctx const graph = new DepGraph() diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index cbed2a8b4..380212dd4 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -11,9 +11,6 @@ export function extractDomainFromBaseUrl(baseUrl: string) { export const CNAME: QuartzEmitterPlugin = () => ({ name: "CNAME", - getQuartzComponents() { - return [] - }, async getDependencyGraph(_ctx, _content, _resources) { return new DepGraph() }, diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index b307aad41..6c1e3d0b6 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -24,7 +24,7 @@ type ComponentResources = { function getComponentResources(ctx: BuildCtx): ComponentResources { const allComponents: Set = new Set() for (const emitter of ctx.cfg.plugins.emitters) { - const components = emitter.getQuartzComponents(ctx) + const components = emitter.getQuartzComponents?.(ctx) ?? [] for (const component of components) { allComponents.add(component) } @@ -200,9 +200,6 @@ function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentReso export const ComponentResources: QuartzEmitterPlugin = () => { return { name: "ComponentResources", - getQuartzComponents() { - return [] - }, async getDependencyGraph(_ctx, _content, _resources) { return new DepGraph() }, diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index bd609b411..2810039fa 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -196,6 +196,5 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { } } }, - getQuartzComponents: () => [], } } diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index c52c62879..5545d2ccb 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -6,9 +6,6 @@ import DepGraph from "../../depgraph" export const Static: QuartzEmitterPlugin = () => ({ name: "Static", - getQuartzComponents() { - return [] - }, async getDependencyGraph({ argv, cfg }, _content, _resources) { const graph = new DepGraph() diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index 283a9999c..e7cfb479f 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -39,7 +39,12 @@ export type QuartzEmitterPlugin = ( export type QuartzEmitterPluginInstance = { name: string emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise - getQuartzComponents(ctx: BuildCtx): QuartzComponent[] + /** + * Returns the components (if any) that are used in rendering the page. + * This helps Quartz optimize the page by only including necessary resources + * for components that are actually used. + */ + getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[] getDependencyGraph?( ctx: BuildCtx, content: ProcessedContent[], From 6d195fd40a48fe275dc910f7a115e5b2f3c1c056 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 6 Mar 2025 09:21:50 -0800 Subject: [PATCH 011/133] feat: font specification flexibility --- quartz/components/Head.tsx | 6 ++-- quartz/util/theme.ts | 61 +++++++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 09156c9ee..b6a7e8d07 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -1,7 +1,7 @@ import { i18n } from "../i18n" import { FullSlug, joinSegments, pathToRoot } from "../util/path" import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/resources" -import { googleFontHref } from "../util/theme" +import { getFontSpecificationName, googleFontHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import satori, { SatoriOptions } from "satori" import { loadEmoji, getIconCode } from "../util/emoji" @@ -77,7 +77,9 @@ export default (() => { // Memoize google fonts if (!fontsPromise && cfg.generateSocialImages) { - fontsPromise = getSatoriFont(cfg.theme.typography.header, cfg.theme.typography.body) + const headerFont = getFontSpecificationName(cfg.theme.typography.header) + const bodyFont = getFontSpecificationName(cfg.theme.typography.body) + fontsPromise = getSatoriFont(headerFont, bodyFont) } const slug = fileData.filePath diff --git a/quartz/util/theme.ts b/quartz/util/theme.ts index 0c903066f..06ddd8c43 100644 --- a/quartz/util/theme.ts +++ b/quartz/util/theme.ts @@ -15,11 +15,19 @@ interface Colors { darkMode: ColorScheme } +type FontSpecification = + | string + | { + name: string + weights?: number[] + includeItalic?: boolean + } + export interface Theme { typography: { - header: string - body: string - code: string + header: FontSpecification + body: FontSpecification + code: FontSpecification } cdnCaching: boolean colors: Colors @@ -32,9 +40,54 @@ const DEFAULT_SANS_SERIF = 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"' const DEFAULT_MONO = "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace" +export function getFontSpecificationName(spec: FontSpecification): string { + if (typeof spec === "string") { + return spec + } + + return spec.name +} + +function formatFontSpecification(type: "header" | "body" | "code", spec: FontSpecification) { + if (typeof spec === "string") { + spec = { name: spec } + } + + const defaultIncludeWeights = type === "header" ? [400, 700] : [400, 600] + const defaultIncludeItalic = type === "body" + const weights = spec.weights ?? defaultIncludeWeights + const italic = spec.includeItalic ?? defaultIncludeItalic + + const features: string[] = [] + if (italic) { + features.push("ital") + } + + if (weights.length > 1) { + const weightSpec = italic + ? weights + .flatMap((w) => [`0,${w}`, `1,${w}`]) + .sort() + .join(";") + : weights.join(";") + + features.push(`wght@${weightSpec}`) + } + + if (features.length > 0) { + return `${spec.name}:${features.join(",")}` + } + + return spec.name +} + export function googleFontHref(theme: Theme) { const { code, header, body } = theme.typography - return `https://fonts.googleapis.com/css2?family=${code}&family=${header}:wght@400;700&family=${body}:ital,wght@0,400;0,600;1,400;1,600&display=swap` + const headerFont = formatFontSpecification("header", header) + const bodyFont = formatFontSpecification("body", body) + const codeFont = formatFontSpecification("code", code) + + return `https://fonts.googleapis.com/css2?family=${bodyFont}&family=${headerFont}&family=${codeFont}&display=swap` } export function joinStyles(theme: Theme, ...stylesheet: string[]) { From cc9704becc78d9ab15abda165799b3c773f2ca2b Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 6 Mar 2025 09:41:26 -0800 Subject: [PATCH 012/133] chore(deps): bump deps, silence internal punycode deprecation --- package-lock.json | 15 ++++++++------- quartz/bootstrap-cli.mjs | 2 +- quartz/plugins/emitters/contentPage.tsx | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ca894a08..66d4898a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "typescript": "^5.8.2" }, "engines": { - "node": "20 || >=22", + "node": ">=20", "npm": ">=9.3.1" } }, @@ -2385,9 +2385,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3955,9 +3956,9 @@ } }, "node_modules/katex": { - "version": "0.16.11", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz", - "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==", + "version": "0.16.21", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", + "integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 35d06af77..69b5aa157 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env node --no-deprecation import yargs from "yargs" import { hideBin } from "yargs/helpers" import { diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index 8788f331d..f59ff6bf5 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -131,7 +131,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp if (!containsIndex && !ctx.argv.fastRebuild) { console.log( chalk.yellow( - `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder. This may cause errors when deploying.`, + `\nWarning: you seem to be missing an \`index.md\` home page file at the root of your \`${ctx.argv.directory}\` folder (\`${path.join(ctx.argv.directory, "index.md")} does not exist\`). This may cause errors when deploying.`, ), ) } From a201105442c3603a34cb609b70cef71072e71392 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 6 Mar 2025 10:01:25 -0800 Subject: [PATCH 013/133] fix(docker): instructions + bump deps + bind mount (#1809) * fix docker * test with docs folder --- .github/workflows/ci.yaml | 2 +- Dockerfile | 4 ++-- docs/features/Docker Support.md | 2 +- quartz/bootstrap-cli.mjs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0fc1fd18..2387e7a5e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,7 +45,7 @@ jobs: run: npm test - name: Ensure Quartz builds, check bundle info - run: npx quartz build --bundleInfo + run: npx quartz build --bundleInfo -d docs publish-tag: if: ${{ github.repository == 'jackyzha0/quartz' && github.ref == 'refs/heads/v4' }} diff --git a/Dockerfile b/Dockerfile index 4493853e2..f8a6f2684 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ -FROM node:20-slim AS builder +FROM node:22-slim AS builder WORKDIR /usr/src/app COPY package.json . COPY package-lock.json* . RUN npm ci -FROM node:20-slim +FROM node:22-slim WORKDIR /usr/src/app COPY --from=builder /usr/src/app/ /usr/src/app/ COPY . . diff --git a/docs/features/Docker Support.md b/docs/features/Docker Support.md index cf73b7fcc..a31fb5b45 100644 --- a/docs/features/Docker Support.md +++ b/docs/features/Docker Support.md @@ -3,5 +3,5 @@ Quartz comes shipped with a Docker image that will allow you to preview your Qua You can run the below one-liner to run Quartz in Docker. ```sh -docker run --rm -itp 8080:8080 $(docker build -q .) +docker run --rm -itp 8080:8080 -p 3001:3001 -v ./content:/usr/src/app/content $(docker build -q .) ``` diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 69b5aa157..8b0b9268f 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -1,4 +1,4 @@ -#!/usr/bin/env node --no-deprecation +#!/usr/bin/env -S node --no-deprecation import yargs from "yargs" import { hideBin } from "yargs/helpers" import { From 5480269d38ffaff7ffd6576d9a9407430429fb2d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sun, 9 Mar 2025 14:58:26 -0700 Subject: [PATCH 014/133] perf(explorer): client side explorer (#1810) * start work on client side explorer * fix tests * fmt * generic test flag * add prenav hook * add highlight class * make flex more consistent, remove transition * open folders that are prefixes of current path * make mobile look nice * more style fixes --- docs/advanced/creating components.md | 12 + index.d.ts | 1 + package.json | 2 +- quartz.config.ts | 2 +- quartz/components/Backlinks.tsx | 6 +- quartz/components/Explorer.tsx | 152 +++---- quartz/components/ExplorerNode.tsx | 242 ----------- quartz/components/OverflowList.tsx | 39 ++ quartz/components/TableOfContents.tsx | 7 +- quartz/components/renderPage.tsx | 3 +- quartz/components/scripts/explorer.inline.ts | 407 +++++++++++-------- quartz/components/scripts/spa.inline.ts | 6 +- quartz/components/scripts/toc.inline.ts | 2 - quartz/components/scripts/util.ts | 1 + quartz/components/styles/backlinks.scss | 22 - quartz/components/styles/darkmode.scss | 1 + quartz/components/styles/explorer.scss | 186 ++++----- quartz/components/styles/toc.scss | 29 +- quartz/plugins/emitters/contentIndex.tsx | 2 + quartz/styles/base.scss | 23 +- quartz/util/clone.ts | 3 + quartz/util/fileTrie.test.ts | 190 +++++++++ quartz/util/fileTrie.ts | 128 ++++++ quartz/util/path.ts | 5 +- 24 files changed, 797 insertions(+), 674 deletions(-) delete mode 100644 quartz/components/ExplorerNode.tsx create mode 100644 quartz/components/OverflowList.tsx create mode 100644 quartz/util/clone.ts create mode 100644 quartz/util/fileTrie.test.ts create mode 100644 quartz/util/fileTrie.ts diff --git a/docs/advanced/creating components.md b/docs/advanced/creating components.md index 628d5aa29..369405b07 100644 --- a/docs/advanced/creating components.md +++ b/docs/advanced/creating components.md @@ -161,6 +161,18 @@ document.addEventListener("nav", () => { }) ``` +You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event. + +```ts +document.addEventListener("prenav", () => { + // executed after an SPA navigation is triggered but + // before the page is replaced + // one usage pattern is to store things in sessionStorage + // in the prenav and then conditionally load then in the consequent + // nav +}) +``` + It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. This will get called on page navigation. diff --git a/index.d.ts b/index.d.ts index a6c594fff..8e524af03 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,6 +5,7 @@ declare module "*.scss" { // dom custom event interface CustomEventMap { + prenav: CustomEvent<{}> nav: CustomEvent<{ url: FullSlug }> themechange: CustomEvent<{ theme: "light" | "dark" }> } diff --git a/package.json b/package.json index 92872d792..81e5dbf10 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs": "npx quartz build --serve -d docs", "check": "tsc --noEmit && npx prettier . --check", "format": "npx prettier . --write", - "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", + "test": "tsx --test", "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" }, "engines": { diff --git a/quartz.config.ts b/quartz.config.ts index 0cd7e946c..51a75515d 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -8,7 +8,7 @@ import * as Plugin from "./quartz/plugins" */ const config: QuartzConfig = { configuration: { - pageTitle: "🪴 Quartz 4", + pageTitle: "Quartz 4", pageTitleSuffix: "", enableSPA: true, enablePopovers: true, diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index e99055e31..735afe727 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss" import { resolveRelative, simplifySlug } from "../util/path" import { i18n } from "../i18n" import { classNames } from "../util/lang" +import OverflowList from "./OverflowList" interface BacklinksOptions { hideWhenEmpty: boolean @@ -29,7 +30,7 @@ export default ((opts?: Partial) => { return (

{i18n(cfg.locale).components.backlinks.title}

-
    + {backlinkFiles.length > 0 ? ( backlinkFiles.map((f) => (
  • @@ -41,12 +42,13 @@ export default ((opts?: Partial) => { ) : (
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • )} -
+
) } Backlinks.css = style + Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul") return Backlinks }) satisfies QuartzComponentConstructor diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ac276a8bc..9c1fbdcfe 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -3,22 +3,34 @@ import style from "./styles/explorer.scss" // @ts-ignore import script from "./scripts/explorer.inline" -import { ExplorerNode, FileNode, Options } from "./ExplorerNode" -import { QuartzPluginData } from "../plugins/vfile" import { classNames } from "../util/lang" import { i18n } from "../i18n" +import { FileTrieNode } from "../util/fileTrie" +import OverflowList from "./OverflowList" -// Options interface defined in `ExplorerNode` to avoid circular dependency -const defaultOptions = { - folderClickBehavior: "collapse", +type OrderEntries = "sort" | "filter" | "map" + +export interface Options { + title?: string + folderDefaultState: "collapsed" | "open" + folderClickBehavior: "collapse" | "link" + useSavedState: boolean + sortFn: (a: FileTrieNode, b: FileTrieNode) => number + filterFn: (node: FileTrieNode) => boolean + mapFn: (node: FileTrieNode) => void + order: OrderEntries[] +} + +const defaultOptions: Options = { folderDefaultState: "collapsed", + folderClickBehavior: "collapse", useSavedState: true, mapFn: (node) => { return node }, sortFn: (a, b) => { - // Sort order: folders first, then files. Sort folders and files alphabetically - if ((!a.file && !b.file) || (a.file && b.file)) { + // Sort order: folders first, then files. Sort folders and files alphabeticall + if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A return a.displayName.localeCompare(b.displayName, undefined, { @@ -27,75 +39,44 @@ const defaultOptions = { }) } - if (a.file && !b.file) { + if (!a.isFolder && b.isFolder) { return 1 } else { return -1 } }, - filterFn: (node) => node.name !== "tags", + filterFn: (node) => node.slugSegment !== "tags", order: ["filter", "map", "sort"], -} satisfies Options +} + +export type FolderState = { + path: string + collapsed: boolean +} export default ((userOpts?: Partial) => { - // Parse config const opts: Options = { ...defaultOptions, ...userOpts } - // memoized - let fileTree: FileNode - let jsonTree: string - let lastBuildId: string = "" - - function constructFileTree(allFiles: QuartzPluginData[]) { - // Construct tree from allFiles - fileTree = new FileNode("") - allFiles.forEach((file) => fileTree.add(file)) - - // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) - if (opts.order) { - // Order is important, use loop with index instead of order.map() - for (let i = 0; i < opts.order.length; i++) { - const functionName = opts.order[i] - if (functionName === "map") { - fileTree.map(opts.mapFn) - } else if (functionName === "sort") { - fileTree.sort(opts.sortFn) - } else if (functionName === "filter") { - fileTree.filter(opts.filterFn) - } - } - } - - // Get all folders of tree. Initialize with collapsed state - // Stringify to pass json tree as data attribute ([data-tree]) - const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") - jsonTree = JSON.stringify(folders) - } - - const Explorer: QuartzComponent = ({ - ctx, - cfg, - allFiles, - displayClass, - fileData, - }: QuartzComponentProps) => { - if (ctx.buildId !== lastBuildId) { - lastBuildId = ctx.buildId - constructFileTree(allFiles) - } + const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => { return ( -
+
-
-
    - -
  • -
+
+
+ +
) } Explorer.css = style - Explorer.afterDOMLoaded = script + Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul") return Explorer }) satisfies QuartzComponentConstructor diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx deleted file mode 100644 index e57d67715..000000000 --- a/quartz/components/ExplorerNode.tsx +++ /dev/null @@ -1,242 +0,0 @@ -// @ts-ignore -import { QuartzPluginData } from "../plugins/vfile" -import { - joinSegments, - resolveRelative, - clone, - simplifySlug, - SimpleSlug, - FilePath, -} from "../util/path" - -type OrderEntries = "sort" | "filter" | "map" - -export interface Options { - title?: string - folderDefaultState: "collapsed" | "open" - folderClickBehavior: "collapse" | "link" - useSavedState: boolean - sortFn: (a: FileNode, b: FileNode) => number - filterFn: (node: FileNode) => boolean - mapFn: (node: FileNode) => void - order: OrderEntries[] -} - -type DataWrapper = { - file: QuartzPluginData - path: string[] -} - -export type FolderState = { - path: string - collapsed: boolean -} - -function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined { - if (!fp) { - return undefined - } - - return fp.split("/").at(idx) -} - -// Structure to add all files into a tree -export class FileNode { - children: Array - name: string // this is the slug segment - displayName: string - file: QuartzPluginData | null - depth: number - - constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { - this.children = [] - this.name = slugSegment - this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment - this.file = file ? clone(file) : null - this.depth = depth ?? 0 - } - - private insert(fileData: DataWrapper) { - if (fileData.path.length === 0) { - return - } - - const nextSegment = fileData.path[0] - - // base case, insert here - if (fileData.path.length === 1) { - if (nextSegment === "") { - // index case (we are the root and we just found index.md), set our data appropriately - const title = fileData.file.frontmatter?.title - if (title && title !== "index") { - this.displayName = title - } - } else { - // direct child - this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1)) - } - - return - } - - // find the right child to insert into - fileData.path = fileData.path.splice(1) - const child = this.children.find((c) => c.name === nextSegment) - if (child) { - child.insert(fileData) - return - } - - const newChild = new FileNode( - nextSegment, - getPathSegment(fileData.file.relativePath, this.depth), - undefined, - this.depth + 1, - ) - newChild.insert(fileData) - this.children.push(newChild) - } - - // Add new file to tree - add(file: QuartzPluginData) { - this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) - } - - /** - * 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) { - this.children = this.children.filter(filterFn) - this.children.forEach((child) => child.filter(filterFn)) - } - - /** - * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place - * @param mapFn function to use for mapping over tree - */ - 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 - * @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 = joinSegments(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 - /** - * 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)) - } -} - -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 - const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" - const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" - - return ( - <> - {node.file ? ( - // Single file node -
  • - - {node.displayName} - -
  • - ) : ( -
  • - {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/OverflowList.tsx b/quartz/components/OverflowList.tsx new file mode 100644 index 000000000..d74c5c255 --- /dev/null +++ b/quartz/components/OverflowList.tsx @@ -0,0 +1,39 @@ +import { JSX } from "preact" + +const OverflowList = ({ + children, + ...props +}: JSX.HTMLAttributes & { id: string }) => { + return ( +
      + {children} +
    • +
    + ) +} + +OverflowList.afterDOMLoaded = (id: string) => ` +document.addEventListener("nav", (e) => { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const parentUl = entry.target.parentElement + if (entry.isIntersecting) { + parentUl.classList.remove("gradient-active") + } else { + parentUl.classList.add("gradient-active") + } + } + }) + + const ul = document.getElementById("${id}") + if (!ul) return + + const end = ul.querySelector(".overflow-end") + if (!end) return + + observer.observe(end) + window.addCleanup(() => observer.disconnect()) +}) +` + +export default OverflowList diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index ec457cfe5..485f434a8 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -6,6 +6,7 @@ import { classNames } from "../util/lang" // @ts-ignore import script from "./scripts/toc.inline" import { i18n } from "../i18n" +import OverflowList from "./OverflowList" interface Options { layout: "modern" | "legacy" @@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
    - +
    ) } TableOfContents.css = modernStyle -TableOfContents.afterDOMLoaded = script +TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul") const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { if (!fileData.toc) { diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 9cebaa849..75ef82b24 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" +import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" +import { clone } from "../util/clone" import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" import { GlobalConfiguration } from "../cfg" diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 9c6c0508f..15f3a84dd 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,53 +1,38 @@ -import { FolderState } from "../ExplorerNode" +import { FileTrieNode } from "../../util/fileTrie" +import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" +import { ContentDetails } from "../../plugins/emitters/contentIndex" -// Current state of folders type MaybeHTMLElement = HTMLElement | undefined -let currentExplorerState: FolderState[] -const observer = new IntersectionObserver((entries) => { - // If last element is observed, remove gradient of "overflow" class so element is visible - const explorerUl = document.getElementById("explorer-ul") - if (!explorerUl) return - for (const entry of entries) { - if (entry.isIntersecting) { - explorerUl.classList.add("no-background") - } else { - explorerUl.classList.remove("no-background") - } - } -}) +interface ParsedOptions { + folderClickBehavior: "collapse" | "link" + folderDefaultState: "collapsed" | "open" + useSavedState: boolean + sortFn: (a: FileTrieNode, b: FileTrieNode) => number + filterFn: (node: FileTrieNode) => boolean + mapFn: (node: FileTrieNode) => void + order: "sort" | "filter" | "map"[] +} +type FolderState = { + path: string + collapsed: boolean +} + +let currentExplorerState: Array function toggleExplorer(this: HTMLElement) { - // Toggle collapsed state of entire explorer - this.classList.toggle("collapsed") - - // Toggle collapsed aria state of entire explorer - this.setAttribute( - "aria-expanded", - this.getAttribute("aria-expanded") === "true" ? "false" : "true", - ) - - const content = ( - this.nextElementSibling?.nextElementSibling - ? this.nextElementSibling.nextElementSibling - : this.nextElementSibling - ) as MaybeHTMLElement - if (!content) return - content.classList.toggle("collapsed") - content.classList.toggle("explorer-viewmode") - - // Prevent scroll under - if (document.querySelector("#mobile-explorer")) { - // Disable scrolling on the page when the explorer is opened on mobile - const bodySelector = document.querySelector("#quartz-body") - if (bodySelector) bodySelector.classList.toggle("lock-scroll") + const explorers = document.querySelectorAll(".explorer") + for (const explorer of explorers) { + explorer.classList.toggle("collapsed") + explorer.setAttribute( + "aria-expanded", + explorer.getAttribute("aria-expanded") === "true" ? "false" : "true", + ) } } function toggleFolder(evt: MouseEvent) { evt.stopPropagation() - - // Element that was clicked const target = evt.target as MaybeHTMLElement if (!target) return @@ -55,162 +40,240 @@ function toggleFolder(evt: MouseEvent) { const isSvg = target.nodeName === "svg" // corresponding