1
0

Initial commit

This commit is contained in:
2025-10-13 16:35:40 +02:00
commit 18a6234db4
1081 changed files with 50261 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { FullSlug } from "../../util/path"
import { sharedPageComponents } from "../../../quartz.layout"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const NotFoundPage: QuartzEmitterPlugin = () => {
const opts: FullPageLayout = {
...sharedPageComponents,
pageBody: NotFound(),
beforeBody: [],
left: [],
right: [],
}
const { head: Head, pageBody, footer: Footer } = opts
const Body = BodyConstructor()
return {
name: "404Page",
getQuartzComponents() {
return [Head, Body, pageBody, Footer]
},
async *emit(ctx, _content, resources) {
const cfg = ctx.cfg.configuration
const slug = "404" as FullSlug
const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
const path = url.pathname as FullSlug
const notFound = i18n(cfg.locale).pages.error.title
const [tree, vfile] = defaultProcessedContent({
slug,
text: notFound,
description: notFound,
frontmatter: { title: notFound, tags: [] },
})
const externalResources = pageResources(path, resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: vfile.data,
externalResources,
cfg,
children: [],
tree,
allFiles: [],
}
yield write({
ctx,
content: renderPage(cfg, slug, componentData, opts, externalResources),
slug,
ext: ".html",
})
},
async *partialEmit() {},
}
}

View File

@@ -0,0 +1,55 @@
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: `
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>${ogSlug}</title>
<link rel="canonical" href="${redirUrl}">
<meta name="robots" content="noindex">
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${redirUrl}">
</head>
</html>
`,
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)
}
}
},
})

View File

@@ -0,0 +1,52 @@
import { FilePath, joinSegments, slugifyFilePath } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import path from "path"
import fs from "fs"
import { glob } from "../../util/glob"
import { Argv } from "../../util/ctx"
import { QuartzConfig } from "../../cfg"
const filesToCopy = async (argv: Argv, cfg: QuartzConfig) => {
// glob all non MD files in content folder and copy it over
return await glob("**", argv.directory, ["**/*.md", ...cfg.configuration.ignorePatterns])
}
const copyFile = async (argv: Argv, fp: FilePath) => {
const src = joinSegments(argv.directory, fp) as FilePath
const name = slugifyFilePath(fp)
const dest = joinSegments(argv.output, name) as FilePath
// ensure dir exists
const dir = path.dirname(dest) as FilePath
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.copyFile(src, dest)
return dest
}
export const Assets: QuartzEmitterPlugin = () => {
return {
name: "Assets",
async *emit({ argv, cfg }) {
const fps = await filesToCopy(argv, cfg)
for (const fp of fps) {
yield copyFile(argv, fp)
}
},
async *partialEmit(ctx, _content, _resources, changeEvents) {
for (const changeEvent of changeEvents) {
const ext = path.extname(changeEvent.path)
if (ext === ".md") continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
yield copyFile(ctx.argv, changeEvent.path)
} else if (changeEvent.type === "delete") {
const name = slugifyFilePath(changeEvent.path)
const dest = joinSegments(ctx.argv.output, name) as FilePath
await fs.promises.unlink(dest)
}
}
},
}
}

View File

@@ -0,0 +1,34 @@
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() {},
})

View File

