feat: split and hash static css
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user