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/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: 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 @@ - - - - - - - - - - - - - - - - - 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 @@ +