feat: split and hash static css

This commit is contained in:
saberzero1
2026-05-22 22:42:06 +02:00
parent 2e34c64c63
commit cfba672f51
6 changed files with 137 additions and 38 deletions

View File

@@ -1,7 +1,7 @@
import { render } from "preact-render-to-string" import { render } from "preact-render-to-string"
import { QuartzComponent, QuartzComponentProps } from "./types" import { QuartzComponent, QuartzComponentProps } from "./types"
import BodyConstructor from "./Body" 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 { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path"
import { clone } from "../util/clone" import { clone } from "../util/clone"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
@@ -11,6 +11,7 @@ import { i18n } from "../i18n"
import { styleText } from "util" import { styleText } from "util"
import { resolveFrame } from "./frames" import { resolveFrame } from "./frames"
import type { TreeTransform } from "../plugins/types" import type { TreeTransform } from "../plugins/types"
import type { BuildCtx } from "../util/ctx"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@@ -28,20 +29,37 @@ const headerRegex = new RegExp(/h[1-6]/)
export function pageResources( export function pageResources(
baseDir: FullSlug | RelativeURL, baseDir: FullSlug | RelativeURL,
staticResources: StaticResources, staticResources: StaticResources,
ctx?: BuildCtx,
): StaticResources { ): 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<string>()
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 contentIndexPath = joinSegments(baseDir, "static/contentIndex.json")
const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())` const contentIndexScript = `const fetchData = fetch("${contentIndexPath}").then(data => data.json())`
const resources: StaticResources = { const resources: StaticResources = {
css: [ css: [
{ {
content: joinSegments(baseDir, "index.css"), content: joinSegments(baseDir, cssFile),
}, },
...componentCssResources,
...staticResources.css, ...staticResources.css,
], ],
js: [ js: [
{ {
src: joinSegments(baseDir, "prescript.js"), src: joinSegments(baseDir, prescriptFile),
loadTime: "beforeDOMReady", loadTime: "beforeDOMReady",
contentType: "external", contentType: "external",
}, },
@@ -57,7 +75,7 @@ export function pageResources(
} }
resources.js.push({ resources.js.push({
src: joinSegments(baseDir, "postscript.js"), src: joinSegments(baseDir, postscriptFile),
loadTime: "afterDOMReady", loadTime: "afterDOMReady",
moduleType: "module", moduleType: "module",
contentType: "external", contentType: "external",

View File

@@ -1,3 +1,4 @@
import { createHash } from "crypto"
import { FullSlug, joinSegments } from "../../util/path" import { FullSlug, joinSegments } from "../../util/path"
import { QuartzEmitterPlugin } from "../types" import { QuartzEmitterPlugin } from "../types"
@@ -10,6 +11,7 @@ import customStyles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss" import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types" import { QuartzComponent } from "../../components/types"
import { normalizeResource } from "../../util/resources"
import { componentRegistry } from "../../components/registry" import { componentRegistry } from "../../components/registry"
import { import {
googleFontHref, googleFontHref,
@@ -21,10 +23,15 @@ import { Features, transform } from "lightningcss"
import { transform as transpile } from "esbuild" import { transform as transpile } from "esbuild"
import { write } from "./helpers" import { write } from "./helpers"
function hashContent(content: string | Buffer): string {
return createHash("sha256").update(content).digest("hex").slice(0, 8)
}
type ComponentResources = { type ComponentResources = {
css: string[] css: string[]
beforeDOMLoaded: string[] beforeDOMLoaded: string[]
afterDOMLoaded: string[] afterDOMLoaded: string[]
componentCssStrings: Set<string>
} }
function getComponentResources(ctx: BuildCtx): ComponentResources { function getComponentResources(ctx: BuildCtx): ComponentResources {
@@ -47,27 +54,18 @@ function getComponentResources(ctx: BuildCtx): ComponentResources {
afterDOMLoaded: 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) { for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component const { css, beforeDOMLoaded, afterDOMLoaded } = component
const normalizedCss = normalizeResource(css) for (const c of normalizeResource(css)) componentResources.css.add(c)
const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded) for (const b of normalizeResource(beforeDOMLoaded)) componentResources.beforeDOMLoaded.add(b)
const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded) for (const a of normalizeResource(afterDOMLoaded)) componentResources.afterDOMLoaded.add(a)
normalizedCss.forEach((c) => componentResources.css.add(c))
normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b))
normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a))
} }
return { return {
css: [...componentResources.css], css: [...componentResources.css],
beforeDOMLoaded: [...componentResources.beforeDOMLoaded], beforeDOMLoaded: [...componentResources.beforeDOMLoaded],
afterDOMLoaded: [...componentResources.afterDOMLoaded], 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 // that everyone else had the chance to register a listener for it
addGlobalPageResources(ctx, componentResources) 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( const quartzBase = joinStyles(
ctx.cfg.configuration.theme, ctx.cfg.configuration.theme,
googleFontsStyleSheet, googleFontsStyleSheet,
...componentResources.css, ...globalCss,
baseStyles, baseStyles,
) )
const stylesheet = `@layer quartz-base {\n${quartzBase}\n}\n${customStyles}` const stylesheet = `@layer quartz-base {\n${quartzBase}\n}\n${customStyles}`
@@ -343,35 +349,83 @@ export const ComponentResources: QuartzEmitterPlugin = () => {
joinScripts(componentResources.afterDOMLoaded), 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<string, string>()
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({ yield write({
ctx, ctx,
slug: "index" as FullSlug, slug: cssSlug as FullSlug,
ext: ".css", ext: ".css",
content: transform({ content: cssContent,
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({ yield write({
ctx, ctx,
slug: "prescript" as FullSlug, slug: prescriptSlug as FullSlug,
ext: ".js", ext: ".js",
content: prescript, content: prescript,
}) })
yield write({ yield write({
ctx, ctx,
slug: "postscript" as FullSlug, slug: postscriptSlug as FullSlug,
ext: ".js", ext: ".js",
content: postscript, content: postscript,
}) })

View File

@@ -88,7 +88,7 @@ async function emitPage(
? "/" ? "/"
: new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug) : new URL(`https://${cfg.baseUrl ?? "example.com"}`).pathname) as FullSlug)
: pathToRoot(slug) : pathToRoot(slug)
const externalResources = pageResources(baseDir, resources) const externalResources = pageResources(baseDir, resources, ctx)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData, fileData,
@@ -126,7 +126,7 @@ function populateVirtualPageHtmlAst(
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
for (const ve of virtualEntries) { for (const ve of virtualEntries) {
const BodyComponent = ve.layout.pageBody const BodyComponent = ve.layout.pageBody
const externalResources = pageResources(pathToRoot(ve.vpSlug), resources) const externalResources = pageResources(pathToRoot(ve.vpSlug), resources, ctx)
const componentData: QuartzComponentProps = { const componentData: QuartzComponentProps = {
ctx, ctx,
fileData: ve.vfile.data, fileData: ve.vfile.data,

View File

@@ -55,7 +55,14 @@ export async function emitContent(ctx: BuildCtx, content: ProcessedContent[]) {
let emittedFiles = 0 let emittedFiles = 0
const staticResources = getStaticResourcesFromPlugins(ctx) 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.) // with pages generated by page type plugins (tag pages, folder pages, bases pages, etc.)
const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher") const dispatcher = cfg.plugins.emitters.find((e) => e.name === "PageTypeDispatcher")
if (dispatcher) { 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). // (e.g. sitemap, RSS, contentIndex.json used by the explorer sidebar).
const contentWithVirtual = const contentWithVirtual =
ctx.virtualPages.length > 0 ? [...content, ...ctx.virtualPages] : content 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( const counts = await Promise.all(
otherEmitters.map((emitter) => otherEmitters.map((emitter) =>
runEmitter(emitter, ctx, contentWithVirtual, staticResources, log), runEmitter(emitter, ctx, contentWithVirtual, staticResources, log),

View File

@@ -21,6 +21,13 @@ export type BuildTimeTrieData = QuartzPluginData & {
filePath: string 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<string, string>
export interface BuildCtx { export interface BuildCtx {
buildId: string buildId: string
argv: Argv argv: Argv
@@ -31,6 +38,10 @@ export interface BuildCtx {
incremental: boolean incremental: boolean
/** Virtual pages generated by page type plugins (e.g. tag pages, folder pages, bases pages) */ /** Virtual pages generated by page type plugins (e.g. tag pages, folder pages, bases pages) */
virtualPages: ProcessedContent[] 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<string, string>
} }
export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> { export function trieFromAllFiles(allFiles: QuartzPluginData[]): FileTrieNode<BuildTimeTrieData> {

View File

@@ -68,6 +68,13 @@ export interface StaticResources {
} }
export type StringResource = string | string[] | undefined 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 { export function concatenateResources(...resources: StringResource[]): StringResource {
return resources return resources
.filter((resource): resource is string | string[] => resource !== undefined) .filter((resource): resource is string | string[] => resource !== undefined)