@@ -0,0 +1,363 @@
import { FullSlug, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
// @ts-ignore
import spaRouterScript from "../../components/scripts/spa.inline"
// @ts-ignore
import popoverScript from "../../components/scripts/popover.inline"
import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types"
import {
googleFontHref,
googleFontSubsetHref,
joinStyles,
processGoogleFonts,
} from "../../util/theme"
import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild"
import { write } from "./helpers"
type ComponentResources = {
css: string[]
beforeDOMLoaded: string[]
afterDOMLoaded: string[]
}
function getComponentResources(ctx: BuildCtx): ComponentResources {
const allComponents: Set<QuartzComponent> = new Set()
for (const emitter of ctx.cfg.plugins.emitters) {
const components = emitter.getQuartzComponents?.(ctx) ?? []
for (const component of components) {
allComponents.add(component)
}
}
const componentResources = {
css: new Set<string>(),
beforeDOMLoaded: new Set<string>(),
afterDOMLoaded: new Set<string>(),
}
function normalizeResource(resource: string | string[] | undefined): string[] {
if (!resource) return []
if (Array.isArray(resource)) return resource
return [resource]
}
for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component
const normalizedCss = normalizeResource(css)
const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded)
const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded)
normalizedCss.forEach((c) => componentResources.css.add(c))
normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b))
normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a))
}
return {
css: [...componentResources.css],
beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
afterDOMLoaded: [...componentResources.afterDOMLoaded],
}
}
async function joinScripts(scripts: string[]): Promise<string> {
// wrap with iife to prevent scope collision
const script = scripts.map((script) => `(function () {${script}})();`).join("\n")
// minify with esbuild
const res = await transpile(script, {
minify: true,
})
return res.code
}
function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentResources) {
const cfg = ctx.cfg.configuration
// popovers
if (cfg.enablePopovers) {
componentResources.afterDOMLoaded.push(popoverScript)
componentResources.css.push(popoverStyle)
}
if (cfg.analytics?.provider === "google") {
const tagId = cfg.analytics.tagId
componentResources.afterDOMLoaded.push(`
const gtagScript = document.createElement('script');
gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}';
gtagScript.defer = true;
gtagScript.onload = () => {
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', '${tagId}', { send_page_view: false });
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
document.addEventListener('nav', () => {
gtag('event', 'page_view', { page_title: document.title, page_location: location.href });
});
};
document.head.appendChild(gtagScript);
`)
} else if (cfg.analytics?.provider === "plausible") {
const plausibleHost = cfg.analytics.host ?? "https://plausible.io"
componentResources.afterDOMLoaded.push(`
const plausibleScript = document.createElement('script');
plausibleScript.src = '${plausibleHost}/js/script.manual.js';
plausibleScript.setAttribute('data-domain', location.hostname);
plausibleScript.defer = true;
plausibleScript.onload = () => {
window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); };
plausible('pageview');
document.addEventListener('nav', () => {
plausible('pageview');
});
};
document.head.appendChild(plausibleScript);
`)
} 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.setAttribute("data-website-id", "${cfg.analytics.websiteId}");
umamiScript.setAttribute("data-auto-track", "true");
umamiScript.defer = true;
document.head.appendChild(umamiScript);
`)
} else if (cfg.analytics?.provider === "goatcounter") {
componentResources.afterDOMLoaded.push(`
const goatcounterScriptPre = document.createElement('script');
goatcounterScriptPre.textContent = \`
window.goatcounter = { no_onload: true };
\`;
document.head.appendChild(goatcounterScriptPre);
const endpoint = "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count";
const goatcounterScript = document.createElement('script');
goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}";
goatcounterScript.defer = true;
goatcounterScript.setAttribute('data-goatcounter', endpoint);
goatcounterScript.onload = () => {
window.goatcounter.endpoint = endpoint;
goatcounter.count({ path: location.pathname });
document.addEventListener('nav', () => {
goatcounter.count({ path: location.pathname });
});
};
document.head.appendChild(goatcounterScript);
`)
} else if (cfg.analytics?.provider === "posthog") {
componentResources.afterDOMLoaded.push(`
const posthogScript = document.createElement("script");
posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${cfg.analytics.apiKey}', {
api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}',
capture_pageview: false,
});
document.addEventListener('nav', () => {
posthog.capture('$pageview', { path: location.pathname });
})\`
document.head.appendChild(posthogScript);
`)
} else if (cfg.analytics?.provider === "tinylytics") {
const siteId = cfg.analytics.siteId
componentResources.afterDOMLoaded.push(`
const tinylyticsScript = document.createElement('script');
tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa';
tinylyticsScript.defer = true;
tinylyticsScript.onload = () => {
window.tinylytics.triggerUpdate();
document.addEventListener('nav', () => {
window.tinylytics.triggerUpdate();
});
};
document.head.appendChild(tinylyticsScript);
`)
} else if (cfg.analytics?.provider === "cabin") {
componentResources.afterDOMLoaded.push(`
const cabinScript = document.createElement("script")
cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js"
cabinScript.defer = true
document.head.appendChild(cabinScript)
`)
} else if (cfg.analytics?.provider === "clarity") {
componentResources.afterDOMLoaded.push(`
const clarityScript = document.createElement("script")
clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "${cfg.analytics.projectId}");\`
document.head.appendChild(clarityScript)
`)
} else if (cfg.analytics?.provider === "matomo") {
componentResources.afterDOMLoaded.push(`
const matomoScript = document.createElement("script");
matomoScript.innerHTML = \`
let _paq = window._paq = window._paq || [];
// Track SPA navigation
// https://developer.matomo.org/guides/spa-tracking
document.addEventListener("nav", () => {
_paq.push(['setCustomUrl', location.pathname]);
_paq.push(['setDocumentTitle', document.title]);
_paq.push(['trackPageView']);
});
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
const u="//${cfg.analytics.host}/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', ${cfg.analytics.siteId}]);
const d=document, g=d.createElement('script'), s=d.getElementsByTagName
('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
\`
document.head.appendChild(matomoScript);
`)
} else if (cfg.analytics?.provider === "vercel") {
/**
* script from {@link https://vercel.com/docs/analytics/quickstart?framework=html#add-the-script-tag-to-your-site|Vercel Docs}
*/
componentResources.beforeDOMLoaded.push(`
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
`)
componentResources.afterDOMLoaded.push(`
const vercelInsightsScript = document.createElement("script")
vercelInsightsScript.src = "/_vercel/insights/script.js"
vercelInsightsScript.defer = true
document.head.appendChild(vercelInsightsScript)
`)
}
if (cfg.enableSPA) {
componentResources.afterDOMLoaded.push(spaRouterScript)
} else {
componentResources.afterDOMLoaded.push(`
window.spaNavigate = (url, _) => window.location.assign(url)
window.addCleanup = () => {}
const event = new CustomEvent("nav", { detail: { url: document.body.dataset.slug } })
document.dispatchEvent(event)
`)
}
}
// This emitter should not update the `resources` parameter. If it does, partial
// rebuilds may not work as expected.
export const ComponentResources: QuartzEmitterPlugin = () => {
return {
name: "ComponentResources",
async *emit(ctx, _content, _resources) {
const cfg = ctx.cfg.configuration
// component specific scripts and styles
const componentResources = getComponentResources(ctx)
let googleFontsStyleSheet = ""
if (cfg.theme.fontOrigin === "local") {
// let the user do it themselves in css
} else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) {
// when cdnCaching is true, we link to google fonts in Head.tsx
const theme = ctx.cfg.configuration.theme
const response = await fetch(googleFontHref(theme))
googleFontsStyleSheet = await response.text()
if (theme.typography.title) {
const title = ctx.cfg.configuration.pageTitle
const response = await fetch(googleFontSubsetHref(theme, title))
googleFontsStyleSheet += `\n${await response.text()}`
}
if (!cfg.baseUrl) {
throw new Error(
"baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching",
)
}
const { processedStylesheet, fontFiles } = await processGoogleFonts(
googleFontsStyleSheet,
cfg.baseUrl,
)
googleFontsStyleSheet = processedStylesheet
// Download and save font files
for (const fontFile of fontFiles) {
const res = await fetch(fontFile.url)
if (!res.ok) {
throw new Error(`Failed to fetch font ${fontFile.filename}`)
}
const buf = await res.arrayBuffer()
yield write({
ctx,
slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug,
ext: `.${fontFile.extension}`,
content: Buffer.from(buf),
})
}
}
// 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
addGlobalPageResources(ctx, componentResources)
const stylesheet = joinStyles(
ctx.cfg.configuration.theme,
googleFontsStyleSheet,
...componentResources.css,
styles,
)
const [prescript, postscript] = await Promise.all([
joinScripts(componentResources.beforeDOMLoaded),
joinScripts(componentResources.afterDOMLoaded),
])
yield write({
ctx,
slug: "index" as FullSlug,
ext: ".css",
content: transform({
filename: "index.css",
code: Buffer.from(stylesheet),
minify: true,
targets: {
safari: (15 << 16) | (6 << 8), // 15.6
ios_saf: (15 << 16) | (6 << 8), // 15.6
edge: 115 << 16,
firefox: 102 << 16,
chrome: 109 << 16,
},
include: Features.MediaQueries,
}).code.toString(),
})
yield write({
ctx,
slug: "prescript" as FullSlug,
ext: ".js",
content: prescript,
})
yield write({
ctx,
slug: "postscript" as FullSlug,
ext: ".js",
content: postscript,
})
},
async *partialEmit() {},
}
}

