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
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
115 lines
4.1 KiB
JavaScript
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);
|
|
});
|