From 4e4930ef9c2e2ddb9bcb1436660d3a3002c19844 Mon Sep 17 00:00:00 2001 From: Anton Bulakh Date: Fri, 24 Jan 2025 05:19:46 +0200 Subject: [PATCH 001/154] chore(styles): omit sass deprecation warnings (#1737) update to newer API --- quartz/styles/base.scss | 35 +++++++++++++++++++---------------- quartz/styles/variables.scss | 10 ++++++---- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 29ddc5aa5..438949108 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -1,3 +1,5 @@ +@use "sass:map"; + @use "./variables.scss" as *; @use "./syntax.scss"; @use "./callouts.scss"; @@ -121,7 +123,7 @@ a { } .page { - max-width: calc(#{map-get($breakpoints, desktop)} + 300px); + max-width: calc(#{map.get($breakpoints, desktop)} + 300px); margin: 0 auto; & article { & > h1 { @@ -151,24 +153,25 @@ a { & > #quartz-body { display: grid; - grid-template-columns: #{map-get($desktopGrid, templateColumns)}; - grid-template-rows: #{map-get($desktopGrid, templateRows)}; - column-gap: #{map-get($desktopGrid, columnGap)}; - row-gap: #{map-get($desktopGrid, rowGap)}; - grid-template-areas: #{map-get($desktopGrid, templateAreas)}; + grid-template-columns: #{map.get($desktopGrid, templateColumns)}; + grid-template-rows: #{map.get($desktopGrid, templateRows)}; + column-gap: #{map.get($desktopGrid, columnGap)}; + row-gap: #{map.get($desktopGrid, rowGap)}; + grid-template-areas: #{map.get($desktopGrid, templateAreas)}; + @media all and ($tablet) { - grid-template-columns: #{map-get($tabletGrid, templateColumns)}; - grid-template-rows: #{map-get($tabletGrid, templateRows)}; - column-gap: #{map-get($tabletGrid, columnGap)}; - row-gap: #{map-get($tabletGrid, rowGap)}; - grid-template-areas: #{map-get($tabletGrid, templateAreas)}; + grid-template-columns: #{map.get($tabletGrid, templateColumns)}; + grid-template-rows: #{map.get($tabletGrid, templateRows)}; + column-gap: #{map.get($tabletGrid, columnGap)}; + row-gap: #{map.get($tabletGrid, rowGap)}; + grid-template-areas: #{map.get($tabletGrid, templateAreas)}; } @media all and ($mobile) { - grid-template-columns: #{map-get($mobileGrid, templateColumns)}; - grid-template-rows: #{map-get($mobileGrid, templateRows)}; - column-gap: #{map-get($mobileGrid, columnGap)}; - row-gap: #{map-get($mobileGrid, rowGap)}; - grid-template-areas: #{map-get($mobileGrid, templateAreas)}; + grid-template-columns: #{map.get($mobileGrid, templateColumns)}; + grid-template-rows: #{map.get($mobileGrid, templateRows)}; + column-gap: #{map.get($mobileGrid, columnGap)}; + row-gap: #{map.get($mobileGrid, rowGap)}; + grid-template-areas: #{map.get($mobileGrid, templateAreas)}; } @media all and not ($desktop) { diff --git a/quartz/styles/variables.scss b/quartz/styles/variables.scss index 4a5cea583..f61adfcdd 100644 --- a/quartz/styles/variables.scss +++ b/quartz/styles/variables.scss @@ -1,3 +1,5 @@ +@use "sass:map"; + /** * Layout breakpoints * $mobile: screen width below this value will use mobile styles @@ -10,11 +12,11 @@ $breakpoints: ( desktop: 1200px, ); -$mobile: "(max-width: #{map-get($breakpoints, mobile)})"; -$tablet: "(min-width: #{map-get($breakpoints, mobile)}) and (max-width: #{map-get($breakpoints, desktop)})"; -$desktop: "(min-width: #{map-get($breakpoints, desktop)})"; +$mobile: "(max-width: #{map.get($breakpoints, mobile)})"; +$tablet: "(min-width: #{map.get($breakpoints, mobile)}) and (max-width: #{map.get($breakpoints, desktop)})"; +$desktop: "(min-width: #{map.get($breakpoints, desktop)})"; -$pageWidth: #{map-get($breakpoints, mobile)}; +$pageWidth: #{map.get($breakpoints, mobile)}; $sidePanelWidth: 320px; //380px; $topSpacing: 6rem; $boldWeight: 700; From 7be47742a6dc86f22d148ca9d304f7a9eea318cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 06:46:45 -0500 Subject: [PATCH 002/154] chore(deps): bump the production-dependencies group across 1 directory with 3 updates (#1744) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 27 +++++++++++++++------------ package.json | 6 +++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30d740c21..d84ab5bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.6.6", + "pixi.js": "^8.7.3", "preact": "^10.25.4", "preact-render-to-string": "^6.5.13", "pretty-bytes": "^6.1.1", @@ -79,10 +79,10 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.10.6", + "@types/node": "^22.12.0", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", - "@types/ws": "^8.5.13", + "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", "esbuild": "^0.24.2", "prettier": "^3.4.2", @@ -1914,10 +1914,11 @@ } }, "node_modules/@types/node": { - "version": "22.10.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.6.tgz", - "integrity": "sha512-qNiuwC4ZDAUNcY47xgaSuS92cjf8JbSUoaKS77bmLG1rU7MlATVSiw/IlrjtIyyskXBZ8KkNfjK/P5na7rgXbQ==", + "version": "22.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", + "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.20.0" } @@ -1943,10 +1944,11 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==" }, "node_modules/@types/ws": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", - "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", + "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -5583,9 +5585,10 @@ } }, "node_modules/pixi.js": { - "version": "8.6.6", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.6.6.tgz", - "integrity": "sha512-o5pw7G2yuIrnBx0G4npBlmFp+XGNcapI/Ufs62rRj/4XKxc1Zo74YJr/BtEXcXTraTKd+pQvYOLvnfxRjxBMvQ==", + "version": "8.7.3", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.7.3.tgz", + "integrity": "sha512-wfWlhJYnGx1s4f2yoouevQjaeacbJ12LTkJGa+n9AIYNIjOnmJylBtZ2mARX7iFk3mr2xv0wuo//XPe2hk5OBw==", + "license": "MIT", "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", diff --git a/package.json b/package.json index 726350b3b..192a8abcc 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", - "pixi.js": "^8.6.6", + "pixi.js": "^8.7.3", "preact": "^10.25.4", "preact-render-to-string": "^6.5.13", "pretty-bytes": "^6.1.1", @@ -102,10 +102,10 @@ "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/js-yaml": "^4.0.9", - "@types/node": "^22.10.6", + "@types/node": "^22.12.0", "@types/pretty-time": "^1.1.5", "@types/source-map-support": "^0.5.10", - "@types/ws": "^8.5.13", + "@types/ws": "^8.5.14", "@types/yargs": "^17.0.33", "esbuild": "^0.24.2", "prettier": "^3.4.2", From fbc45548f7ee80715ec74d8c249c662a26f7feae Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Sat, 1 Feb 2025 16:22:29 -0500 Subject: [PATCH 003/154] feat(graph): enable radial mode (#1738) --- docs/features/graph view.md | 2 ++ quartz/components/Graph.tsx | 5 ++++- quartz/components/scripts/graph.inline.ts | 11 +++++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/features/graph view.md b/docs/features/graph view.md index 4f905c78d..19f086286 100644 --- a/docs/features/graph view.md +++ b/docs/features/graph view.md @@ -36,6 +36,7 @@ Component.Graph({ opacityScale: 1, // how quickly do we fade out the labels when zooming out? removeTags: [], // what tags to remove from the graph showTags: true, // whether to show tags in the graph + enableRadial: false, // whether to constrain the graph, similar to Obsidian }, globalGraph: { drag: true, @@ -49,6 +50,7 @@ Component.Graph({ opacityScale: 1, removeTags: [], // what tags to remove from the graph showTags: true, // whether to show tags in the graph + enableRadial: true, // whether to constrain the graph, similar to Obsidian }, }) ``` diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index ec3475d1e..e8b462da0 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -18,6 +18,7 @@ export interface D3Config { removeTags: string[] showTags: boolean focusOnHover?: boolean + enableRadial?: boolean } interface GraphOptions { @@ -39,6 +40,7 @@ const defaultOptions: GraphOptions = { showTags: true, removeTags: [], focusOnHover: false, + enableRadial: false, }, globalGraph: { drag: true, @@ -53,10 +55,11 @@ const defaultOptions: GraphOptions = { showTags: true, removeTags: [], focusOnHover: true, + enableRadial: true, }, } -export default ((opts?: GraphOptions) => { +export default ((opts?: Partial) => { const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index dbddae91f..16ee33f64 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -8,6 +8,7 @@ import { forceCenter, forceLink, forceCollide, + forceRadial, zoomIdentity, select, drag, @@ -87,6 +88,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { removeTags, showTags, focusOnHover, + enableRadial, } = JSON.parse(graph.dataset["cfg"]!) as D3Config const data: Map = new Map( @@ -161,15 +163,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) { })), } + const width = graph.offsetWidth + const height = Math.max(graph.offsetHeight, 250) + // we virtualize the simulation and use pixi to actually render it + // Calculate the radius of the container circle + const radius = Math.min(width, height) / 2 - 40 // 40px padding const simulation: Simulation = forceSimulation(graphData.nodes) .force("charge", forceManyBody().strength(-100 * repelForce)) .force("center", forceCenter().strength(centerForce)) .force("link", forceLink(graphData.links).distance(linkDistance)) .force("collide", forceCollide((n) => nodeRadius(n)).iterations(3)) - const width = graph.offsetWidth - const height = Math.max(graph.offsetHeight, 250) + if (enableRadial) + simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3)) // precompute style prop strings as pixi doesn't support css variables const cssVars = [ From 91189dfd2f4cb32e205117b327e0ae7a0c2dd716 Mon Sep 17 00:00:00 2001 From: Emile Bangma Date: Mon, 3 Feb 2025 15:25:42 +0100 Subject: [PATCH 004/154] feat(explorer): collapsible mobile explorer (#1471) Co-authored-by: Aaron Pham --- quartz.layout.ts | 4 +- quartz/components/Explorer.tsx | 38 ++++- quartz/components/scripts/explorer.inline.ts | 155 ++++++++++++++----- quartz/components/styles/explorer.scss | 150 +++++++++++++++++- 4 files changed, 296 insertions(+), 51 deletions(-) diff --git a/quartz.layout.ts b/quartz.layout.ts index 4a78256aa..f45da0c92 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), - Component.DesktopOnly(Component.Explorer()), + Component.Explorer(), ], right: [ Component.Graph(), @@ -44,7 +44,7 @@ export const defaultListPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), - Component.DesktopOnly(Component.Explorer()), + Component.Explorer(), ], right: [], } diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ec7c48ef7..ac276a8bc 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -1,5 +1,5 @@ import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import explorerStyle from "./styles/explorer.scss" +import style from "./styles/explorer.scss" // @ts-ignore import script from "./scripts/explorer.inline" @@ -83,18 +83,46 @@ export default ((userOpts?: Partial) => { lastBuildId = ctx.buildId constructFileTree(allFiles) } - return (
+
) } From c97fd7089ad372537114ab469f1f9d6e95e5237a Mon Sep 17 00:00:00 2001 From: Stephen Tse Date: Thu, 6 Mar 2025 09:14:06 +0800 Subject: [PATCH 025/154] 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 026/154] 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 027/154] 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 028/154] 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 029/154] 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 030/154] 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 031/154] 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 032/154] 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 033/154] 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