View File

@@ -0,0 +1,174 @@
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<FullSlug, ContentDetails>
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 => `<url>
<loc>https://${joinSegments(base, encodeURI(slug))}</loc>
${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
</url>`
const urls = Array.from(idx)
.map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
.join("")
return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}</urlset>`
}
function generateRSSFeed(cfg: GlobalConfiguration, idx: ContentIndexMap, limit?: number): string {
const base = cfg.baseUrl ?? ""
const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<item>
<title>${escapeHTML(content.title)}</title>
<link>https://${joinSegments(base, encodeURI(slug))}</link>
<guid>https://${joinSegments(base, encodeURI(slug))}</guid>
<description><![CDATA[ ${content.richContent ?? content.description} ]]></description>
<pubDate>${content.date?.toUTCString()}</pubDate>
</item>`
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 `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>${escapeHTML(cfg.pageTitle)}</title>
<link>https://${base}</link>
<description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
cfg.pageTitle,
)}</description>
<generator>Quartz -- quartz.jzhao.xyz</generator>
${items}
</channel>
</rss>`
}
export const ContentIndex: QuartzEmitterPlugin<Partial<Options>> = (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: [
<link
rel="alternate"
type="application/rss+xml"
title="RSS Feed"
href={`https://${ctx.cfg.configuration.baseUrl}/index.xml`}
/>,
],
}
}
},
}
}

