dorfteich/deploy/fonts/build-fonts.mjs
Claude Opus 4.8 f500198c5d
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Auth e2e pack (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add per-pond fonts: catalog, build, application, and admin UI (#66)
Self-hosted Google Fonts with per-pond selection (ADR 0016), the GDPR
"zero external requests" posture (security.md, CSP `font-src 'self'`).

- Catalog: a curated 15-family OFL/Apache list in shared (family, weights,
  category, license, google-webfonts-helper id). `deploy/fonts/build-fonts.mjs`
  validates every entry has license info (fails the build otherwise),
  downloads the WOFF2 weights into apps/web/public/fonts/ (gitignored), and
  generates the @font-face stylesheet — run at image build time from the web
  Dockerfile (with retries), never from a visitor's browser.
- Application: PondFontScope sets --font-heading/body/mono (+ weights) from
  pond.settings.fonts on the editor + read view; the existing global CSS
  already reads those custom properties, so headings/body/code re-resolve to
  the pond's fonts. A pond with no settings arrives with the defaulted values
  (Roboto 400 / Roboto 200 / Fira Code), so the vision defaults always render.
- Admin UI: pond-settings 'Appearance' section — three slots (family + weight)
  with a live preview, Pond-Admin-gated (fonts added to updatePondInputSchema
  and merged in PondsService.update); a font catalog attribution page (/fonts)
  listing families and licenses. New `font` i18n namespace (de+en).
- CSP: strict Content-Security-Policy in nginx.conf (default-src 'self';
  font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; …) —
  the app's scripts are all external files, inline styles cover CSS variables.
- Tests: shared catalog-integrity unit test (the invariant the build enforces);
  e2e fonts pack — no request leaves the origin when rendering a pond (the GDPR
  network assertion), a font choice applies to a page and persists, and a pond
  without settings renders the defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 11:16:29 +02:00

115 lines
4.1 KiB
JavaScript

#!/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);
});