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
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
117 lines
4.2 KiB
JavaScript
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);
|
|
});
|