View File

@@ -0,0 +1,121 @@
import path from "path"
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { pathToRoot } from "../../util/path"
import { defaultContentPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { Content } from "../../components"
import { styleText } from "util"
import { write } from "./helpers"
import { BuildCtx } from "../../util/ctx"
import { Node } from "unist"
import { StaticResources } from "../../util/resources"
import { QuartzPluginData } from "../vfile"
async function processContent(
ctx: BuildCtx,
tree: Node,
fileData: QuartzPluginData,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
const slug = fileData.slug!
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
return write({
ctx,
content,
slug,
ext: ".html",
})
}
export const ContentPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultContentPageLayout,
pageBody: Content(),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "ContentPage",
getQuartzComponents() {
return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
},
async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data)
let containsIndex = false
for (const [tree, file] of content) {
const slug = file.data.slug!
if (slug === "index") {
containsIndex = true
}
// only process home page, non-tag pages, and non-index pages
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
}
if (!containsIndex) {
console.log(
styleText(
"yellow",
`\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.`,
),
)
}
},
async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data)
// find all slugs that changed or were added
const changedSlugs = new Set<string>()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
changedSlugs.add(changeEvent.file.data.slug!)
}
}
for (const [tree, file] of content) {
const slug = file.data.slug!
if (!changedSlugs.has(slug)) continue
if (slug.endsWith("/index") || slug.startsWith("tags/")) continue
yield processContent(ctx, tree, file.data, allFiles, opts, resources)
}
},
}
}

View File

@@ -0,0 +1,22 @@
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() {},
})

View File

