优化 Mermaid 渲染
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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}).`);
|
||||
}
|
||||
Reference in New Issue
Block a user