Repository source: docs/specs/asset_fingerprinting_and_css_split_v1.md
Asset Fingerprinting and CSS Splitting
Spec for the content-hash asset fingerprinting scheme
(src/ForumRewrite/Host/AssetFingerprint.php) and the CSS delivery strategy
built on top of it: critical inline CSS, a site.css base stylesheet, a
per-page CSS split, and a per-theme CSS split. docs/specs/ otherwise
documents the canonical-record/contract layer thoroughly; this fills the gap
for this newer infra layer.
Why
Public reads get long-lived, immutable Cache-Control on every asset
response, which requires the URL to change whenever the file's contents
change. Fingerprinting embeds a content hash into each /assets/... URL so
browsers/CDNs can cache indefinitely while still picking up new content
immediately after a deploy.
Fingerprint format
AssetFingerprint::fingerprintedPath() takes a request path like
/assets/site.css, hashes the file's contents (sha256, truncated to the
first 12 hex characters), and inserts the hash before the extension:
/assets/site.css -> /assets/site.a1b2c3d4e5f6.css
Only paths under /assets/ are fingerprinted; anything else passes through
unchanged. If the source file doesn't exist, the original path is returned
unchanged (fails open, not closed).
AssetFingerprint::sourcePathForFingerprint() is the inverse: given a
fingerprinted request path, it extracts the hash, recomputes it from the
current source file, and returns the source file path only if the hashes
match (hash_equals()) — this is also the mechanism that rejects a stale or
forged fingerprint.
AssetFingerprint::replacementPathForFingerprint() handles the "valid
source file, but the fingerprint in the URL is stale" case (the file changed
since the URL was generated): it returns the current fingerprinted path for
that source file, or null if the given path is already current.
Request-time serving (FrontController)
On every request, before any other routing:
resolveFingerprintedAssetPath()— tries to resolve the request path viasourcePathForFingerprint(). On a match, serves the file directly withCache-Control: public, max-age=31536000, immutable(sendAsset()).resolveStaleFingerprintedAssetPath()— if step 1 didn't match, triesreplacementPathForFingerprint(). On a match (a stale-but-recognizable fingerprinted URL), issues a302redirect to the current fingerprinted path withCache-Control: no-store(sendAssetRedirect()), rather than a 404. This is what keeps old fingerprinted links (e.g. from a stale static HTML artifact or an external cached page) working across a CSS change instead of breaking.
Only unfingerprinted text/html responses get the short-lived
no-cache, must-revalidate treatment; asset responses are the
immutable branch specifically because the content-hash URL guarantees the
bytes never change under that URL.
Template-time generation (TemplateRenderer)
TemplateRenderer::assetPath() wraps AssetFingerprint::fingerprintedPath()
and is the only way template code should reference a /assets/... URL — it
fingerprints every stylesheet and script path emitted into templates/layout.php
(siteCssPath, per-page/per-theme stylesheet paths, all script paths).
Critical CSS
site.css contains a /* critical-css-end */ marker (currently around
line 341). TemplateRenderer::criticalCss() reads everything in the file
before that marker and inlines it directly into <style data-role="critical-css">
in <head>, so above-the-fold styling doesn't wait on an external stylesheet
request. The full site.css (fingerprinted) then loads via the standard
preload-and-swap pattern:
<link rel="preload" href="{siteCssPath}" as="style" fetchpriority="high">
<link rel="stylesheet" href="{siteCssPath}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="{siteCssPath}"></noscript>
(media="print" + onload swap is the standard non-render-blocking
stylesheet load trick; the <noscript> fallback covers JS-disabled clients.)
Per-page CSS split
Beyond site.css (base styles, loaded on every page), each page template can
declare additional page-specific stylesheets via
TemplateRenderer::PAGE_STYLESHEET_PATHS, a page template => stylesheet
paths map:
'about.php' => ['/assets/about.css'],
'activity.php' => ['/assets/activity.css'],
'account_key.php' => ['/assets/identity.css'],
'bookmarklets.php' => ['/assets/tools.css'],
'invites.php' => ['/assets/invitations.css', '/assets/identity.css'],
'post.php' => ['/assets/identity.css', '/assets/content-interactions.css'],
'profile.php' => ['/assets/identity.css'],
'board.php' => ['/assets/thread-list.css'],
'tags.php' => ['/assets/tags.css'],
'tag.php' => ['/assets/thread-list.css'],
'thread.php' => ['/assets/identity.css', '/assets/content-interactions.css'],
'tools.php' => ['/assets/tools.css'],
A page template not listed here loads only site.css. These load as plain
render-blocking <link rel="stylesheet"> tags (no preload/swap — they're
page-specific, so there's no cross-page benefit to the print-media trick),
each fingerprinted individually. To add a new page-specific stylesheet, add
an entry to this map and create the CSS file under public/assets/; no
other wiring is needed.
Per-theme CSS split
Each theme is its own public/assets/theme-<name>.css file, loaded on
demand by swapping <link id="theme-stylesheet">'s href — this is a
separate, older split from the per-page one above and is documented in full
in docs/runbooks/theme_development_guide.md (registry wiring, file
structure, anti-FOUC allow-list). Both splits are fingerprinted the same way
via TemplateRenderer::assetPath().
Static HTML artifact build (StaticArtifactBuilder)
Thread/profile/tag/etc. routes can be served from pre-baked static HTML
(./v3 build-static). When building an artifact:
copyReferencedAssets()scans the rendered HTML for fingerprinted asset references and copies each referenced source file into the artifact root under its current fingerprinted name, viaAssetFingerprint::copyReferencedFingerprintedAssets().assertFingerprintReferencesAvailable()then verifies every fingerprinted reference in the rendered HTML resolves to a file that actually exists in the artifact root, throwing if any are missing — this is what makes a broken build fail loudly instead of shipping a page with dead CSS/JS links.
The standalone scripts/check_static_artifacts.php (documented in
docs/reference/v3_cli.md) runs the same missing-reference check against an
already-built artifact tree, independent of a build run — useful for
catching drift (e.g. a manually deleted or renamed asset) after the fact.
Non-goals / not covered here
Fingerprinting only applies to paths under
/assets/; nothing else is content-hashed.This doc covers the fingerprinting/delivery mechanism and the two CSS splits. It is not a catalog of every current stylesheet file or every page's full asset list — read
TemplateRenderer::PAGE_STYLESHEET_PATHSandThemeRegistrydirectly for that, since it changes often.