@@ -0,0 +1,170 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg"
import path from "path"
import {
FullSlug,
SimpleSlug,
stripSlashes,
joinSegments,
pathToRoot,
simplifySlug,
} from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
interface FolderPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
}
async function* processFolderInfo(
ctx: BuildCtx,
folderInfo: Record<SimpleSlug, ProcessedContent>,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
for (const [folder, folderContent] of Object.entries(folderInfo) as [
SimpleSlug,
ProcessedContent,
][]) {
const slug = joinSegments(folder, "index") as FullSlug
const [tree, file] = folderContent
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
yield write({
ctx,
content,
slug,
ext: ".html",
})
}
}
function computeFolderInfo(
folders: Set<SimpleSlug>,
content: ProcessedContent[],
locale: keyof typeof TRANSLATIONS,
): Record<SimpleSlug, ProcessedContent> {
// Create default folder descriptions
const folderInfo: Record<SimpleSlug, ProcessedContent> = Object.fromEntries(
[...folders].map((folder) => [
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
frontmatter: {
title: `${i18n(locale).pages.folderContent.folder}: ${folder}`,
tags: [],
},
}),
]),
)
// Update with actual content if available
for (const [tree, file] of content) {
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
folderInfo[slug] = [tree, file]
}
}
return folderInfo
}
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}
export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
pageBody: FolderContent({ sort: userOpts?.sort }),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "FolderPage",
getQuartzComponents() {
return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
},
async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => {
return data.slug
? _getFolders(data.slug).filter(
(folderName) => folderName !== "." && folderName !== "tags",
)
: []
}),
)
const folderInfo = computeFolderInfo(folders, content, cfg.locale)
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
},
async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
// Find all folders that need to be updated based on changed files
const affectedFolders: Set<SimpleSlug> = new Set()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
const slug = changeEvent.file.data.slug!
const folders = _getFolders(slug).filter(
(folderName) => folderName !== "." && folderName !== "tags",
)
folders.forEach((folder) => affectedFolders.add(folder))
}
// If there are affected folders, rebuild their pages
if (affectedFolders.size > 0) {
const folderInfo = computeFolderInfo(affectedFolders, content, cfg.locale)
yield* processFolderInfo(ctx, folderInfo, allFiles, opts, resources)
}
},
}
}

View File

@@ -0,0 +1,20 @@
import path from "path"
import fs from "fs"
import { BuildCtx } from "../../util/ctx"
import { FilePath, FullSlug, joinSegments } from "../../util/path"
import { Readable } from "stream"
type WriteOptions = {
ctx: BuildCtx
slug: FullSlug
ext: `.${string}` | ""
content: string | Buffer | Readable
}
export const write = async ({ ctx, slug, ext, content }: WriteOptions): Promise<FilePath> => {
const pathToPage = joinSegments(ctx.argv.output, slug + ext) as FilePath
const dir = path.dirname(pathToPage)
await fs.promises.mkdir(dir, { recursive: true })
await fs.promises.writeFile(pathToPage, content)
return pathToPage
}

View File

@@ -0,0 +1,12 @@
export { ContentPage } from "./contentPage"
export { TagPage } from "./tagPage"
export { FolderPage } from "./folderPage"
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 { NotFoundPage } from "./404"
export { CNAME } from "./cname"
export { CustomOgImages } from "./ogImage"

View File

@@ -0,0 +1,182 @@
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<Readable> {
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<Partial<SocialImageOptions>> = (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 && (
<>
<meta property="og:image:width" content={fullOptions.width.toString()} />
<meta property="og:image:height" content={fullOptions.height.toString()} />
</>
)}
<meta property="og:image" content={ogImagePath} />
<meta property="og:image:url" content={ogImagePath} />
<meta name="twitter:image" content={ogImagePath} />
<meta property="og:image:type" content={ogImageMimeType} />
</>
)
},
],
}
},
}
}

View File

