From 0acf9bb701546611c8d48042db32a1e62b089afc Mon Sep 17 00:00:00 2001 From: Lars Francke Date: Tue, 28 Jul 2026 14:48:40 +0200 Subject: [PATCH 1/3] perf: Content-hash the bundle assets and take the icons from their package The css and js entry file names now carry a content hash: vite emits hashed names, the mermaid dist entry gets a sha-based one (its chunks are hashed upstream), and the partials referencing them are rewritten from the manifest while being staged. That makes the files safely cacheable forever, so the netlify rules serve css/js as immutable; fonts and images keep stable names with moderate lifetimes. The rules are non-overlapping on purpose: netlify applies every matching header rule, and overlaps would emit duplicate Cache-Control. The icon sprite is now generated at build time from the @fortawesome/fontawesome-free package instead of vendored path data, so the icons are version-managed like every other dependency. The attribution comments carry the actual package version. --- netlify.toml | 26 ++++++++++++--- package-lock.json | 11 ++++++ ui/NOTICE | 8 +++-- ui/build.mjs | 79 ++++++++++++++++++++++++++++++++++++++++---- ui/package.json | 1 + ui/src/img/icons.svg | 22 ------------ 6 files changed, 111 insertions(+), 36 deletions(-) delete mode 100644 ui/src/img/icons.svg diff --git a/netlify.toml b/netlify.toml index 8f5cbcc1b..0fca5697a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -12,10 +12,26 @@ to = "/home/stable/release-notes" status = 301 -# The bundle file names are not content-hashed, so a stale asset can be -# served until max-age expires. One hour keeps that window small; with -# content-hashed file names this could be immutable. +# The css and js file names carry a content hash, so they can be cached +# forever; fonts and images keep stable names and get moderate lifetimes. +# The rules are deliberately non-overlapping: netlify applies every matching +# header rule, and overlapping rules would emit duplicate Cache-Control. [[headers]] - for = "/_/*" + for = "/_/css/*" [headers.values] - Cache-Control = "public, max-age=3600" + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/_/js/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/_/font/*" + [headers.values] + Cache-Control = "public, max-age=604800" + +[[headers]] + for = "/_/img/*" + [headers.values] + Cache-Control = "public, max-age=86400" diff --git a/package-lock.json b/package-lock.json index 96a0ccfd5..e605f2feb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -797,6 +797,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "dev": true, + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -7999,6 +8009,7 @@ "@fontsource/ibm-plex-mono": "^5.0.14", "@fontsource/noto-sans": "^5.0.22", "@fontsource/noto-sans-mono": "^5.0.20", + "@fortawesome/fontawesome-free": "^6.7.2", "archiver": "^8.0.0", "eslint": "^10.7.0", "globals": "^17.7.0", diff --git a/ui/NOTICE b/ui/NOTICE index 96bdacb01..7eba38ac5 100644 --- a/ui/NOTICE +++ b/ui/NOTICE @@ -8,9 +8,11 @@ third-party components. Build-time minification strips embedded license header comments; this file preserves the attributions and is included in the published bundle. -- Font Awesome Free 6.2.1 (https://fontawesome.com) - The inline svg icon path data is derived from Font Awesome Free icons - (CC BY 4.0). https://fontawesome.com/license/free +- Font Awesome Free (https://fontawesome.com) + The icon sprite (img/icons.svg) is generated at build time from the + @fortawesome/fontawesome-free package (see ui/package.json for the + version); the icon path data is CC BY 4.0. + https://fontawesome.com/license/free - highlight.js (https://highlightjs.org) BSD 3-Clause License, (c) 2006 Ivan Sagalaev and contributors - Asciidoctor Tabs (https://github.com/asciidoctor/asciidoctor-tabs) diff --git a/ui/build.mjs b/ui/build.mjs index 665d813fb..71fcf1e7f 100644 --- a/ui/build.mjs +++ b/ui/build.mjs @@ -13,8 +13,10 @@ import { mkdirSync, readdirSync, readFileSync, - rmSync + rmSync, + writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { resolve, basename, dirname } from 'node:path'; import { ZipArchive } from 'archiver'; @@ -68,8 +70,14 @@ function config(overrides) { rmSync(resolve(root, 'build'), { recursive: true, force: true }); +// css and js entry files carry a content hash in their name so they can be +// cached as immutable; the partials reference them by their unhashed name and +// are rewritten from this manifest when they are staged. Fonts and images +// keep stable names (they are referenced from css/content and rarely change). +const hashedNames = new Map(); + for (const entry of jsEntries) { - await build( + const result = await build( config({ build: { outDir: staged, @@ -78,15 +86,17 @@ for (const entry of jsEntries) { input: entry.input, output: { format: 'iife', - entryFileNames: `js/${entry.name}.js` + entryFileNames: `js/${entry.name}-[hash].js` } } } }) ); + const chunk = result.output.find((file) => file.type === 'chunk' && file.isEntry); + hashedNames.set(`js/${entry.name}.js`, chunk.fileName); } -await build( +const cssResult = await build( config({ build: { outDir: staged, @@ -97,7 +107,7 @@ await build( output: { assetFileNames: (asset) => { const name = asset.names[0] ?? ''; - if (name === 'site.css') return 'css/site.css'; + if (name === 'site.css') return 'css/site-[hash][extname]'; if (/\.(woff2?|ttf)$/.test(name)) return 'font/[name][extname]'; if (/\.(svg|png|gif|ico|jpg)$/.test(name)) return 'img/[name][extname]'; return 'css/[name][extname]'; @@ -107,6 +117,8 @@ await build( } }) ); +const siteCss = cssResult.output.find((file) => file.fileName.startsWith('css/site-')); +hashedNames.set('css/site.css', siteCss.fileName); // mermaid is imported on demand by partials/mermaid-script.hbs. It ships its // own prebuilt es-module dist and breaks at runtime when re-bundled, so the @@ -121,16 +133,71 @@ while (mermaidQueue.length > 0) { if (mermaidSeen.has(file)) continue; mermaidSeen.add(file); const content = readFileSync(resolve(mermaidDist, file), 'utf8'); - copyFileSync(resolve(mermaidDist, file), resolve(mermaidOut, file)); + if (file === 'mermaid.esm.min.mjs') { + // the chunks already carry upstream content hashes; the entry does not, + // so it gets one here to be safely cacheable as immutable like the rest + const hash = createHash('sha256').update(content).digest('hex').slice(0, 8); + const hashedEntry = `mermaid.esm.min-${hash}.mjs`; + copyFileSync(resolve(mermaidDist, file), resolve(mermaidOut, hashedEntry)); + hashedNames.set('js/vendor/mermaid/mermaid.esm.min.mjs', `js/vendor/mermaid/${hashedEntry}`); + } else { + copyFileSync(resolve(mermaidDist, file), resolve(mermaidOut, file)); + } for (const match of content.matchAll(/(?:from\s*|import\s*\(?\s*)"\.\/([^"]+)"/g)) { mermaidQueue.push(match[1]); } } console.log(`mermaid: copied ${mermaidSeen.size} dist files`); +// The icon sprite is generated from the Font Awesome package so the icons are +// version-managed like every other dependency (CC BY 4.0, see NOTICE). +const faDir = dirname(require.resolve('@fortawesome/fontawesome-free/package.json')); +const faVersion = JSON.parse(readFileSync(resolve(faDir, 'package.json'), 'utf8')).version; +const icons = { + 'external-link': 'solid/arrow-up-right-from-square', + search: 'solid/magnifying-glass', + xing: 'brands/xing', + linkedin: 'brands/linkedin', + github: 'brands/github', + twitter: 'brands/twitter', + link: 'solid/link' +}; +const symbols = Object.entries(icons).map(([name, faIcon]) => { + const svg = readFileSync(resolve(faDir, 'svgs', `${faIcon}.svg`), 'utf8'); + const viewBox = svg.match(/viewBox="([^"]+)"/)[1]; + const path = svg.match(/\n` + + ` ` + ); +}); +const sprite = ` + +${symbols.join('\n')} + +`; + +// hbs templates reference the hashed assets by their unhashed names and are +// rewritten from the manifest while being staged for (const dir of ['helpers', 'layouts', 'partials', 'img']) { cpSync(resolve(src, dir), resolve(staged, dir), { recursive: true }); } +for (const dir of ['layouts', 'partials']) { + for (const name of readdirSync(resolve(staged, dir))) { + if (!name.endsWith('.hbs')) continue; + const file = resolve(staged, dir, name); + let content = readFileSync(file, 'utf8'); + for (const [plain, hashed] of hashedNames) { + content = content.replaceAll(plain, hashed); + } + writeFileSync(file, content); + } +} +writeFileSync(resolve(staged, 'img', 'icons.svg'), sprite); for (const file of ['NOTICE', 'LICENSE']) { copyFileSync(resolve(root, file), resolve(staged, file)); } diff --git a/ui/package.json b/ui/package.json index c8bb422ce..1ef46386f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,6 +21,7 @@ "@fontsource/ibm-plex-mono": "^5.0.14", "@fontsource/noto-sans": "^5.0.22", "@fontsource/noto-sans-mono": "^5.0.20", + "@fortawesome/fontawesome-free": "^6.7.2", "archiver": "^8.0.0", "eslint": "^10.7.0", "globals": "^17.7.0", diff --git a/ui/src/img/icons.svg b/ui/src/img/icons.svg deleted file mode 100644 index 66e0979b1..000000000 --- a/ui/src/img/icons.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - From 2fb32b2a84efac15fa86e5a1614504f443552888 Mon Sep 17 00:00:00 2001 From: Lars Francke Date: Tue, 28 Jul 2026 14:48:40 +0200 Subject: [PATCH 2/3] docs: Document the dependency special cases in the ui README mermaid, pagefind, @asciidoctor/tabs, highlight.js, fontawesome and fontsource each get consumed in a non-obvious way; the README now says how and why, plus how the asset hashing works. --- ui/README.adoc | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ui/README.adoc b/ui/README.adoc index 73dd0a09f..13da939c7 100644 --- a/ui/README.adoc +++ b/ui/README.adoc @@ -60,6 +60,19 @@ Inside the `src` directory are: The js entries are `js/site.js` (the numbered scripts under `src/js`, concatenated in order) and one `js/vendor/.js` per `src/js/vendor/.bundle.js` with its imports bundled in. +=== Dependency special cases + +* `mermaid`: ships its own prebuilt es-module dist and breaks at runtime when re-bundled; the build copies the entry and its transitive chunk imports verbatim and loads it on demand (see `partials/mermaid-script.hbs`). +* `pagefind`: lives entirely outside this UI bundle. It is a devDependency of the repository root, indexes the finished site after the Antora build (`make build-search-index`) and self-serves its assets under `/pagefind/`. The asset hashing below does not cover it. +* `@asciidoctor/tabs`: doubles as a build-time Asciidoctor extension (playbook) and a runtime asset. The browser js and the css are imported by explicit path (`dist/js/tabs.js`, `dist/css/tabs.css`) because Vite honors neither the `browser` nor the `style` package field. +* `highlight.js`: bundled from its modular source with a curated language list in `src/js/vendor/highlight.bundle.js`. +* `@fortawesome/fontawesome-free`: the icon sprite `img/icons.svg` is generated at build time from the package's svgs (CC BY 4.0, attributed in NOTICE and in the sprite itself). +* `@fontsource/*`: the font files are pulled out of the packages via the `~@fontsource/` url alias in `src/css/fonts.css`. + +=== Asset caching + +The css and js entry files carry a content hash in their names and are served with immutable cache headers (see `netlify.toml`). The hbs templates reference them by their unhashed names; the build rewrites the references while staging the bundle. Fonts and images keep stable names with moderate cache lifetimes. + === Building the final documentation The build is wired into the Playbooks of this repository: From 4ac60cbb97ca165890dbed0f3ad47a13fc72c180 Mon Sep 17 00:00:00 2001 From: Lars Francke Date: Tue, 28 Jul 2026 14:48:40 +0200 Subject: [PATCH 3/3] perf: Preload the bold body font Lighthouse showed noto-sans-700 arriving late through the css chain on pages that use bold text above the fold. --- ui/src/partials/head-styles.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/partials/head-styles.hbs b/ui/src/partials/head-styles.hbs index 321a12378..71477a431 100644 --- a/ui/src/partials/head-styles.hbs +++ b/ui/src/partials/head-styles.hbs @@ -1,4 +1,5 @@ +