/** * Generate `src/styles/fonts.generated.css`. * * Policy: ship no font binaries, and never let a font request block rendering. * * 1. Site typography — `Noto Sans SC` from the Google Fonts CN API, loaded * through a non-blocking `` in `Head.astro`, followed by a long local * fallback chain so the page is fully readable even when Google is * unreachable. * * 2. KaTeX — a math font cannot be faked with a UI font: KaTeX positions every * glyph against `KaTeX_Main`/`KaTeX_Math` metrics and needs the `KaTeX_Size*` * faces for large delimiters. So the faces come from a CDN (cdnjs, which is * reachable from mainland China, unlike jsDelivr) and KaTeX's own * `@font-face` blocks — the ~1 MB of `fonts/KaTeX_*.woff2` this repo used to * copy into `dist/` — are replaced by CDN-pinned ones. Every element-level * `font-family: KaTeX_*` declaration keeps its matching KaTeX face first, * followed by local fallbacks. If the CDN is blocked, the local faces keep * text visible but cannot guarantee KaTeX's glyph metrics or delimiters. * * The generator proves its own contract: every `url()` it emits must be an * absolute HTTPS CDN URL, and no relative font reference may survive, so the * bundler can never pick up a font file by accident. * * Output is deterministic, so it is generated at predev/prebuild instead of * being committed. `--check` verifies without writing. */ import { createHash } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; const KATEX_DIR = new URL('../node_modules/katex/', import.meta.url); const KATEX_PKG = fileURLToPath(new URL('package.json', KATEX_DIR)); const KATEX_CSS = fileURLToPath(new URL('dist/katex.css', KATEX_DIR)); const OUT_FILE = fileURLToPath(new URL('../src/styles/fonts.generated.css', import.meta.url)); /** cdnjs mirror. Swap for another host if this one is not reachable for you. */ const KATEX_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/KaTeX'; /** Site webfont family requested from the Google Fonts CN API in `Head.astro`. */ export const SANS_FAMILY = 'Noto Sans SC'; /** * Heading webfont for the site title in the top-left corner. The title contains * a Greek `Σ`, so the face must cover Greek as well as Latin: Manrope is a * variable font (200..800) with a greek subset, and Archivo Black (weight 400) * is requested as a separate heavy fallback. */ export const DISPLAY_FAMILY = 'Manrope'; const SYSTEM_SANS = [ 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', ]; const CJK_SANS = [ 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Source Han Sans SC', 'WenQuanYi Micro Hei', ]; /** Quote only the families that need it, so the output stays readable. */ const fontStack = (families) => families.map((family) => (/[\s"]/.test(family) ? `"${family}"` : family)).join(', '); /** Site stack: the webfont first, then every plausible local fallback. */ const SANS_STACK = fontStack([SANS_FAMILY, ...SYSTEM_SANS, ...CJK_SANS, 'sans-serif']); /** * Display stack for the site title. Only Latin + Greek are covered by the * webfonts, so the site's CJK fallbacks follow for any Chinese in the title. * `Archivo Black` covers Greek and is a genuine display cut, so it is a real * fallback rather than a placeholder. */ const DISPLAY_STACK = fontStack([ DISPLAY_FAMILY, 'Archivo Black', ...SYSTEM_SANS, ...CJK_SANS, 'sans-serif', ]); /** * Math stack. Locally installed TeX faces come first, then the platform UI * faces for Latin/Greek symbols, then CJK faces for any Chinese inside math. */ const MATH_STACK = fontStack([ 'STIX Two Math', 'Latin Modern Math', 'Cambria Math', ...SYSTEM_SANS, ...CJK_SANS, 'sans-serif', ]); const EXPECTED_FAMILIES = [ 'KaTeX_AMS', 'KaTeX_Caligraphic', 'KaTeX_Fraktur', 'KaTeX_Main', 'KaTeX_Math', 'KaTeX_SansSerif', 'KaTeX_Script', 'KaTeX_Size1', 'KaTeX_Size2', 'KaTeX_Size3', 'KaTeX_Size4', 'KaTeX_Typewriter', ]; /** Read a single `prop: value` declaration out of a rule body. */ function readProp(body, prop) { const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;}]+)`, 'i').exec(body); return match ? match[1].trim() : null; } /** Collect every `@font-face` as `{ family, style, weight, src }`. */ function findFontFaces(css) { const faces = []; let cursor = 0; for (;;) { const start = css.indexOf('@font-face', cursor); if (start === -1) break; const open = css.indexOf('{', start); const close = css.indexOf('}', open); if (open === -1 || close === -1) break; const body = css.slice(open + 1, close); const family = readProp(body, 'font-family')?.replace(/^['"]|['"]$/g, ''); if (family) { faces.push({ family, style: readProp(body, 'font-style') ?? 'normal', weight: readProp(body, 'font-weight') ?? 'normal', src: readProp(body, 'src') ?? '', }); } cursor = close + 1; } return faces; } /** * Turn a face's `src` into absolute CDN URLs, keeping only the formats asked * for. Upstream ships woff2 + woff + ttf; woff2 is universally supported for * the browsers this site targets, so shipping one format is enough. */ function cdnSources(face, version, formats = ['woff2']) { const files = [...face.src.matchAll(/url\(\s*([^)\s"']+)\s*\)/gi)] .map((match) => match[1]) .filter((file) => formats.some((format) => file.endsWith(`.${format}`))); if (!files.length) { throw new Error(`No ${formats.join('/')} source found for ${face.family} (${face.style} ${face.weight})`); } return files .map((file) => { const name = file.replace(/^.*\//, ''); const format = name.slice(name.lastIndexOf('.') + 1); const remote = `${KATEX_CDN}/${version}/fonts/${name}`; return `url(${remote}) format("${format}")`; }) .join(',\n '); } const version = JSON.parse(await readFile(KATEX_PKG, 'utf8')).version; const source = await readFile(KATEX_CSS, 'utf8'); const faces = findFontFaces(source); const families = [...new Set(faces.map((face) => face.family))]; const added = families.filter((family) => !EXPECTED_FAMILIES.includes(family)); const dropped = EXPECTED_FAMILIES.filter((family) => !families.includes(family)); if (added.length || dropped.length) { throw new Error( `KaTeX ${version} declares unexpected families (added: ${added.join(', ') || 'none'}; ` + `missing: ${dropped.join(', ') || 'none'}). Update EXPECTED_FAMILIES in ` + 'scripts/generate-fonts.mjs and re-check the fallback chain.', ); } const fontFaces = EXPECTED_FAMILIES.flatMap((family) => faces .filter((face) => face.family === family) .map( (face) => `@font-face { font-family: ${family}; src: ${cdnSources(face, version)}; font-style: ${face.style}; font-weight: ${face.weight}; font-display: swap; }`, ), ).join('\n\n'); /** * Append local fallbacks while preserving the exact KaTeX family, handling both * `font-family: KaTeX_Main, Times New Roman, serif` and the `font:` shorthand * (`.katex { font: normal 1.21em KaTeX_Main, ... }`), which is easy to miss * because it has no `font-family` token. */ function addMathFallbacks(rule) { return rule .replace(/(^|[\s;{])(font-family\s*:\s*)([^;}]+)/gm, (match, lead, prop, value) => value.includes('KaTeX_') ? `${lead}${prop}${value.match(/KaTeX_[A-Za-z0-9]+/)[0]}, ${MATH_STACK}` : match, ) .replace(/(^|[\s;{])(font\s*:\s*)([^;}]+)/gm, (match, lead, prop, value) => { if (!value.includes('KaTeX_')) return match; // Keep the position of the family list: `font: normal 1em `. const [head, ...rest] = value.split(/\s+(?=KaTeX_)/); const family = value.match(/KaTeX_[A-Za-z0-9]+/)[0]; return rest.length ? `${lead}${prop}${head} ${family}, ${MATH_STACK}` : match; }); } // KaTeX's layout/sizing rules are version-locked to its HTML output, so they are // kept verbatim. Dropping the `@font-face` blocks leaves whitespace behind, and // only fallback font lists change inside the rules themselves. const layout = addMathFallbacks(source.replace(/@font-face\s*\{[^}]*\}/g, '')) .replace(/\n{3,}/g, '\n\n') .trim(); // Declaring @font-face is not sufficient: layout rules must actually use it. for (const family of EXPECTED_FAMILIES) { if (!layout.includes(family + ',')) { throw new Error(`KaTeX layout lost its ${family} font reference.`); } } const css = `/* * GENERATED FILE - do not edit. Run \`node scripts/generate-fonts.mjs\`. * KaTeX fonts are pinned to cdnjs ${version}; no font file is bundled. */ :root { /* Latin first, then Simplified Chinese, then the hosting platform's UI and CJK faces. The webfonts are optional: every entry after them is local. */ --sl-font: ${SANS_STACK}; --sl-font-mono: ${SANS_STACK}; --sl-font-system-mono: ${SANS_STACK}; /* Heading face for the site title; see .site-title in custom.css. */ --sl-font-display: ${DISPLAY_STACK}; } /* * KaTeX math faces, loaded from the CDN and never bundled. \`font-display: swap\` * means a blocked CDN shows the local stack immediately instead of blank math. * Local fallback metrics differ, so complex formulas require the remote faces. */ ${fontFaces} /* --- katex/dist/katex.css layout, preserving KaTeX faces with local fallbacks --- */ ${layout} `; // Contract: no relative font reference may survive, so the bundler has nothing // to copy into dist/_astro and no page can request a font from its own origin. const urls = [...css.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((match) => match[1]); const relative = urls.filter((url) => !url.startsWith('https://')); if (relative.length) { throw new Error(`Generated CSS must only reference absolute CDN fonts, found: ${relative.join(', ')}`); } if (!urls.length) { throw new Error('Generated CSS references no KaTeX fonts; check the cdnSources mapping.'); } const digest = createHash('sha256').update(css).digest('hex').slice(0, 16); const output = `/* sha256:${digest} */\n${css}`; const summary = `KaTeX ${version} pinned to cdnjs, ${urls.length} remote faces, 0 bundled files`; const current = await readFile(OUT_FILE, 'utf8').catch(() => null); if (process.argv.includes('--check')) { if (current !== output) { console.error(`fonts.generated.css is stale (${summary}). Run: node scripts/generate-fonts.mjs`); process.exit(1); } console.log(`fonts.generated.css is up to date (${summary}).`); } else if (current === output) { console.log(`fonts.generated.css unchanged (${summary}).`); } else { await writeFile(OUT_FILE, output, 'utf8'); console.log(`Wrote src/styles/fonts.generated.css (${summary}).`); }