From 6b0364e2e4012c2786399d7400f92566d263c2fc Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sat, 14 Feb 2026 00:06:26 +0100 Subject: [PATCH] Migrate emitters to external plugins (alias-redirects, cname, favicon, content-index, og-image) --- quartz.config.ts | 14 +- quartz.lock.json | 30 ++++ quartz/components/Head.tsx | 2 +- quartz/plugins/emitters/aliases.ts | 55 ------- quartz/plugins/emitters/cname.ts | 34 ----- quartz/plugins/emitters/contentIndex.tsx | 174 ---------------------- quartz/plugins/emitters/favicon.ts | 22 --- quartz/plugins/emitters/index.ts | 5 - quartz/plugins/emitters/ogImage.tsx | 182 ----------------------- quartz/util/fileTrie.ts | 2 +- 10 files changed, 42 insertions(+), 478 deletions(-) delete mode 100644 quartz/plugins/emitters/aliases.ts delete mode 100644 quartz/plugins/emitters/cname.ts delete mode 100644 quartz/plugins/emitters/contentIndex.tsx delete mode 100644 quartz/plugins/emitters/favicon.ts delete mode 100644 quartz/plugins/emitters/ogImage.tsx diff --git a/quartz.config.ts b/quartz.config.ts index 46aff1b7a..a8410ddd5 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -72,20 +72,21 @@ const config: QuartzConfig = { ], filters: [ExternalPlugin.RemoveDrafts()], emitters: [ - Plugin.AliasRedirects(), + ExternalPlugin.AliasRedirects(), Plugin.ComponentResources(), - Plugin.ContentIndex({ + ExternalPlugin.ContentIndex({ enableSiteMap: true, enableRSS: true, }), Plugin.Assets(), Plugin.Static(), - Plugin.Favicon(), + ExternalPlugin.Favicon(), Plugin.PageTypes.PageTypeDispatcher({ defaults: layout.defaults, byPageType: layout.byPageType, }), - Plugin.CustomOgImages(), + ExternalPlugin.CustomOgImages(), + ExternalPlugin.CNAME(), ], pageTypes: [ ExternalPlugin.ContentPage(), @@ -124,6 +125,11 @@ const config: QuartzConfig = { "github:quartz-community/roam", "github:quartz-community/remove-draft", "github:quartz-community/explicit-publish", + "github:quartz-community/alias-redirects", + "github:quartz-community/cname", + "github:quartz-community/favicon", + "github:quartz-community/content-index", + "github:quartz-community/og-image", ], } diff --git a/quartz.lock.json b/quartz.lock.json index 1e0f3597a..0e73a7b76 100644 --- a/quartz.lock.json +++ b/quartz.lock.json @@ -186,6 +186,36 @@ "resolved": "https://github.com/quartz-community/explicit-publish.git", "commit": "f870ac6a13d297c0f4451986c7f7042f006c3b38", "installedAt": "2026-02-13T22:28:08.774Z" + }, + "alias-redirects": { + "source": "github:quartz-community/alias-redirects", + "resolved": "https://github.com/quartz-community/alias-redirects.git", + "commit": "e890973e16a4365a989306ccc9fb9c4591128897", + "installedAt": "2026-02-13T22:53:31.585Z" + }, + "cname": { + "source": "github:quartz-community/cname", + "resolved": "https://github.com/quartz-community/cname.git", + "commit": "1d2cf8aba20e1687c9e54fc40ce20048e55648f5", + "installedAt": "2026-02-13T22:53:45.670Z" + }, + "favicon": { + "source": "github:quartz-community/favicon", + "resolved": "https://github.com/quartz-community/favicon.git", + "commit": "319c6ac41e56c63f2d2f503c78fc18e59e70bd8d", + "installedAt": "2026-02-13T22:53:55.256Z" + }, + "content-index": { + "source": "github:quartz-community/content-index", + "resolved": "https://github.com/quartz-community/content-index.git", + "commit": "2eb7bfd0dc1e6a625542eadb470009e77990b056", + "installedAt": "2026-02-13T22:59:01.330Z" + }, + "og-image": { + "source": "github:quartz-community/og-image", + "resolved": "https://github.com/quartz-community/og-image.git", + "commit": "d68d1fba330d97661c3b4a5df73149c4e20e7c56", + "installedAt": "2026-02-13T22:59:01.745Z" } } } diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 23183ca8c..0c1a20468 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -4,7 +4,7 @@ import { CSSResourceToStyleElement, JSResourceToScriptElement } from "../util/re import { googleFontHref, googleFontSubsetHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { unescapeHTML } from "../util/escape" -import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage" +import { CustomOgImagesEmitterName } from "../../.quartz/plugins" export default (() => { const Head: QuartzComponent = ({ cfg, diff --git a/quartz/plugins/emitters/aliases.ts b/quartz/plugins/emitters/aliases.ts deleted file mode 100644 index 9cb9bd576..000000000 --- a/quartz/plugins/emitters/aliases.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FullSlug, isRelativeURL, resolveRelative, simplifySlug } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import { write } from "./helpers" -import { BuildCtx } from "../../util/ctx" -import { VFile } from "vfile" -import path from "path" - -async function* processFile(ctx: BuildCtx, file: VFile) { - const ogSlug = simplifySlug(file.data.slug!) - - for (const aliasTarget of file.data.aliases ?? []) { - const aliasTargetSlug = ( - isRelativeURL(aliasTarget) - ? path.normalize(path.join(ogSlug, "..", aliasTarget)) - : aliasTarget - ) as FullSlug - - const redirUrl = resolveRelative(aliasTargetSlug, ogSlug) - yield write({ - ctx, - content: ` - - - - ${ogSlug} - - - - - - - `, - slug: aliasTargetSlug, - ext: ".html", - }) - } -} - -export const AliasRedirects: QuartzEmitterPlugin = () => ({ - name: "AliasRedirects", - async *emit(ctx, content) { - for (const [_tree, file] of content) { - yield* processFile(ctx, file) - } - }, - async *partialEmit(ctx, _content, _resources, changeEvents) { - for (const changeEvent of changeEvents) { - if (!changeEvent.file) continue - if (changeEvent.type === "add" || changeEvent.type === "change") { - // add new ones if this file still exists - yield* processFile(ctx, changeEvent.file) - } - } - }, -}) diff --git a/quartz/plugins/emitters/cname.ts b/quartz/plugins/emitters/cname.ts deleted file mode 100644 index 64fdbab68..000000000 --- a/quartz/plugins/emitters/cname.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { QuartzEmitterPlugin } from "../types" -import { write } from "./helpers" -import { styleText } from "util" -import { FullSlug } from "../../util/path" - -export function extractDomainFromBaseUrl(baseUrl: string) { - const url = new URL(`https://${baseUrl}`) - return url.hostname -} - -export const CNAME: QuartzEmitterPlugin = () => ({ - name: "CNAME", - async emit(ctx) { - if (!ctx.cfg.configuration.baseUrl) { - console.warn( - styleText("yellow", "CNAME emitter requires `baseUrl` to be set in your configuration"), - ) - return [] - } - const content = extractDomainFromBaseUrl(ctx.cfg.configuration.baseUrl) - if (!content) { - return [] - } - - const path = await write({ - ctx, - content, - slug: "CNAME" as FullSlug, - ext: "", - }) - return [path] - }, - async *partialEmit() {}, -}) diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx deleted file mode 100644 index 56392b358..000000000 --- a/quartz/plugins/emitters/contentIndex.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Root } from "hast" -import { GlobalConfiguration } from "../../cfg" -import { getDate } from "../../components/Date" -import { escapeHTML } from "../../util/escape" -import { FilePath, FullSlug, SimpleSlug, joinSegments, simplifySlug } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import { toHtml } from "hast-util-to-html" -import { write } from "./helpers" -import { i18n } from "../../i18n" - -export type ContentIndexMap = Map -export type ContentDetails = { - slug: FullSlug - filePath: FilePath - title: string - links: SimpleSlug[] - tags: string[] - content: string - richContent?: string - date?: Date - description?: string -} - -interface Options { - enableSiteMap: boolean - enableRSS: boolean - rssLimit?: number - rssFullHtml: boolean - rssSlug: string - includeEmptyFiles: boolean -} - -const defaultOptions: Options = { - enableSiteMap: true, - enableRSS: true, - rssLimit: 10, - rssFullHtml: false, - rssSlug: "index", - includeEmptyFiles: true, -} - -function generateSiteMap(cfg: GlobalConfiguration, idx: ContentIndexMap): string { - const base = cfg.baseUrl ?? "" - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - https://${joinSegments(base, encodeURI(slug))} - ${content.date && `${content.date.toISOString()}`} - ` - const urls = Array.from(idx) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .join("") - return `${urls}` -} - -function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string { - const base = cfg.baseUrl ?? "" - - const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => ` - ${escapeHTML(content.title)} - https://${joinSegments(base, encodeURI(slug))} - https://${joinSegments(base, encodeURI(slug))} - - ${content.date?.toUTCString()} - ` - - const items = Array.from(idx) - .sort(([_, f1], [__, f2]) => { - if (f1.date && f2.date) { - return f2.date.getTime() - f1.date.getTime() - } else if (f1.date && !f2.date) { - return -1 - } else if (!f1.date && f2.date) { - return 1 - } - - return f1.title.localeCompare(f2.title) - }) - .map(([slug, content]) => createURLEntry(simplifySlug(slug), content)) - .slice(0, limit ?? idx.size) - .join("") - - return ` - - - ${escapeHTML(cfg.pageTitle)} - https://${base} - ${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML( - cfg.pageTitle, - )} - Quartz -- quartz.jzhao.xyz - ${items} - - ` -} - -export const ContentIndex: QuartzEmitterPlugin> = (opts) => { - opts = { ...defaultOptions, ...opts } - return { - name: "ContentIndex", - async *emit(ctx, content) { - const cfg = ctx.cfg.configuration - const linkIndex: ContentIndexMap = new Map() - for (const [tree, file] of content) { - const slug = file.data.slug! - const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() - if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { - linkIndex.set(slug, { - slug, - filePath: file.data.relativePath!, - title: file.data.frontmatter?.title!, - links: file.data.links ?? [], - tags: file.data.frontmatter?.tags ?? [], - content: file.data.text ?? "", - richContent: opts?.rssFullHtml - ? escapeHTML(toHtml(tree as Root, { allowDangerousHtml: true })) - : undefined, - date: date, - description: file.data.description ?? "", - }) - } - } - - if (opts?.enableSiteMap) { - yield write({ - ctx, - content: generateSiteMap(cfg, linkIndex), - slug: "sitemap" as FullSlug, - ext: ".xml", - }) - } - - if (opts?.enableRSS) { - yield write({ - ctx, - content: generateRSSFeed(cfg, linkIndex, opts.rssLimit), - slug: (opts?.rssSlug ?? "index") as FullSlug, - ext: ".xml", - }) - } - - const fp = joinSegments("static", "contentIndex") as FullSlug - const simplifiedIndex = Object.fromEntries( - Array.from(linkIndex).map(([slug, content]) => { - // remove description and from content index as nothing downstream - // actually uses it. we only keep it in the index as we need it - // for the RSS feed - delete content.description - delete content.date - return [slug, content] - }), - ) - - yield write({ - ctx, - content: JSON.stringify(simplifiedIndex), - slug: fp, - ext: ".json", - }) - }, - externalResources: (ctx) => { - if (opts?.enableRSS) { - return { - additionalHead: [ - , - ], - } - } - }, - } -} diff --git a/quartz/plugins/emitters/favicon.ts b/quartz/plugins/emitters/favicon.ts deleted file mode 100644 index b05f9309d..000000000 --- a/quartz/plugins/emitters/favicon.ts +++ /dev/null @@ -1,22 +0,0 @@ -import sharp from "sharp" -import { joinSegments, QUARTZ, FullSlug } from "../../util/path" -import { QuartzEmitterPlugin } from "../types" -import { write } from "./helpers" -import { BuildCtx } from "../../util/ctx" - -export const Favicon: QuartzEmitterPlugin = () => ({ - name: "Favicon", - async *emit({ argv }) { - const iconPath = joinSegments(QUARTZ, "static", "icon.png") - - const faviconContent = sharp(iconPath).resize(48, 48).toFormat("png") - - yield write({ - ctx: { argv } as BuildCtx, - slug: "favicon" as FullSlug, - ext: ".ico", - content: faviconContent, - }) - }, - async *partialEmit() {}, -}) diff --git a/quartz/plugins/emitters/index.ts b/quartz/plugins/emitters/index.ts index 08fee89ed..bba0892b8 100644 --- a/quartz/plugins/emitters/index.ts +++ b/quartz/plugins/emitters/index.ts @@ -1,8 +1,3 @@ -export { ContentIndex as ContentIndex } from "./contentIndex" -export { AliasRedirects } from "./aliases" export { Assets } from "./assets" export { Static } from "./static" -export { Favicon } from "./favicon" export { ComponentResources } from "./componentResources" -export { CNAME } from "./cname" -export { CustomOgImages } from "./ogImage" diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx deleted file mode 100644 index 813d9348c..000000000 --- a/quartz/plugins/emitters/ogImage.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { QuartzEmitterPlugin } from "../types" -import { i18n } from "../../i18n" -import { unescapeHTML } from "../../util/escape" -import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path" -import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og" -import sharp from "sharp" -import satori, { SatoriOptions } from "satori" -import { loadEmoji, getIconCode } from "../../util/emoji" -import { Readable } from "stream" -import { write } from "./helpers" -import { BuildCtx } from "../../util/ctx" -import { QuartzPluginData } from "../vfile" -import fs from "node:fs/promises" -import { styleText } from "util" - -const defaultOptions: SocialImageOptions = { - colorScheme: "lightMode", - width: 1200, - height: 630, - imageStructure: defaultImage, - excludeRoot: false, -} - -/** - * Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder - * @param opts options for generating image - */ -async function generateSocialImage( - { cfg, description, fonts, title, fileData }: ImageOptions, - userOpts: SocialImageOptions, -): Promise { - const { width, height } = userOpts - const iconPath = joinSegments(QUARTZ, "static", "icon.png") - let iconBase64: string | undefined = undefined - try { - const iconData = await fs.readFile(iconPath) - iconBase64 = `data:image/png;base64,${iconData.toString("base64")}` - } catch (err) { - console.warn(styleText("yellow", `Warning: Could not find icon at ${iconPath}`)) - } - - const imageComponent = userOpts.imageStructure({ - cfg, - userOpts, - title, - description, - fonts, - fileData, - iconBase64, - }) - - const svg = await satori(imageComponent, { - width, - height, - fonts, - loadAdditionalAsset: async (languageCode: string, segment: string) => { - if (languageCode === "emoji") { - return await loadEmoji(getIconCode(segment)) - } - - return languageCode - }, - }) - - return sharp(Buffer.from(svg)).webp({ quality: 40 }) -} - -async function processOgImage( - ctx: BuildCtx, - fileData: QuartzPluginData, - fonts: SatoriOptions["fonts"], - fullOptions: SocialImageOptions, -) { - const cfg = ctx.cfg.configuration - const slug = fileData.slug! - const titleSuffix = cfg.pageTitleSuffix ?? "" - const title = - (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix - const description = - fileData.frontmatter?.socialDescription ?? - fileData.frontmatter?.description ?? - unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) - - const stream = await generateSocialImage( - { - title, - description, - fonts, - cfg, - fileData, - }, - fullOptions, - ) - - return write({ - ctx, - content: stream, - slug: `${slug}-og-image` as FullSlug, - ext: ".webp", - }) -} - -export const CustomOgImagesEmitterName = "CustomOgImages" -export const CustomOgImages: QuartzEmitterPlugin> = (userOpts) => { - const fullOptions = { ...defaultOptions, ...userOpts } - - return { - name: CustomOgImagesEmitterName, - getQuartzComponents() { - return [] - }, - async *emit(ctx, content, _resources) { - const cfg = ctx.cfg.configuration - const headerFont = cfg.theme.typography.header - const bodyFont = cfg.theme.typography.body - const fonts = await getSatoriFonts(headerFont, bodyFont) - - for (const [_tree, vfile] of content) { - if (vfile.data.frontmatter?.socialImage !== undefined) continue - yield processOgImage(ctx, vfile.data, fonts, fullOptions) - } - }, - async *partialEmit(ctx, _content, _resources, changeEvents) { - const cfg = ctx.cfg.configuration - const headerFont = cfg.theme.typography.header - const bodyFont = cfg.theme.typography.body - const fonts = await getSatoriFonts(headerFont, bodyFont) - - // find all slugs that changed or were added - for (const changeEvent of changeEvents) { - if (!changeEvent.file) continue - if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue - if (changeEvent.type === "add" || changeEvent.type === "change") { - yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions) - } - } - }, - externalResources: (ctx) => { - if (!ctx.cfg.configuration.baseUrl) { - return {} - } - - const baseUrl = ctx.cfg.configuration.baseUrl - return { - additionalHead: [ - (pageData) => { - const isRealFile = pageData.filePath !== undefined - let userDefinedOgImagePath = pageData.frontmatter?.socialImage - - if (userDefinedOgImagePath) { - userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath) - ? userDefinedOgImagePath - : `https://${baseUrl}/static/${userDefinedOgImagePath}` - } - - const generatedOgImagePath = isRealFile - ? `https://${baseUrl}/${pageData.slug!}-og-image.webp` - : undefined - const defaultOgImagePath = `https://${baseUrl}/static/og-image.png` - const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath - const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}` - return ( - <> - {!userDefinedOgImagePath && ( - <> - - - - )} - - - - - - - ) - }, - ], - } - }, - } -} diff --git a/quartz/util/fileTrie.ts b/quartz/util/fileTrie.ts index 9e6706f4a..f4a4e789b 100644 --- a/quartz/util/fileTrie.ts +++ b/quartz/util/fileTrie.ts @@ -1,4 +1,4 @@ -import { ContentDetails } from "../plugins/emitters/contentIndex" +import { ContentDetails } from "../../.quartz/plugins" import { FullSlug, joinSegments } from "./path" interface FileTrieData {