diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index ce05c25..aad75a3 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -1,7 +1,7 @@ import { render } from "preact-render-to-string" import { QuartzComponent, QuartzComponentProps } from "./types" import BodyConstructor from "./Body" -import { JSResourceToScriptElement, StaticResources } from "../util/resources" +import { CSSResource, JSResourceToScriptElement, StaticResources } from "../util/resources" import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" import { clone } from "../util/clone" import { visit } from "unist-util-visit" @@ -11,6 +11,7 @@ import { i18n } from "../i18n" import { styleText } from "util" import { resolveFrame } from "./frames" import type { TreeTransform } from "../plugins/types" +import type { BuildCtx } from "../util/ctx" interface RenderComponents { head: QuartzComponent @@ -28,20 +29,37 @@ const headerRegex = new RegExp(/h[1-6]/) export function pageResources( baseDir: FullSlug | RelativeURL, staticResources: StaticResources, + ctx?: BuildCtx, ): StaticResources { + const hashedNames = ctx?.hashedResourceNames + const cssFile = hashedNames?.["index.css"] ?? "index.css" + const prescriptFile = hashedNames?.["prescript.js"] ?? "prescript.js" + const postscriptFile = hashedNames?.["postscript.js"] ?? "postscript.js" + + const componentCssResources: CSSResource[] = [] + if (ctx?.componentCssMap) { + const seen = new Set() + for (const filename of ctx.componentCssMap.values()) { + if (seen.has(filename)) continue + seen.add(filename) + componentCssResources.push({ content: joinSegments(baseDir, filename) }) + } + } + const contentIndexPath = joinSegments(baseDir, "static/contentIndex.json") const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` const resources: StaticResources = { css: [ { - content: joinSegments(baseDir, "index.css"), + content: joinSegments(baseDir, cssFile), }, + ...componentCssResources, ...staticResources.css, ], js: [ { - src: joinSegments(baseDir, "prescript.js"), + src: joinSegments(baseDir, prescriptFile), loadTime: "beforeDOMReady", contentType: "external", }, @@ -57,7 +75,7 @@ export function pageResources( } resources.js.push({ - src: joinSegments(baseDir, "postscript.js"), + src: joinSegments(baseDir, postscriptFile), loadTime: "afterDOMReady", moduleType: "module", contentType: "external", diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index c44e4d2..9a44496 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto" import { FullSlug, joinSegments } from "../../util/path" import { QuartzEmitterPlugin } from "../types" @@ -10,6 +11,7 @@ import customStyles from "../../styles/custom.scss" import popoverStyle from "../../components/styles/popover.scss" import { BuildCtx } from "../../util/ctx" import { QuartzComponent } from "../../components/types" +import { normalizeResource } from "../../util/resources" import { componentRegistry } from "../../components/registry" import { googleFontHref, @@ -21,10 +23,15 @@ import { Features, transform } from "lightningcss" import { transform as transpile } from "esbuild" import { write } from "./helpers" +function hashContent(content: string | Buffer): string { + return createHash("sha256").update(content).digest("hex").slice(0, 8) +} + type ComponentResources = { css: string[] beforeDOMLoaded: string[] afterDOMLoaded: string[] + componentCssStrings: Set } function getComponentResources(ctx: BuildCtx): ComponentResources { @@ -47,27 +54,18 @@ function getComponentResources(ctx: BuildCtx): ComponentResources { afterDOMLoaded: new Set(), } - 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)) + for (const c of normalizeResource(css)) componentResources.css.add(c) + for (const b of normalizeResource(beforeDOMLoaded)) componentResources.beforeDOMLoaded.add(b) + for (const a of normalizeResource(afterDOMLoaded)) componentResources.afterDOMLoaded.add(a) } return { css: [...componentResources.css], beforeDOMLoaded: [...componentResources.beforeDOMLoaded], afterDOMLoaded: [...componentResources.afterDOMLoaded], + componentCssStrings: new Set(componentResources.css), } } @@ -330,10 +328,18 @@ export const ComponentResources: QuartzEmitterPlugin = () => { // that everyone else had the chance to register a listener for it addGlobalPageResources(ctx, componentResources) + // Separate global CSS (added by addGlobalPageResources, e.g. popover CSS) + // from component CSS. Global CSS was pushed onto componentResources.css + // AFTER getComponentResources() returned, so it's not in componentCssStrings. + const globalCss = componentResources.css.filter( + (c) => !componentResources.componentCssStrings.has(c), + ) + + // Core CSS: theme + fonts + global CSS + base styles (no per-component CSS) const quartzBase = joinStyles( ctx.cfg.configuration.theme, googleFontsStyleSheet, - ...componentResources.css, + ...globalCss, baseStyles, ) const stylesheet = `@layer quartz-base {\n${quartzBase}\n}\n${customStyles}` @@ -343,35 +349,83 @@ export const ComponentResources: QuartzEmitterPlugin = () => { joinScripts(componentResources.afterDOMLoaded), ]) + const lightningTargets = { + safari: (15 << 16) | (6 << 8), // 15.6 + ios_saf: (15 << 16) | (6 << 8), // 15.6 + edge: 115 << 16, + firefox: 102 << 16, + chrome: 109 << 16, + } + + const cssContent = transform({ + filename: "index.css", + code: Buffer.from(stylesheet), + minify: true, + targets: lightningTargets, + include: Features.MediaQueries, + }).code.toString() + + const useHashing = !ctx.argv.serve + + const cssStringToFilename = new Map() + for (const cssString of componentResources.componentCssStrings) { + if (cssStringToFilename.has(cssString)) continue + + const wrapped = `@layer quartz-base {\n${cssString}\n}` + const minified = transform({ + filename: "component.css", + code: Buffer.from(wrapped), + minify: true, + targets: lightningTargets, + include: Features.MediaQueries, + }).code.toString() + + const hash = hashContent(minified) + const slug = `component-${hash}` + const filename = `${slug}.css` + cssStringToFilename.set(cssString, filename) + + yield write({ + ctx, + slug: slug as FullSlug, + ext: ".css", + content: minified, + }) + } + + ctx.componentCssMap = cssStringToFilename + + const cssHash = useHashing ? hashContent(cssContent) : null + const prescriptHash = useHashing ? hashContent(prescript) : null + const postscriptHash = useHashing ? hashContent(postscript) : null + + const cssSlug = cssHash ? `index-${cssHash}` : "index" + const prescriptSlug = prescriptHash ? `prescript-${prescriptHash}` : "prescript" + const postscriptSlug = postscriptHash ? `postscript-${postscriptHash}` : "postscript" + + ctx.hashedResourceNames = { + "index.css": `${cssSlug}.css`, + "prescript.js": `${prescriptSlug}.js`, + "postscript.js": `${postscriptSlug}.js`, + } + yield write({ ctx, - slug: "index" as FullSlug, + slug: cssSlug 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(), + content: cssContent, }) yield write({ ctx, - slug: "prescript" as FullSlug, + slug: prescriptSlug as FullSlug, ext: ".js", content: prescript, }) yield write({ ctx, - slug: "postscript" as FullSlug, + slug: postscriptSlug as FullSlug, ext: ".js", content: postscript, }) diff --git a/quartz/plugins/pageTypes/dispatcher.ts b/quartz/plugins/pageTypes/dispatcher.ts index 543cb07..803a076 100644 --- a/quartz/plugins/pageTypes/dispatcher.ts +++ b/quartz/plugins/pageTypes/dispatcher.ts @@ -88,7 +88,7 @@ async function emitPage( ? "/" : new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug) : pathToRoot(slug) - const externalResources = pageResources(baseDir, resources) + const externalResources = pageResources(baseDir, resources, ctx) const componentData: QuartzComponentProps = { ctx, fileData, @@ -126,7 +126,7 @@ function populateVirtualPageHtmlAst( const cfg = ctx.cfg.configuration for (const ve of virtualEntries) { const BodyComponent = ve.layout.pageBody - const externalResources = pageResources(pathToRoot(ve.vpSlug), resources) + const externalResources = pageResources(pathToRoot(ve.vpSlug), resources, ctx) const componentData: QuartzComponentProps = { ctx, fileData: ve.vfile.data, diff --git a/quartz/processors/emit.ts b/quartz/processors/emit.ts index 0c3204d..2ad3883 100644 --- a/quartz/processors/emit.ts +++ b/quartz/processors/emit.ts @@ -55,7 +55,14 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { let emittedFiles = 0 const staticResources = getStaticResourcesFromPlugins(ctx) - // Phase 1: Run PageTypeDispatcher first so it populates ctx.virtualPages + // Phase 0: Run ComponentResources first so content-hashed asset filenames + // (e.g. index-a3f2c1b.css) are available on ctx before pages are rendered. + const componentResources = cfg.plugins.emitters.find((e) => e.name === "ComponentResources") + if (componentResources) { + emittedFiles += await runEmitter(componentResources, ctx, content, staticResources, log) + } + + // Phase 1: Run PageTypeDispatcher so it populates ctx.virtualPages // with pages generated by page type plugins (tag pages, folder pages, bases pages, etc.) const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher") if (dispatcher) { @@ -68,7 +75,9 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) { // (e.g. sitemap, RSS, contentIndex.json used by the explorer sidebar). const contentWithVirtual = ctx.virtualPages.length > 0 ? [...content, ...ctx.virtualPages] : content - const otherEmitters = cfg.plugins.emitters.filter((e) => e.name !== "PageTypeDispatcher") + const otherEmitters = cfg.plugins.emitters.filter( + (e) => e.name !== "PageTypeDispatcher" && e.name !== "ComponentResources", + ) const counts = await Promise.all( otherEmitters.map((emitter) => runEmitter(emitter, ctx, contentWithVirtual, staticResources, log), diff --git a/quartz/util/ctx.ts b/quartz/util/ctx.ts index 83167f3..00fd15f 100644 --- a/quartz/util/ctx.ts +++ b/quartz/util/ctx.ts @@ -21,6 +21,13 @@ export type BuildTimeTrieData = QuartzPluginData & { filePath: string } +/** + * Mapping from logical asset names (e.g. "index.css") to their content-hashed + * filenames (e.g. "index-a3f2c1b.css"). Populated by the ComponentResources + * emitter before pages are rendered. + */ +export type HashedResourceNames = Record + export interface BuildCtx { buildId: string argv: Argv @@ -31,6 +38,10 @@ export interface BuildCtx { incremental: boolean /** Virtual pages generated by page type plugins (e.g. tag pages, folder pages, bases pages) */ virtualPages: ProcessedContent[] + /** Content-hashed asset filenames, populated by ComponentResources emitter */ + hashedResourceNames?: HashedResourceNames + /** Maps CSS content strings to their emitted hashed filenames. Populated by ComponentResources. */ + componentCssMap?: Map } export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode { diff --git a/quartz/util/resources.tsx b/quartz/util/resources.tsx index 43151d1..ee8e0c4 100644 --- a/quartz/util/resources.tsx +++ b/quartz/util/resources.tsx @@ -68,6 +68,13 @@ export interface StaticResources { } export type StringResource = string | string[] | undefined + +export function normalizeResource(resource: StringResource): string[] { + if (!resource) return [] + if (Array.isArray(resource)) return resource + return [resource] +} + export function concatenateResources(...resources: StringResource[]): StringResource { return resources .filter((resource): resource is string | string[] => resource !== undefined)