Files
simba_docs/scripts/generate-fonts.mjs
T
2026-09-25 01:45:31 +08:00

300 lines
11 KiB
JavaScript

/**
* 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 `<link>` 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 <families>`.
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}).`);
}