@@ -0,0 +1,23 @@
import { FilePath, QUARTZ, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types"
import fs from "fs"
import { glob } from "../../util/glob"
import { dirname } from "path"
export const Static: QuartzEmitterPlugin = () => ({
name: "Static",
async *emit({ argv, cfg }) {
const staticPath = joinSegments(QUARTZ, "static")
const fps = await glob("**", staticPath, cfg.configuration.ignorePatterns)
const outputStaticPath = joinSegments(argv.output, "static")
await fs.promises.mkdir(outputStaticPath, { recursive: true })
for (const fp of fps) {
const src = joinSegments(staticPath, fp) as FilePath
const dest = joinSegments(outputStaticPath, fp) as FilePath
await fs.promises.mkdir(dirname(dest), { recursive: true })
await fs.promises.copyFile(src, dest)
yield dest
}
},
async *partialEmit() {},
})

View File

@@ -0,0 +1,170 @@
import { QuartzEmitterPlugin } from "../types"
import { QuartzComponentProps } from "../../components/types"
import HeaderConstructor from "../../components/Header"
import BodyConstructor from "../../components/Body"
import { pageResources, renderPage } from "../../components/renderPage"
import { ProcessedContent, QuartzPluginData, defaultProcessedContent } from "../vfile"
import { FullPageLayout } from "../../cfg"
import { FullSlug, getAllSegmentPrefixes, joinSegments, pathToRoot } from "../../util/path"
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
import { i18n, TRANSLATIONS } from "../../i18n"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
interface TagPageOptions extends FullPageLayout {
sort?: (f1: QuartzPluginData, f2: QuartzPluginData) => number
}
function computeTagInfo(
allFiles: QuartzPluginData[],
content: ProcessedContent[],
locale: keyof typeof TRANSLATIONS,
): [Set<string>, Record<string, ProcessedContent>] {
const tags: Set<string> = new Set(
allFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
// add base tag
tags.add("index")
const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
[...tags].map((tag) => {
const title =
tag === "index"
? i18n(locale).pages.tagContent.tagIndex
: `${i18n(locale).pages.tagContent.tag}: ${tag}`
return [
tag,
defaultProcessedContent({
slug: joinSegments("tags", tag) as FullSlug,
frontmatter: { title, tags: [] },
}),
]
}),
)
// Update with actual content if available
for (const [tree, file] of content) {
const slug = file.data.slug!
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
if (tags.has(tag)) {
tagDescriptions[tag] = [tree, file]
if (file.data.frontmatter?.title === tag) {
file.data.frontmatter.title = `${i18n(locale).pages.tagContent.tag}: ${tag}`
}
}
}
}
return [tags, tagDescriptions]
}
async function processTagPage(
ctx: BuildCtx,
tag: string,
tagContent: ProcessedContent,
allFiles: QuartzPluginData[],
opts: FullPageLayout,
resources: StaticResources,
) {
const slug = joinSegments("tags", tag) as FullSlug
const [tree, file] = tagContent
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData: file.data,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
const content = renderPage(cfg, slug, componentData, opts, externalResources)
return write({
ctx,
content,
slug: file.data.slug!,
ext: ".html",
})
}
export const TagPage: QuartzEmitterPlugin<Partial<TagPageOptions>> = (userOpts) => {
const opts: FullPageLayout = {
...sharedPageComponents,
...defaultListPageLayout,
pageBody: TagContent({ sort: userOpts?.sort }),
...userOpts,
}
const { head: Head, header, beforeBody, pageBody, afterBody, left, right, footer: Footer } = opts
const Header = HeaderConstructor()
const Body = BodyConstructor()
return {
name: "TagPage",
getQuartzComponents() {
return [
Head,
Header,
Body,
...header,
...beforeBody,
pageBody,
...afterBody,
...left,
...right,
Footer,
]
},
async *emit(ctx, content, resources) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
const [tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
for (const tag of tags) {
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
}
},
async *partialEmit(ctx, content, resources, changeEvents) {
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
// Find all tags that need to be updated based on changed files
const affectedTags: Set<string> = new Set()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
const slug = changeEvent.file.data.slug!
// If it's a tag page itself that changed
if (slug.startsWith("tags/")) {
const tag = slug.slice("tags/".length)
affectedTags.add(tag)
}
// If a file with tags changed, we need to update those tag pages
const fileTags = changeEvent.file.data.frontmatter?.tags ?? []
fileTags.flatMap(getAllSegmentPrefixes).forEach((tag) => affectedTags.add(tag))
// Always update the index tag page if any file changes
affectedTags.add("index")
}
// If there are affected tags, rebuild their pages
if (affectedTags.size > 0) {
// We still need to compute all tags because tag pages show all tags
const [_tags, tagDescriptions] = computeTagInfo(allFiles, content, cfg.locale)
for (const tag of affectedTags) {
if (tagDescriptions[tag]) {
yield processTagPage(ctx, tag, tagDescriptions[tag], allFiles, opts, resources)
}
}
}
},
}
}