1// Assets under content-hashed names, /assets/style.<hash>.css, so browsers2// can cache them forever: the stylesheet, the logo, the fonts (in fonts/,3// shipped next to the rest), the vendored scripts, our TypeScript with its4// types stripped, sqlite-wasm (its script and5// its .wasm, for code search), and @simplewebauthn/browser (passkeys, on6// the sign-in page). 11ty's bundle plugin7// writes the templates' own CSS next to them, also named by a hash.8//9// Pages from older builds keep linking older names: forge build swaps one10// repo at a time, and unchanged pages are hard links to the last build's.11// So each build also carries over the files of the last few code versions,12// recorded in assets/versions.json, and drops the rest.13import fs from 'node:fs';14import path from 'node:path';15import { createHash } from 'node:crypto';16import { stripTypeScriptTypes } from 'node:module';17import { fileURLToPath } from 'node:url';1819const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');20const src = path.join(root, 'site', 'assets');21const fonts = path.join(src, 'fonts');22const SQLITE = path.join(root, 'node_modules', '@sqlite.org', 'sqlite-wasm', 'dist');23const WEBAUTHN = path.join(root, 'node_modules', '@simplewebauthn', 'browser', 'dist', 'bundle', 'index.umd.min.js');24// Files from node_modules: short name, the name they ship as, the source.25const VENDOR: [string, string, string][] = [26 // The browser build, an ES module that codesearch.ts imports in its worker.27 ['sqlite3', 'sqlite3.js', path.join(SQLITE, 'index.mjs')],28 ['sqlite3wasm', 'sqlite3.wasm', path.join(SQLITE, 'sqlite3.wasm')],29 // A classic script that defines SimpleWebAuthnBrowser, for login.ts.30 ['webauthn', 'webauthn.js', WEBAUTHN],31];32const VERSIONS = 'versions.json';33// Code versions whose files stay: enough for a rebuild of every repo to finish34// and for a repo whose build fails to keep its styles for a few deploys.35const KEEP = 5;3637// The same name 11ty's bundle plugin gives a bundle: base64url sha256, 10 characters.38export const hash = (data: string | Buffer): string => createHash('sha256').update(data).digest('base64url').slice(0, 10);3940export interface Asset {41 file: string; // e.g. style.Zm9vYmFyYm.css42 data: Buffer;43}4445// By short name: style, logo, open-sans, noto-sans-mono, noto-serif,46// noto-serif-italic, fixi, ssexi, diffs, keys, live,47// search, query, codesearch, login, ui, sqlite3, sqlite3wasm, webauthn.48export type Assets = Record<string, Asset>;4950let memo: { key: string; assets: Assets } | null = null;5152export function assets(): Assets {53 const files: [string, string, string][] = [54 ...fs.readdirSync(src).filter((f) => /\.(css|svg|webp|js|ts)$/.test(f)).sort().map((f): [string, string, string] => [f.split('.')[0], f, path.join(src, f)]),55 ...fs.readdirSync(fonts).filter((f) => f.endsWith('.woff2')).sort().map((f): [string, string, string] => [f.split('.')[0], f, path.join(fonts, f)]),56 ...VENDOR,57 ];58 // Rebuilt only when a source changes (the dev server rebuilds in one process).59 const key = files.map(([, , f]) => `${f} ${fs.statSync(f).mtimeMs}`).join('\n');60 if (memo?.key === key) return memo.assets;61 // Our scripts import each other as './query.ts', which Node and tsc read62 // as it is; shipped, that is the other file's hashed name, so a script's63 // name changes with what it imports. A url(./fonts/x.woff2) in the64 // stylesheet becomes that file's hashed name the same way, beside it.65 const local = (base: string, dep: string): Asset => {66 const d = files.find(([, , p]) => path.relative(src, p) === dep);67 if (!d) throw new Error(`${base} links ./${dep}, which is not in site/assets`);68 return make(d);69 };70 const made = new Map<string, Asset>();71 const make = ([short, base, f]: [string, string, string]): Asset => {72 const done = made.get(short);73 if (done) return done;74 let data = fs.readFileSync(f);75 let ext = path.extname(base);76 const name = base.split('.')[0];77 if (ext === '.ts') {78 const js = stripTypeScriptTypes(data.toString('utf8')).replace(/(\bfrom\s*)'\.\/([\w-]+\.ts)'/g, (_, from: string, dep: string) => `${from}'./${local(base, dep).file}'`);79 data = Buffer.from(js);80 ext = '.js';81 } else if (ext === '.css') {82 data = Buffer.from(data.toString('utf8').replace(/\burl\(\.\/((?:fonts\/)?[\w-]+\.\w+)\)/g, (_, dep: string) => `url(./${local(base, dep).file})`));83 }84 const a = { file: `${name}.${hash(data)}${ext}`, data };85 made.set(short, a);86 return a;87 };88 const out: Assets = {};89 for (const f of files) out[f[0]] = make(f);90 memo = { key, assets: out };91 return out;92}9394// What templates link: assets.style is /assets/style.<hash>.css.95export const assetUrls = (): Record<string, string> => Object.fromEntries(Object.entries(assets()).map(([k, a]) => [k, `/assets/${a.file}`]));9697interface Version {98 code: string;99 files: string[];100}101102// The versions a site's assets keep. A live site's assets from before hashing103// count as one more old version.104function versions(dir: string, live: boolean): Version[] {105 try {106 return JSON.parse(fs.readFileSync(path.join(dir, VERSIONS), 'utf8'));107 } catch {108 const files = live && fs.existsSync(dir) ? fs.readdirSync(dir) : [];109 return files.length ? [{ code: '', files }] : [];110 }111}112113// A file from the live site: a hard link (same filesystem), else a copy.114function carry(from: string, to: string): boolean {115 try {116 fs.linkSync(from, to);117 } catch {118 try {119 fs.cpSync(from, to, { recursive: true });120 } catch {121 return false;122 }123 }124 return true;125}126127// Write this build's assets into <out>/assets, next to the CSS bundles 11ty128// wrote there, and carry over what earlier versions' pages still link from129// <live>/assets. `code` names this version of the forge's code.130export function shipAssets(out: string, live: string | null, code: string): void {131 const dir = path.join(out, 'assets');132 const was = live ? path.join(live, 'assets') : dir;133 const prev = versions(was, !!live);134 fs.mkdirSync(dir, { recursive: true });135 for (const a of Object.values(assets())) {136 const to = path.join(dir, a.file);137 if (fs.existsSync(to) || (live && carry(path.join(was, a.file), to))) continue;138 fs.writeFileSync(to, a.data);139 }140 // Files of the same code version stay current: pages linked from the last141 // build may use bundles that no page of this build rendered.142 const now = new Set(fs.readdirSync(dir).filter((f) => f !== VERSIONS));143 for (const v of prev) if (v.code === code) for (const f of v.files) now.add(f);144 const keep = [{ code, files: [...now].sort() }, ...prev.filter((v) => v.code !== code)].slice(0, KEEP);145 if (live) {146 for (const v of keep) v.files = v.files.filter((f) => fs.existsSync(path.join(dir, f)) || carry(path.join(was, f), path.join(dir, f)));147 }148 fs.writeFileSync(path.join(dir, VERSIONS), JSON.stringify(keep, null, 1) + '\n');149}