From 2b9659a1c228c96070f57dc84b89e5e14245fc16 Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Mon, 5 Feb 2024 14:19:21 -0800 Subject: [PATCH 01/14] fix(i18n): add default locale --- quartz/i18n/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 69a18c9..9e14ffe 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -13,5 +13,5 @@ export const TRANSLATIONS = { "nl-NL": nl, } as const -export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale] +export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? "en-US"] export type ValidLocale = keyof typeof TRANSLATIONS From bec726b666ca0051bb14eb62926c57fe2d98a70e Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Mon, 5 Feb 2024 16:40:39 -0800 Subject: [PATCH 02/14] fix(i18n): forgot a string --- quartz/components/renderPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index fa8305d..1ed05a7 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -104,7 +104,9 @@ export function renderPage( type: "element", tagName: "a", properties: { href: inner.properties?.href, class: ["internal"] }, - children: [{ type: "text", value: `Link to original` }], + children: [ + { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal }, + ], }, ] } From 34334eabed151cf5fa881d6b34f75107a2d3871e Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Mon, 5 Feb 2024 20:36:31 -0800 Subject: [PATCH 03/14] perf: don't load mermaid if its not on the page --- quartz/plugins/transformers/ofm.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 908c23d..f8a28c4 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -600,17 +600,22 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin if (opts.mermaid) { js.push({ script: ` - import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; - const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'loose', - theme: darkMode ? 'dark' : 'default' - }); + let mermaidImport = undefined document.addEventListener('nav', async () => { - await mermaid.run({ - querySelector: '.mermaid' - }) + if (document.querySelector("code.mermaid")) { + mermaidImport ||= await import('https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs') + const mermaid = mermaidImport.default + const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark' + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + theme: darkMode ? 'dark' : 'default' + }) + + await mermaid.run({ + querySelector: '.mermaid' + }) + } }); `, loadTime: "afterDOMReady", From 52ef6d1b6f2c4be99426929fc98b17dc7e7e96fc Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Tue, 6 Feb 2024 01:12:31 -0500 Subject: [PATCH 04/14] fix(search): set background-color for icon within preview panel (#815) Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> --- quartz/components/styles/search.scss | 4 ++++ quartz/plugins/transformers/gfm.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index d25d0f8..2f07fe7 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -159,6 +159,10 @@ margin: 0 auto; width: min($pageWidth, 100%); } + + & [data-icon] { + background-color: transparent; + } } & > #results-container { diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts index 7860f85..dc0d1f0 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/gfm.ts @@ -35,6 +35,7 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin | ariaHidden: true, tabIndex: -1, "data-no-popover": true, + "data-icon": true, }, content: { type: "element", From d2fb50b83c9557f1cd36e4706e9a11aa8f01e458 Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Tue, 6 Feb 2024 02:06:19 -0500 Subject: [PATCH 05/14] fix(links): show backdrop on links highlighted in headers alias (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: assign specific classes based on parent node Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * fix: use custom role for anchor icone Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * fix: allow color on links 😄 Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: unify search inner container Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> --------- Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> --- quartz/components/styles/search.scss | 2 +- quartz/plugins/transformers/gfm.ts | 2 +- quartz/styles/base.scss | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/quartz/components/styles/search.scss b/quartz/components/styles/search.scss index 2f07fe7..8a9ec67 100644 --- a/quartz/components/styles/search.scss +++ b/quartz/components/styles/search.scss @@ -160,7 +160,7 @@ width: min($pageWidth, 100%); } - & [data-icon] { + a[role="anchor"] { background-color: transparent; } } diff --git a/quartz/plugins/transformers/gfm.ts b/quartz/plugins/transformers/gfm.ts index dc0d1f0..48681ff 100644 --- a/quartz/plugins/transformers/gfm.ts +++ b/quartz/plugins/transformers/gfm.ts @@ -32,10 +32,10 @@ export const GitHubFlavoredMarkdown: QuartzTransformerPlugin | { behavior: "append", properties: { + role: "anchor", ariaHidden: true, tabIndex: -1, "data-no-popover": true, - "data-icon": true, }, content: { type: "element", diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 33d6267..f0e7c14 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -259,11 +259,9 @@ thead { font-weight: revert; margin-bottom: 0; - article > & > a { + article > & > a[role="anchor"] { color: var(--dark); - &.internal { - background-color: transparent; - } + background-color: transparent; } } From ce413b4bae40a21e354b2e065fda60045ae28ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Silviu=20Loren=C8=9B?= <124451350+smilorent@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:26:45 +0200 Subject: [PATCH 06/14] feat(i18n): add Romanian to i18n (#821) --- quartz/i18n/index.ts | 2 ++ quartz/i18n/locales/ro-RO.ts | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 quartz/i18n/locales/ro-RO.ts diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 9e14ffe..579f19e 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -4,6 +4,7 @@ import fr from "./locales/fr-FR" import ja from "./locales/ja-JP" import de from "./locales/de-DE" import nl from "./locales/nl-NL" +import ro from "./locales/ro-RO" export const TRANSLATIONS = { "en-US": en, @@ -11,6 +12,7 @@ export const TRANSLATIONS = { "ja-JP": ja, "de-DE": de, "nl-NL": nl, + "ro-RO": ro, } as const export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? "en-US"] diff --git a/quartz/i18n/locales/ro-RO.ts b/quartz/i18n/locales/ro-RO.ts new file mode 100644 index 0000000..8fce4a9 --- /dev/null +++ b/quartz/i18n/locales/ro-RO.ts @@ -0,0 +1,65 @@ +import { Translation } from "./definition" + +export default { + propertyDefaults: { + title: "Fără titlu", + description: "Nici o descriere furnizată", + }, + components: { + backlinks: { + title: "Legături înapoi", + noBacklinksFound: "Nu s-au găsit legături înapoi", + }, + themeToggle: { + lightMode: "Modul luminos", + darkMode: "Modul întunecat", + }, + explorer: { + title: "Explorator", + }, + footer: { + createdWith: "Creat cu", + }, + graph: { + title: "Graf", + }, + recentNotes: { + title: "Notițe recente", + seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining} →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`, + linkToOriginal: "Legătură către original", + }, + search: { + title: "Căutare", + searchBarPlaceholder: "Introduceți termenul de căutare...", + }, + tableOfContents: { + title: "Cuprins", + }, + }, + pages: { + rss: { + recentNotes: "Notițe recente", + lastFewNotes: ({ count }) => `Ultimele ${count} notițe`, + }, + error: { + title: "Pagina nu a fost găsită", + notFound: "Fie această pagină este privată, fie nu există.", + }, + folderContent: { + folder: "Dosar", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 articol în acest dosar." : `${count} elemente în acest dosar.`, + }, + tagContent: { + tag: "Etichetă", + tagIndex: "Indexul etichetelor", + itemsUnderTag: ({ count }) => + count === 1 ? "1 articol cu această etichetă." : `${count} articole cu această etichetă.`, + showingFirst: ({ count }) => `Se afișează primele ${count} etichete.`, + totalTags: ({ count }) => `Au fost găsite ${count} etichete în total.`, + }, + }, +} as const satisfies Translation From 2578597f7ec7e932f5e30e2c38995dc9dd6eac75 Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Wed, 7 Feb 2024 12:29:47 -0500 Subject: [PATCH 07/14] chore(lang): lang element based on frontmatter or default locale (#819) default locale Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> --- quartz/components/renderPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 1ed05a7..d6bcf0a 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -209,8 +209,10 @@ export function renderPage( ) + const lang = componentData.frontmatter?.lang ?? cfg.locale.split("-")[0] + const doc = ( - +
From ca284778b257698457116d65bc9559dbb37a1166 Mon Sep 17 00:00:00 2001 From: Miguel Pimentel Date: Wed, 7 Feb 2024 09:57:14 -0800 Subject: [PATCH 08/14] add Spanish translations (#822) * add Spanish translations * format with prettier * clears npm ci, formatted w/ prettier --- quartz/i18n/index.ts | 2 ++ quartz/i18n/locales/es-ES.ts | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 quartz/i18n/locales/es-ES.ts diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 579f19e..d24d528 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -5,6 +5,7 @@ import ja from "./locales/ja-JP" import de from "./locales/de-DE" import nl from "./locales/nl-NL" import ro from "./locales/ro-RO" +import es from "./locales/es-ES" export const TRANSLATIONS = { "en-US": en, @@ -13,6 +14,7 @@ export const TRANSLATIONS = { "de-DE": de, "nl-NL": nl, "ro-RO": ro, + "es-ES": es, } as const export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? "en-US"] diff --git a/quartz/i18n/locales/es-ES.ts b/quartz/i18n/locales/es-ES.ts new file mode 100644 index 0000000..4d39542 --- /dev/null +++ b/quartz/i18n/locales/es-ES.ts @@ -0,0 +1,65 @@ +import { Translation } from "./definition" + +export default { + propertyDefaults: { + title: "Sin título", + description: "Sin descripción", + }, + components: { + backlinks: { + title: "Enlaces de Retroceso", + noBacklinksFound: "No se han encontrado enlaces traseros", + }, + themeToggle: { + lightMode: "Modo claro", + darkMode: "Modo oscuro", + }, + explorer: { + title: "Explorador", + }, + footer: { + createdWith: "Creado con", + }, + graph: { + title: "Vista Gráfica", + }, + recentNotes: { + title: "Notas Recientes", + seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`, + linkToOriginal: "Enlace al original", + }, + search: { + title: "Buscar", + searchBarPlaceholder: "Busca algo", + }, + tableOfContents: { + title: "Tabla de Contenidos", + }, + }, + pages: { + rss: { + recentNotes: "Notas recientes", + lastFewNotes: ({ count }) => `Últimás ${count} notas`, + }, + error: { + title: "No se encontró.", + notFound: "Esta página es privada o no existe.", + }, + folderContent: { + folder: "Carpeta", + itemsUnderFolder: ({ count }) => + count === 1 ? "1 artículo en esta carpeta" : `${count} artículos en esta carpeta.`, + }, + tagContent: { + tag: "Etiqueta", + tagIndex: "Índice de Etiquetas", + itemsUnderTag: ({ count }) => + count === 1 ? "1 artículo con esta etiqueta" : `${count} artículos con esta etiqueta.`, + showingFirst: ({ count }) => `Mostrando las primeras ${count} etiquetas.`, + totalTags: ({ count }) => `Se encontraron ${count} etiquetas en total.`, + }, + }, +} as const satisfies Translation From 330e322e48ba8890b11f4de0a84f3cffaad22096 Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Thu, 8 Feb 2024 02:52:55 -0500 Subject: [PATCH 09/14] feat(fonts): fetch before build (#817) * feat: fetch google fonts before build Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * Update quartz/plugins/emitters/componentResources.ts * fix: fetching wolff2 Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: remove request stylesheet Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * fix: race condition Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: remove preconnect for static fonts since we are already downloading fonts into public folder Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: remove deadcode Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: add options to gate for cdn caching Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Jacky Zhao * chore: apply jacky's suggestion Co-authored-by: Jacky Zhao * chore: add docs and only use one promise Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * fix: fmt Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> * chore: remove deadcode * chore: final touches Co-authored-by: Jacky Zhao * revert: changes in theme.ts * fix: styles and remove deadcode Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> --------- Signed-off-by: Aaron <29749331+aarnphm@users.noreply.github.com> Co-authored-by: Jacky Zhao --- docs/configuration.md | 1 + quartz.config.ts | 1 + quartz/components/Head.tsx | 8 ++- quartz/plugins/emitters/componentResources.ts | 62 ++++++++++++++++--- quartz/plugins/emitters/helpers.ts | 2 +- quartz/util/theme.ts | 1 + 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 33d5a57..b0c8506 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ This part of the configuration concerns anything that can affect the whole site. - `ignorePatterns`: a list of [glob]() patterns that Quartz should ignore and not search through when looking for files inside the `content` folder. See [[private pages]] for more details. - `defaultDateType`: whether to use created, modified, or published as the default date to display on pages and page listings. - `theme`: configure how the site looks. + - `cdnCaching`: Whether to use Google CDN to cache the fonts (generally will be faster). Disable this if you want Quartz to be self-contained. Default to `true` - `typography`: what fonts to use. Any font available on [Google Fonts](https://fonts.google.com/) works here. - `header`: Font to use for headers - `code`: Font for inline and block quotes. diff --git a/quartz.config.ts b/quartz.config.ts index 4921a11..4e36e94 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -14,6 +14,7 @@ const config: QuartzConfig = { ignorePatterns: ["private", "templates", ".obsidian"], defaultDateType: "created", theme: { + cdnCaching: true, typography: { header: "Schibsted Grotesk", body: "Source Sans Pro", diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index b94909c..dae81c7 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -30,8 +30,12 @@ export default (() => { - - + {cfg.theme.cdnCaching && ( + <> + + + + )} {css.map((href) => ( ))} diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 5eb9718..666c1d2 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,4 +1,4 @@ -import { FilePath, FullSlug } from "../../util/path" +import { FilePath, FullSlug, joinSegments } from "../../util/path" import { QuartzEmitterPlugin } from "../types" // @ts-ignore @@ -172,27 +172,72 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< return [] }, async emit(ctx, _content, resources): Promise { + const promises: Promise[] = [] + const cfg = ctx.cfg.configuration // component specific scripts and styles const componentResources = getComponentResources(ctx) // important that this goes *after* component scripts // as the "nav" event gets triggered here and we should make sure // that everyone else had the chance to register a listener for it - if (fontOrigin === "googleFonts") { - resources.css.push(googleFontHref(ctx.cfg.configuration.theme)) - } else if (fontOrigin === "local") { + let googleFontsStyleSheet = "" + if (fontOrigin === "local") { // let the user do it themselves in css + } else if (fontOrigin === "googleFonts") { + if (cfg.theme.cdnCaching) { + resources.css.push(googleFontHref(cfg.theme)) + } else { + let match + + const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g + + googleFontsStyleSheet = await ( + await fetch(googleFontHref(ctx.cfg.configuration.theme)) + ).text() + + while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) { + // match[0] is the `url(path)`, match[1] is the `path` + const url = match[1] + // the static name of this file. + const [filename, ext] = url.split("/").pop()!.split(".") + + googleFontsStyleSheet = googleFontsStyleSheet.replace(url, `/fonts/${filename}.ttf`) + + promises.push( + fetch(url) + .then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch font`) + } + return res.arrayBuffer() + }) + .then((buf) => + write({ + ctx, + slug: joinSegments("fonts", filename) as FullSlug, + ext: `.${ext}`, + content: Buffer.from(buf), + }), + ), + ) + } + } } addGlobalPageResources(ctx, resources, componentResources) - const stylesheet = joinStyles(ctx.cfg.configuration.theme, ...componentResources.css, styles) + const stylesheet = joinStyles( + ctx.cfg.configuration.theme, + ...componentResources.css, + googleFontsStyleSheet, + styles, + ) const [prescript, postscript] = await Promise.all([ joinScripts(componentResources.beforeDOMLoaded), joinScripts(componentResources.afterDOMLoaded), ]) - const fps = await Promise.all([ + promises.push( write({ ctx, slug: "index" as FullSlug, @@ -223,8 +268,9 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< ext: ".js", content: postscript, }), - ]) - return fps + ) + + return await Promise.all(promises) }, } } diff --git a/quartz/plugins/emitters/helpers.ts b/quartz/plugins/emitters/helpers.ts index ef1d1c3..523151c 100644 --- a/quartz/plugins/emitters/helpers.ts +++ b/quartz/plugins/emitters/helpers.ts @@ -7,7 +7,7 @@ type WriteOptions = { ctx: BuildCtx slug: FullSlug ext: `.${string}` | "" - content: string + content: string | Buffer } export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise => { diff --git a/quartz/util/theme.ts b/quartz/util/theme.ts index 47951c4..bd0da5f 100644 --- a/quartz/util/theme.ts +++ b/quartz/util/theme.ts @@ -15,6 +15,7 @@ export interface Theme { body: string code: string } + cdnCaching: boolean colors: { lightMode: ColorScheme darkMode: ColorScheme From 51818efc38eae1e4d300f004cb637d2f75a7f41a Mon Sep 17 00:00:00 2001 From: Aaron Pham <29749331+aarnphm@users.noreply.github.com> Date: Thu, 8 Feb 2024 11:45:20 -0500 Subject: [PATCH 10/14] fix(umami): format correct string from custom hosts (#826) --- quartz/plugins/emitters/componentResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 666c1d2..4033bdf 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -119,7 +119,7 @@ function addGlobalPageResources( } else if (cfg.analytics?.provider === "umami") { componentResources.afterDOMLoaded.push(` const umamiScript = document.createElement("script") - umamiScript.src = cfg.analytics.host ?? "https://analytics.umami.is/script.js" + umamiScript.src = ${cfg.analytics.host} ?? "https://analytics.umami.is/script.js" umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") umamiScript.async = true From e186811c9c309a61f9c29208541949dd2bc5d77d Mon Sep 17 00:00:00 2001 From: Serhii Stets Date: Thu, 8 Feb 2024 18:47:12 +0200 Subject: [PATCH 11/14] added Ukrainian to i18n (#829) --- quartz/i18n/index.ts | 2 ++ quartz/i18n/locales/uk-UA.ts | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 quartz/i18n/locales/uk-UA.ts diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index d24d528..2031724 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -6,6 +6,7 @@ import de from "./locales/de-DE" import nl from "./locales/nl-NL" import ro from "./locales/ro-RO" import es from "./locales/es-ES" +import uk from "./locales/uk-UA" export const TRANSLATIONS = { "en-US": en, @@ -15,6 +16,7 @@ export const TRANSLATIONS = { "nl-NL": nl, "ro-RO": ro, "es-ES": es, + "uk-UA": uk, } as const export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? "en-US"] diff --git a/quartz/i18n/locales/uk-UA.ts b/quartz/i18n/locales/uk-UA.ts new file mode 100644 index 0000000..bf664b6 --- /dev/null +++ b/quartz/i18n/locales/uk-UA.ts @@ -0,0 +1,65 @@ +import { Translation } from "./definition" + +export default { + propertyDefaults: { + title: "Без назви", + description: "Опис не надано", + }, + components: { + backlinks: { + title: "Зворотні посилання", + noBacklinksFound: "Зворотних посилань не знайдено", + }, + themeToggle: { + lightMode: "Світлий режим", + darkMode: "Темний режим", + }, + explorer: { + title: "Провідник", + }, + footer: { + createdWith: "Створено за допомогою", + }, + graph: { + title: "Вигляд графа", + }, + recentNotes: { + title: "Останні нотатки", + seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining} →`, + }, + transcludes: { + transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`, + linkToOriginal: "Посилання на оригінал", + }, + search: { + title: "Пошук", + searchBarPlaceholder: "Шукати щось", + }, + tableOfContents: { + title: "Зміст", + }, + }, + pages: { + rss: { + recentNotes: "Останні нотатки", + lastFewNotes: ({ count }) => `Останні нотатки: ${count}`, + }, + error: { + title: "Не знайдено", + notFound: "Ця сторінка або приватна, або не існує.", + }, + folderContent: { + folder: "Папка", + itemsUnderFolder: ({ count }) => + count === 1 ? "У цій папці 1 елемент" : `Елементів у цій папці: ${count}.`, + }, + tagContent: { + tag: "Тег", + tagIndex: "Індекс тегу", + itemsUnderTag: ({ count }) => + count === 1 ? "1 елемент з цим тегом" : `Елементів з цим тегом: ${count}.`, + showingFirst: ({ count }) => `Показ перших ${count} тегів.`, + totalTags: ({ count }) => `Всього знайдено тегів: ${count}.`, + }, + }, +} as const satisfies Translation From fd785ada56902d7ab0a63899b0e798e0c43e8071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Silviu=20Loren=C8=9B?= <124451350+smilorent@users.noreply.github.com> Date: Thu, 8 Feb 2024 18:48:13 +0200 Subject: [PATCH 12/14] feat(i18n): use Romanian translation for `ro-MD` locale (#828) --- quartz/i18n/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 2031724..0f17fff 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -15,6 +15,7 @@ export const TRANSLATIONS = { "de-DE": de, "nl-NL": nl, "ro-RO": ro, + "ro-MD": ro, "es-ES": es, "uk-UA": uk, } as const From a87704cd05e379c670abce823385fb937a65920d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 8 Feb 2024 09:31:36 -0800 Subject: [PATCH 13/14] fix: set default locale for lang attribute --- quartz/components/renderPage.tsx | 3 +-- quartz/i18n/index.ts | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index d6bcf0a..4643d0a 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -209,8 +209,7 @@ export function renderPage(
) - const lang = componentData.frontmatter?.lang ?? cfg.locale.split("-")[0] - + const lang = componentData.frontmatter?.lang ?? cfg.locale?.split("-")[0] ?? "en" const doc = ( diff --git a/quartz/i18n/index.ts b/quartz/i18n/index.ts index 0f17fff..7fd978e 100644 --- a/quartz/i18n/index.ts +++ b/quartz/i18n/index.ts @@ -20,5 +20,6 @@ export const TRANSLATIONS = { "uk-UA": uk, } as const -export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? "en-US"] +export const defaultTranslation = "en-US" +export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale ?? defaultTranslation] export type ValidLocale = keyof typeof TRANSLATIONS From fe353d946bd90d38647a9dceff7ea85d425e8a83 Mon Sep 17 00:00:00 2001 From: kabirgh <15871468+kabirgh@users.noreply.github.com> Date: Fri, 9 Feb 2024 15:07:32 +0000 Subject: [PATCH 14/14] feat(experimental): partial rebuilds (#716) --- package.json | 2 +- quartz/build.ts | 194 +++++++++++++++++- quartz/cli/args.js | 5 + quartz/depgraph.test.ts | 96 +++++++++ quartz/depgraph.ts | 187 +++++++++++++++++ quartz/plugins/emitters/404.tsx | 4 + quartz/plugins/emitters/aliases.ts | 5 + quartz/plugins/emitters/assets.ts | 19 ++ quartz/plugins/emitters/cname.ts | 4 + quartz/plugins/emitters/componentResources.ts | 26 ++- quartz/plugins/emitters/contentIndex.ts | 21 ++ quartz/plugins/emitters/contentPage.tsx | 17 +- quartz/plugins/emitters/folderPage.tsx | 8 + quartz/plugins/emitters/static.ts | 15 ++ quartz/plugins/emitters/tagPage.tsx | 5 + quartz/plugins/types.ts | 6 + quartz/util/ctx.ts | 1 + 17 files changed, 604 insertions(+), 11 deletions(-) create mode 100644 quartz/depgraph.test.ts create mode 100644 quartz/depgraph.ts diff --git a/package.json b/package.json index c51a9ed..5c75701 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,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", + "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" }, "engines": { diff --git a/quartz/build.ts b/quartz/build.ts index 1f90301..ed166bb 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -17,6 +17,10 @@ import { glob, toPosixPath } from "./util/glob" import { trace } from "./util/trace" import { options } from "./util/sourcemap" import { Mutex } from "async-mutex" +import DepGraph from "./depgraph" +import { getStaticResourcesFromPlugins } from "./plugins" + +type Dependencies = Record | null> type BuildData = { ctx: BuildCtx @@ -29,8 +33,11 @@ type BuildData = { toRebuild: Set toRemove: Set lastBuildMs: number + dependencies: Dependencies } +type FileEvent = "add" | "change" | "delete" + async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const ctx: BuildCtx = { argv, @@ -68,12 +75,24 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const parsedFiles = await parseMarkdown(ctx, filePaths) const filteredContent = filterContent(ctx, parsedFiles) + + const dependencies: Record | null> = {} + + // Only build dependency graphs if we're doing a fast rebuild + if (argv.fastRebuild) { + const staticResources = getStaticResourcesFromPlugins(ctx) + for (const emitter of cfg.plugins.emitters) { + dependencies[emitter.name] = + (await emitter.getDependencyGraph?.(ctx, filteredContent, staticResources)) ?? null + } + } + await emitContent(ctx, filteredContent) console.log(chalk.green(`Done processing ${fps.length} files in ${perf.timeSince()}`)) release() if (argv.serve) { - return startServing(ctx, mut, parsedFiles, clientRefresh) + return startServing(ctx, mut, parsedFiles, clientRefresh, dependencies) } } @@ -83,9 +102,11 @@ async function startServing( mut: Mutex, initialContent: ProcessedContent[], clientRefresh: () => void, + dependencies: Dependencies, // emitter name: dep graph ) { const { argv } = ctx + // cache file parse results const contentMap = new Map() for (const content of initialContent) { const [_tree, vfile] = content @@ -95,6 +116,7 @@ async function startServing( const buildData: BuildData = { ctx, mut, + dependencies, contentMap, ignored: await isGitIgnored(), initialSlugs: ctx.allSlugs, @@ -110,19 +132,181 @@ async function startServing( ignoreInitial: true, }) + const buildFromEntry = argv.fastRebuild ? partialRebuildFromEntrypoint : rebuildFromEntrypoint watcher - .on("add", (fp) => rebuildFromEntrypoint(fp, "add", clientRefresh, buildData)) - .on("change", (fp) => rebuildFromEntrypoint(fp, "change", clientRefresh, buildData)) - .on("unlink", (fp) => rebuildFromEntrypoint(fp, "delete", clientRefresh, buildData)) + .on("add", (fp) => buildFromEntry(fp, "add", clientRefresh, buildData)) + .on("change", (fp) => buildFromEntry(fp, "change", clientRefresh, buildData)) + .on("unlink", (fp) => buildFromEntry(fp, "delete", clientRefresh, buildData)) return async () => { await watcher.close() } } +async function partialRebuildFromEntrypoint( + filepath: string, + action: FileEvent, + clientRefresh: () => void, + buildData: BuildData, // note: this function mutates buildData +) { + const { ctx, ignored, dependencies, contentMap, mut, toRemove } = buildData + const { argv, cfg } = ctx + + // don't do anything for gitignored files + if (ignored(filepath)) { + return + } + + const buildStart = new Date().getTime() + buildData.lastBuildMs = buildStart + const release = await mut.acquire() + if (buildData.lastBuildMs > buildStart) { + release() + return + } + + const perf = new PerfTimer() + console.log(chalk.yellow("Detected change, rebuilding...")) + + // UPDATE DEP GRAPH + const fp = joinSegments(argv.directory, toPosixPath(filepath)) as FilePath + + const staticResources = getStaticResourcesFromPlugins(ctx) + let processedFiles: ProcessedContent[] = [] + + switch (action) { + case "add": + // add to cache when new file is added + processedFiles = await parseMarkdown(ctx, [fp]) + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) + + // update the dep graph by asking all emitters whether they depend on this file + for (const emitter of cfg.plugins.emitters) { + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null + + // emmiter may not define a dependency graph. nothing to update if so + if (emitterGraph) { + dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) + } + } + break + case "change": + // invalidate cache when file is changed + processedFiles = await parseMarkdown(ctx, [fp]) + processedFiles.forEach(([tree, vfile]) => contentMap.set(vfile.data.filePath!, [tree, vfile])) + + // only content files can have added/removed dependencies because of transclusions + if (path.extname(fp) === ".md") { + for (const emitter of cfg.plugins.emitters) { + // get new dependencies from all emitters for this file + const emitterGraph = + (await emitter.getDependencyGraph?.(ctx, processedFiles, staticResources)) ?? null + + // emmiter may not define a dependency graph. nothing to update if so + if (emitterGraph) { + // merge the new dependencies into the dep graph + dependencies[emitter.name]?.updateIncomingEdgesForNode(emitterGraph, fp) + } + } + } + break + case "delete": + toRemove.add(fp) + break + } + + if (argv.verbose) { + console.log(`Updated dependency graphs in ${perf.timeSince()}`) + } + + // EMIT + perf.addEvent("rebuild") + let emittedFiles = 0 + const destinationsToDelete = new Set() + + for (const emitter of cfg.plugins.emitters) { + const depGraph = dependencies[emitter.name] + + // emitter hasn't defined a dependency graph. call it with all processed files + if (depGraph === null) { + if (argv.verbose) { + console.log( + `Emitter ${emitter.name} doesn't define a dependency graph. Calling it with all files...`, + ) + } + + const files = [...contentMap.values()].filter( + ([_node, vfile]) => !toRemove.has(vfile.data.filePath!), + ) + + const emittedFps = await emitter.emit(ctx, files, staticResources) + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`) + } + } + + emittedFiles += emittedFps.length + continue + } + + // only call the emitter if it uses this file + if (depGraph.hasNode(fp)) { + // re-emit using all files that are needed for the downstream of this file + // eg. for ContentIndex, the dep graph could be: + // a.md --> contentIndex.json + // b.md ------^ + // + // if a.md changes, we need to re-emit contentIndex.json, + // and supply [a.md, b.md] to the emitter + const upstreams = [...depGraph.getLeafNodeAncestors(fp)] as FilePath[] + + if (action === "delete" && upstreams.length === 1) { + // if there's only one upstream, the destination is solely dependent on this file + destinationsToDelete.add(upstreams[0]) + } + + const upstreamContent = upstreams + // filter out non-markdown files + .filter((file) => contentMap.has(file)) + // if file was deleted, don't give it to the emitter + .filter((file) => !toRemove.has(file)) + .map((file) => contentMap.get(file)!) + + const emittedFps = await emitter.emit(ctx, upstreamContent, staticResources) + + if (ctx.argv.verbose) { + for (const file of emittedFps) { + console.log(`[emit:${emitter.name}] ${file}`) + } + } + + emittedFiles += emittedFps.length + } + } + + console.log(`Emitted ${emittedFiles} files to \`${argv.output}\` in ${perf.timeSince("rebuild")}`) + + // CLEANUP + // delete files that are solely dependent on this file + await rimraf([...destinationsToDelete]) + for (const file of toRemove) { + // remove from cache + contentMap.delete(file) + // remove the node from dependency graphs + Object.values(dependencies).forEach((depGraph) => depGraph?.removeNode(file)) + } + + toRemove.clear() + release() + clientRefresh() +} + async function rebuildFromEntrypoint( fp: string, - action: "add" | "change" | "delete", + action: FileEvent, clientRefresh: () => void, buildData: BuildData, // note: this function mutates buildData ) { diff --git a/quartz/cli/args.js b/quartz/cli/args.js index 7ed5b07..123d0ac 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -71,6 +71,11 @@ export const BuildArgv = { default: false, describe: "run a local server to live-preview your Quartz", }, + fastRebuild: { + boolean: true, + default: false, + describe: "[experimental] rebuild only the changed files", + }, baseDir: { string: true, default: "", diff --git a/quartz/depgraph.test.ts b/quartz/depgraph.test.ts new file mode 100644 index 0000000..43eb402 --- /dev/null +++ b/quartz/depgraph.test.ts @@ -0,0 +1,96 @@ +import test, { describe } from "node:test" +import DepGraph from "./depgraph" +import assert from "node:assert" + +describe("DepGraph", () => { + test("getLeafNodes", () => { + const graph = new DepGraph() + graph.addEdge("A", "B") + graph.addEdge("B", "C") + graph.addEdge("D", "C") + assert.deepStrictEqual(graph.getLeafNodes("A"), new Set(["C"])) + assert.deepStrictEqual(graph.getLeafNodes("B"), new Set(["C"])) + assert.deepStrictEqual(graph.getLeafNodes("C"), new Set(["C"])) + assert.deepStrictEqual(graph.getLeafNodes("D"), new Set(["C"])) + }) + + describe("getLeafNodeAncestors", () => { + test("gets correct ancestors in a graph without cycles", () => { + const graph = new DepGraph() + graph.addEdge("A", "B") + graph.addEdge("B", "C") + graph.addEdge("D", "B") + assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "D"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "D"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "D"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "D"])) + }) + + test("gets correct ancestors in a graph with cycles", () => { + const graph = new DepGraph() + graph.addEdge("A", "B") + graph.addEdge("B", "C") + graph.addEdge("C", "A") + graph.addEdge("C", "D") + assert.deepStrictEqual(graph.getLeafNodeAncestors("A"), new Set(["A", "B", "C"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("B"), new Set(["A", "B", "C"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("C"), new Set(["A", "B", "C"])) + assert.deepStrictEqual(graph.getLeafNodeAncestors("D"), new Set(["A", "B", "C"])) + }) + }) + + describe("updateIncomingEdgesForNode", () => { + test("merges when node exists", () => { + // A.md -> B.md -> B.html + const graph = new DepGraph() + graph.addEdge("A.md", "B.md") + graph.addEdge("B.md", "B.html") + + // B.md is edited so it removes the A.md transclusion + // and adds C.md transclusion + // C.md -> B.md + const other = new DepGraph() + other.addEdge("C.md", "B.md") + other.addEdge("B.md", "B.html") + + // A.md -> B.md removed, C.md -> B.md added + // C.md -> B.md -> B.html + graph.updateIncomingEdgesForNode(other, "B.md") + + const expected = { + nodes: ["A.md", "B.md", "B.html", "C.md"], + edges: [ + ["B.md", "B.html"], + ["C.md", "B.md"], + ], + } + + assert.deepStrictEqual(graph.export(), expected) + }) + + test("adds node if it does not exist", () => { + // A.md -> B.md + const graph = new DepGraph() + graph.addEdge("A.md", "B.md") + + // Add a new file C.md that transcludes B.md + // B.md -> C.md + const other = new DepGraph() + other.addEdge("B.md", "C.md") + + // B.md -> C.md added + // A.md -> B.md -> C.md + graph.updateIncomingEdgesForNode(other, "C.md") + + const expected = { + nodes: ["A.md", "B.md", "C.md"], + edges: [ + ["A.md", "B.md"], + ["B.md", "C.md"], + ], + } + + assert.deepStrictEqual(graph.export(), expected) + }) + }) +}) diff --git a/quartz/depgraph.ts b/quartz/depgraph.ts new file mode 100644 index 0000000..1efad07 --- /dev/null +++ b/quartz/depgraph.ts @@ -0,0 +1,187 @@ +export default class DepGraph { + // node: incoming and outgoing edges + _graph = new Map; outgoing: Set }>() + + constructor() { + this._graph = new Map() + } + + export(): Object { + return { + nodes: this.nodes, + edges: this.edges, + } + } + + toString(): string { + return JSON.stringify(this.export(), null, 2) + } + + // BASIC GRAPH OPERATIONS + + get nodes(): T[] { + return Array.from(this._graph.keys()) + } + + get edges(): [T, T][] { + let edges: [T, T][] = [] + this.forEachEdge((edge) => edges.push(edge)) + return edges + } + + hasNode(node: T): boolean { + return this._graph.has(node) + } + + addNode(node: T): void { + if (!this._graph.has(node)) { + this._graph.set(node, { incoming: new Set(), outgoing: new Set() }) + } + } + + removeNode(node: T): void { + if (this._graph.has(node)) { + this._graph.delete(node) + } + } + + hasEdge(from: T, to: T): boolean { + return Boolean(this._graph.get(from)?.outgoing.has(to)) + } + + addEdge(from: T, to: T): void { + this.addNode(from) + this.addNode(to) + + this._graph.get(from)!.outgoing.add(to) + this._graph.get(to)!.incoming.add(from) + } + + removeEdge(from: T, to: T): void { + if (this._graph.has(from) && this._graph.has(to)) { + this._graph.get(from)!.outgoing.delete(to) + this._graph.get(to)!.incoming.delete(from) + } + } + + // returns -1 if node does not exist + outDegree(node: T): number { + return this.hasNode(node) ? this._graph.get(node)!.outgoing.size : -1 + } + + // returns -1 if node does not exist + inDegree(node: T): number { + return this.hasNode(node) ? this._graph.get(node)!.incoming.size : -1 + } + + forEachOutNeighbor(node: T, callback: (neighbor: T) => void): void { + this._graph.get(node)?.outgoing.forEach(callback) + } + + forEachInNeighbor(node: T, callback: (neighbor: T) => void): void { + this._graph.get(node)?.incoming.forEach(callback) + } + + forEachEdge(callback: (edge: [T, T]) => void): void { + for (const [source, { outgoing }] of this._graph.entries()) { + for (const target of outgoing) { + callback([source, target]) + } + } + } + + // DEPENDENCY ALGORITHMS + + // For the node provided: + // If node does not exist, add it + // If an incoming edge was added in other, it is added in this graph + // If an incoming edge was deleted in other, it is deleted in this graph + updateIncomingEdgesForNode(other: DepGraph, node: T): void { + this.addNode(node) + + // Add edge if it is present in other + other.forEachInNeighbor(node, (neighbor) => { + this.addEdge(neighbor, node) + }) + + // For node provided, remove incoming edge if it is absent in other + this.forEachEdge(([source, target]) => { + if (target === node && !other.hasEdge(source, target)) { + this.removeEdge(source, target) + } + }) + } + + // Get all leaf nodes (i.e. destination paths) reachable from the node provided + // Eg. if the graph is A -> B -> C + // D ---^ + // and the node is B, this function returns [C] + getLeafNodes(node: T): Set { + let stack: T[] = [node] + let visited = new Set() + let leafNodes = new Set() + + // DFS + while (stack.length > 0) { + let node = stack.pop()! + + // If the node is already visited, skip it + if (visited.has(node)) { + continue + } + visited.add(node) + + // Check if the node is a leaf node (i.e. destination path) + if (this.outDegree(node) === 0) { + leafNodes.add(node) + } + + // Add all unvisited neighbors to the stack + this.forEachOutNeighbor(node, (neighbor) => { + if (!visited.has(neighbor)) { + stack.push(neighbor) + } + }) + } + + return leafNodes + } + + // Get all ancestors of the leaf nodes reachable from the node provided + // Eg. if the graph is A -> B -> C + // D ---^ + // and the node is B, this function returns [A, B, D] + getLeafNodeAncestors(node: T): Set { + const leafNodes = this.getLeafNodes(node) + let visited = new Set() + let upstreamNodes = new Set() + + // Backwards DFS for each leaf node + leafNodes.forEach((leafNode) => { + let stack: T[] = [leafNode] + + while (stack.length > 0) { + let node = stack.pop()! + + if (visited.has(node)) { + continue + } + visited.add(node) + // Add node if it's not a leaf node (i.e. destination path) + // Assumes destination file cannot depend on another destination file + if (this.outDegree(node) !== 0) { + upstreamNodes.add(node) + } + + // Add all unvisited parents to the stack + this.forEachInNeighbor(node, (parentNode) => { + if (!visited.has(parentNode)) { + stack.push(parentNode) + } + }) + } + }) + + return upstreamNodes + } +} diff --git a/quartz/plugins/emitters/404.tsx b/quartz/plugins/emitters/404.tsx index 079adbc..f9d7a86 100644 --- a/quartz/plugins/emitters/404.tsx +++ b/quartz/plugins/emitters/404.tsx @@ -9,6 +9,7 @@ import { NotFound } from "../../components" import { defaultProcessedContent } from "../vfile" import { write } from "./helpers" import { i18n } from "../../i18n" +import DepGraph from "../../depgraph" export const NotFoundPage: QuartzEmitterPlugin = () => { const opts: FullPageLayout = { @@ -27,6 +28,9 @@ export const NotFoundPage: QuartzEmitterPlugin = () => { getQuartzComponents() { return [Head, Body, pageBody, Footer] }, + async getDependencyGraph(_ctx, _content, _resources) { + return new DepGraph() + }, async emit(ctx, _content, resources): Promise { const cfg = ctx.cfg.configuration const slug = "404" as FullSlug diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts index d407629..fb25a44 100644 --- a/quartz/plugins/emitters/aliases.ts +++ b/quartz/plugins/emitters/aliases.ts @@ -2,12 +2,17 @@ import { FilePath, FullSlug, joinSegments, resolveRelative, simplifySlug } from import { QuartzEmitterPlugin } from "../types" import path from "path" import { write } from "./helpers" +import DepGraph from "../../depgraph" export const AliasRedirects: QuartzEmitterPlugin = () => ({ name: "AliasRedirects", getQuartzComponents() { return [] }, + async getDependencyGraph(_ctx, _content, _resources) { + // TODO implement + return new DepGraph() + }, async emit(ctx, content, _resources): Promise { const { argv } = ctx const fps: FilePath[] = [] diff --git a/quartz/plugins/emitters/assets.ts b/quartz/plugins/emitters/assets.ts index cc97b2e..379cd5b 100644 --- a/quartz/plugins/emitters/assets.ts +++ b/quartz/plugins/emitters/assets.ts @@ -3,6 +3,7 @@ import { QuartzEmitterPlugin } from "../types" import path from "path" import fs from "fs" import { glob } from "../../util/glob" +import DepGraph from "../../depgraph" export const Assets: QuartzEmitterPlugin = () => { return { @@ -10,6 +11,24 @@ export const Assets: QuartzEmitterPlugin = () => { getQuartzComponents() { return [] }, + async getDependencyGraph(ctx, _content, _resources) { + const { argv, cfg } = ctx + const graph = new DepGraph() + + const fps = await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns]) + + for (const fp of fps) { + const ext = path.extname(fp) + const src = joinSegments(argv.directory, fp) as FilePath + const name = (slugifyFilePath(fp as FilePath, true) + ext) as FilePath + + const dest = joinSegments(argv.output, name) as FilePath + + graph.addEdge(src, dest) + } + + return graph + }, async emit({ argv, cfg }, _content, _resources): Promise { // glob all non MD/MDX/HTML files in content folder and copy it over const assetsPath = argv.output diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts index 3e17fea..cbed2a8 100644 --- a/quartz/plugins/emitters/cname.ts +++ b/quartz/plugins/emitters/cname.ts @@ -2,6 +2,7 @@ import { FilePath, joinSegments } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import fs from "fs" import chalk from "chalk" +import DepGraph from "../../depgraph" export function extractDomainFromBaseUrl(baseUrl: string) { const url = new URL(`https://${baseUrl}`) @@ -13,6 +14,9 @@ export const CNAME: QuartzEmitterPlugin = () => ({ getQuartzComponents() { return [] }, + async getDependencyGraph(_ctx, _content, _resources) { + return new DepGraph() + }, async emit({ argv, cfg }, _content, _resources): Promise { if (!cfg.configuration.baseUrl) { console.warn(chalk.yellow("CNAME emitter requires `baseUrl` to be set in your configuration")) diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 4033bdf..c3a60b2 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -14,6 +14,7 @@ import { googleFontHref, joinStyles } from "../../util/theme" import { Features, transform } from "lightningcss" import { transform as transpile } from "esbuild" import { write } from "./helpers" +import DepGraph from "../../depgraph" type ComponentResources = { css: string[] @@ -149,9 +150,10 @@ function addGlobalPageResources( loadTime: "afterDOMReady", contentType: "inline", script: ` - const socket = new WebSocket('${wsUrl}') - socket.addEventListener('message', () => document.location.reload()) - `, + const socket = new WebSocket('${wsUrl}') + // reload(true) ensures resources like images and scripts are fetched again in firefox + socket.addEventListener('message', () => document.location.reload(true)) + `, }) } } @@ -171,6 +173,24 @@ export const ComponentResources: QuartzEmitterPlugin = (opts?: Partial< getQuartzComponents() { return [] }, + async getDependencyGraph(ctx, content, _resources) { + // This emitter adds static resources to the `resources` parameter. One + // important resource this emitter adds is the code to start a websocket + // connection and listen to rebuild messages, which triggers a page reload. + // The resources parameter with the reload logic is later used by the + // ContentPage emitter while creating the final html page. In order for + // the reload logic to be included, and so for partial rebuilds to work, + // we need to run this emitter for all markdown files. + const graph = new DepGraph() + + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath! + const slug = file.data.slug! + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) + } + + return graph + }, async emit(ctx, _content, resources): Promise { const promises: Promise[] = [] const cfg = ctx.cfg.configuration diff --git a/quartz/plugins/emitters/contentIndex.ts b/quartz/plugins/emitters/contentIndex.ts index 1c86b71..c0fef86 100644 --- a/quartz/plugins/emitters/contentIndex.ts +++ b/quartz/plugins/emitters/contentIndex.ts @@ -7,6 +7,7 @@ import { QuartzEmitterPlugin } from "../types" import { toHtml } from "hast-util-to-html" import { write } from "./helpers" import { i18n } from "../../i18n" +import DepGraph from "../../depgraph" export type ContentIndex = Map export type ContentDetails = { @@ -92,6 +93,26 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { opts = { ...defaultOptions, ...opts } return { name: "ContentIndex", + async getDependencyGraph(ctx, content, _resources) { + const graph = new DepGraph() + + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath! + + graph.addEdge( + sourcePath, + joinSegments(ctx.argv.output, "static/contentIndex.json") as FilePath, + ) + if (opts?.enableSiteMap) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "sitemap.xml") as FilePath) + } + if (opts?.enableRSS) { + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, "index.xml") as FilePath) + } + } + + return graph + }, async emit(ctx, content, _resources) { const cfg = ctx.cfg.configuration const emitted: FilePath[] = [] diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index b11890b..e531b36 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -4,11 +4,12 @@ import HeaderConstructor from "../../components/Header" import BodyConstructor from "../../components/Body" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" -import { FilePath, pathToRoot } from "../../util/path" +import { FilePath, joinSegments, pathToRoot } from "../../util/path" import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout" import { Content } from "../../components" import chalk from "chalk" import { write } from "./helpers" +import DepGraph from "../../depgraph" export const ContentPage: QuartzEmitterPlugin> = (userOpts) => { const opts: FullPageLayout = { @@ -27,6 +28,18 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, + async getDependencyGraph(ctx, content, _resources) { + // TODO handle transclusions + const graph = new DepGraph() + + for (const [_tree, file] of content) { + const sourcePath = file.data.filePath! + const slug = file.data.slug! + graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) + } + + return graph + }, async emit(ctx, content, resources): Promise { const cfg = ctx.cfg.configuration const fps: FilePath[] = [] @@ -60,7 +73,7 @@ export const ContentPage: QuartzEmitterPlugin> = (userOp fps.push(fp) } - if (!containsIndex) { + 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.`, diff --git a/quartz/plugins/emitters/folderPage.tsx b/quartz/plugins/emitters/folderPage.tsx index 35c360a..7a62cda 100644 --- a/quartz/plugins/emitters/folderPage.tsx +++ b/quartz/plugins/emitters/folderPage.tsx @@ -19,6 +19,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay import { FolderContent } from "../../components" import { write } from "./helpers" import { i18n } from "../../i18n" +import DepGraph from "../../depgraph" export const FolderPage: QuartzEmitterPlugin> = (userOpts) => { const opts: FullPageLayout = { @@ -37,6 +38,13 @@ export const FolderPage: QuartzEmitterPlugin> = (userOpt getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, + async getDependencyGraph(ctx, content, _resources) { + // Example graph: + // nested/file.md --> nested/file.html + // \-------> nested/index.html + // TODO implement + return new DepGraph() + }, async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) diff --git a/quartz/plugins/emitters/static.ts b/quartz/plugins/emitters/static.ts index 9f93d9b..c52c628 100644 --- a/quartz/plugins/emitters/static.ts +++ b/quartz/plugins/emitters/static.ts @@ -2,12 +2,27 @@ import { FilePath, QUARTZ, joinSegments } from "../../util/path" import { QuartzEmitterPlugin } from "../types" import fs from "fs" import { glob } from "../../util/glob" +import DepGraph from "../../depgraph" export const Static: QuartzEmitterPlugin = () => ({ name: "Static", getQuartzComponents() { return [] }, + async getDependencyGraph({ argv, cfg }, _content, _resources) { + const graph = new DepGraph() + + const staticPath = joinSegments(QUARTZ, "static") + const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) + for (const fp of fps) { + graph.addEdge( + joinSegments("static", fp) as FilePath, + joinSegments(argv.output, "static", fp) as FilePath, + ) + } + + return graph + }, async emit({ argv, cfg }, _content, _resources): Promise { const staticPath = joinSegments(QUARTZ, "static") const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns) diff --git a/quartz/plugins/emitters/tagPage.tsx b/quartz/plugins/emitters/tagPage.tsx index 2411c68..332c758 100644 --- a/quartz/plugins/emitters/tagPage.tsx +++ b/quartz/plugins/emitters/tagPage.tsx @@ -16,6 +16,7 @@ import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.lay import { TagContent } from "../../components" import { write } from "./helpers" import { i18n } from "../../i18n" +import DepGraph from "../../depgraph" export const TagPage: QuartzEmitterPlugin> = (userOpts) => { const opts: FullPageLayout = { @@ -34,6 +35,10 @@ export const TagPage: QuartzEmitterPlugin> = (userOpts) getQuartzComponents() { return [Head, Header, Body, ...header, ...beforeBody, pageBody, ...left, ...right, Footer] }, + async getDependencyGraph(ctx, _content, _resources) { + // TODO implement + return new DepGraph() + }, async emit(ctx, content, resources): Promise { const fps: FilePath[] = [] const allFiles = content.map((c) => c[1].data) diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index a361bb9..a23f5d6 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -4,6 +4,7 @@ import { ProcessedContent } from "./vfile" import { QuartzComponent } from "../components/types" import { FilePath } from "../util/path" import { BuildCtx } from "../util/ctx" +import DepGraph from "../depgraph" export interface PluginTypes { transformers: QuartzTransformerPluginInstance[] @@ -38,4 +39,9 @@ export type QuartzEmitterPluginInstance = { name: string emit(ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources): Promise getQuartzComponents(ctx: BuildCtx): QuartzComponent[] + getDependencyGraph?( + ctx: BuildCtx, + content: ProcessedContent[], + resources: StaticResources, + ): Promise> } diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index 13e0bf8..e056114 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -6,6 +6,7 @@ export interface Argv { verbose: boolean output: string serve: boolean + fastRebuild: boolean port: number wsPort: number remoteDevHost?: string