dorfteich/deploy/fonts/build-fonts.mjs
Claude Opus 4.8 8a68ef68e7
All checks were successful
CD / Build and push images (push) Successful in 4m3s
CI / Lint, typecheck, test (push) Successful in 3m5s
CI / Auth e2e pack (push) Successful in 4m7s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
Add PDF export via Gotenberg (#67)
Server-side PDF export for reading/sharing (ADR 0009), rendered by a new
internal Gotenberg (headless Chromium) sidecar.

- Sidecar: `gotenberg/gotenberg:8` in the compose stack (internal, pinned,
  healthcheck); api `GOTENBERG_URL` env; a `renderer` readyz check at
  warning-level (mirrors the converter) so PDF export degrades gracefully when
  Gotenberg is down without failing readyz.
- Export HTML: `buildPdfHtml` renders a self-contained document (no app chrome)
  — the page's content with images inlined as data URIs, the pond's fonts
  inlined as base64 `@font-face` + applied via CSS variables (ADR 0016), print
  CSS (A4, page-break rules, a title header), and page numbers from Gotenberg's
  footer. Plugin-block fallbacks are a marked TODO(#79) for M7.
- Fonts in the api image: the api Dockerfile now bakes the font catalog in
  (`build-fonts.mjs` with FONTS_OUT) so the exporter can read a pond's chosen
  WOFF2 and inline them; a missing file falls back to the system stack.
- Job flow: `POST /pages/:id/export {format: pdf}` builds the HTML (read
  permission checked by the guard) and enqueues an `export_pdf` job on the #62
  queue with the HTML as input; the worker branches `to === 'pdf'` to the
  `GotenbergRenderer` (html → pdf) instead of pandoc, retrying an unreachable
  sidecar and failing a refused render (`renderer_unavailable`/`render_failed`,
  de+en). The client polls and downloads `GET /jobs/:id/result`.
- Frontend: the page-menu PDF button is now a real export (PDF added to
  EXPORT_FORMATS; the disabled placeholder removed).
- Tests: export.service.db PDF cases (HTML has title/font-variable/inlined
  image; renderer-down fails with `render_failed`); e2e PDF export self-skips
  without a Gotenberg sidecar (like the .docx case). Verified locally against
  real Gotenberg — a valid PDF with the pond font embedded.

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

117 lines
4.2 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')
);
// Default output is the web app's public/fonts (served by the web image); the
// api image build overrides FONTS_OUT to bake the same catalog in for PDF export.
const OUT_DIR = process.env.FONTS_OUT ?? 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);
});