#!/usr/bin/env node // Font catalog build step (ADR 0016): download each catalog family's WOFF2 // weights from google-webfonts-helper (upstream gstatic) and bake them into the // web app under public/fonts/, plus generate the @font-face stylesheet. Run at // image build time (the web Dockerfile) — no runtime download, no third-party // request from a visitor's browser (CSP `font-src 'self'`). // // node deploy/fonts/build-fonts.mjs // // Requires the shared package to be built (it owns the catalog). The build // FAILS if any catalog entry lacks license info — attribution is mandatory. import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); // Import from the built shared package directly — this script runs outside any // workspace package, so `@dorfteich/shared` isn't resolvable by name here. const { FONT_CATALOG, fontSlug } = await import( join(ROOT, 'packages', 'shared', 'dist', 'index.mjs') ); const OUT_DIR = join(ROOT, 'apps', 'web', 'public', 'fonts'); const GWFH = 'https://gwfh.mranftl.com/api/fonts'; const SUBSETS = 'latin,latin-ext'; /** Reject a catalog that would ship a font without attribution (AC). */ function validateCatalog() { const bad = FONT_CATALOG.filter( (e) => !e.license || !e.licenseUrl || !e.family || e.weights.length === 0, ); if (bad.length > 0) { throw new Error( `Font catalog entries missing license/weights: ${bad.map((e) => e.family).join(', ')}`, ); } } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** GET with a few retries — the image build depends on upstream (gwfh / * gstatic), so a transient hiccup must not fail the whole build. */ async function get(url) { let lastError; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url} → ${res.status}`); return res; } catch (error) { lastError = error; if (attempt < 4) await sleep(attempt * 1000); } } throw lastError; } async function fetchJson(url) { return (await get(url)).json(); } async function fetchBytes(url) { return Buffer.from(await (await get(url)).arrayBuffer()); } async function build() { validateCatalog(); await mkdir(OUT_DIR, { recursive: true }); const faces = []; let total = 0; for (const entry of FONT_CATALOG) { const slug = fontSlug(entry.family); const meta = await fetchJson(`${GWFH}/${entry.id}?subsets=${SUBSETS}`); const byWeight = new Map( meta.variants.filter((v) => v.fontStyle === 'normal').map((v) => [Number(v.fontWeight), v]), ); await mkdir(join(OUT_DIR, slug), { recursive: true }); for (const weight of entry.weights) { const variant = byWeight.get(weight); if (!variant) throw new Error(`${entry.family}: upstream has no weight ${weight}`); const bytes = await fetchBytes(variant.woff2); const file = `${slug}/${slug}-${weight}.woff2`; await writeFile(join(OUT_DIR, file), bytes); faces.push( `@font-face {\n` + ` font-family: '${entry.family}';\n` + ` font-style: normal;\n` + ` font-weight: ${weight};\n` + ` font-display: swap;\n` + ` src: url('/fonts/${file}') format('woff2');\n` + `}`, ); total += bytes.length; console.log( ` ${entry.family} ${weight} → ${file} (${(bytes.length / 1024).toFixed(1)} KiB)`, ); } } const header = '/* Generated by deploy/fonts/build-fonts.mjs (ADR 0016) — do not edit.\n' + ' Self-hosted catalog fonts; served only from this origin. */\n\n'; await writeFile(join(OUT_DIR, 'catalog.css'), header + faces.join('\n\n') + '\n'); console.log( `\nWrote ${faces.length} @font-face rules for ${FONT_CATALOG.length} families ` + `(${(total / 1024 / 1024).toFixed(2)} MiB) to ${OUT_DIR}`, ); } build().catch((error) => { console.error(`font build failed: ${error.message}`); process.exit(1); });