Files
Jyotisha/frontend/tests/font-stack-loadable-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 8112f62b51 perf(fonts): heading serif slices served from public/ under content-hashed names, cached for good (BUG-1128)
Bundled media carry Next's per-deploy ?dpl= and were downloaded again after
every release. Bytes unchanged; /fonts/serif-sc/* is immutable. Overturns the
serif-headings contract line "bundled by Next, not served from public/"
per TASK-home-first-load-20260930 decision 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 00:40:11 +08:00

156 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync, readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
/**
* BUG-737: `--font-display` led with "Tiempos Headline" and `--font-body` with
* StyreneB. Both are Anthropic licensed faces this app has never loaded — no
* @font-face, nothing in public/, and layout.tsx vendors only Inter — so every
* CJK heading fell through to the next entry, Songti SC / SimSun, for the life
* of the product. Nothing failed, because nothing checked that a declared family
* can actually resolve.
*
* This contract closes that: every quoted family in the two stacks must either
* be vendored through next/font/local, be declared by a self-hosted @font-face
* whose files exist (the heading face "Jyotisha Serif SC",
* src/app/fonts/serif-sc/serif-sc.css), or be a face the OS is known to ship.
*
* 2026-09-28 (TASK-serif-headings-20260928): headings take a self-hosted serif.
* BUG-737's conclusion "CJK never takes a serif" was overturned by product; its
* two guards stay: a declared family must load, and a heading must never fall
* through to a system 宋体 (Songti / STSong / SimSun / generic serif).
*/
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
/** Faces that ship with a target OS, so naming them costs no request. */
const SYSTEM_FACES = new Set([
// Apple
"PingFang SC",
"Helvetica Neue",
// Windows
"Microsoft YaHei",
"Segoe UI",
// Cross-platform fallbacks that are generic families or UA keywords.
"Georgia",
"Consolas",
"SFMono-Regular",
"JetBrains Mono",
]);
function stackValue(token: string): string {
const match = css.match(new RegExp(`^\\s*${token}:\\s*([^;]+);`, "m"));
assert.ok(match, `${token} must be declared in globals.css`);
return match![1];
}
function quotedFamilies(stack: string): string[] {
return [...stack.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
}
const serifCssUrl = new URL("../src/app/fonts/serif-sc/serif-sc.css", import.meta.url);
const serifCss = readFileSync(serifCssUrl, "utf8");
/** Families declared by the self-hosted @font-face list whose every src file exists. */
function selfHostedFamilies(): Set<string> {
const families = new Set<string>();
for (const face of serifCss.matchAll(/@font-face\s*\{([^}]*)\}/g)) {
const family = face[1].match(/font-family:\s*"([^"]+)"/)?.[1];
const sources = [...face[1].matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
assert.ok(family && sources.length > 0, "every @font-face in serif-sc.css names a family and a url()");
for (const source of sources) {
// 原值:url() 相对 serif-sc.css 解析(切片与 CSS 同目录,由 Next 打包)。
// 新值:以 / 开头的 url() 按 public/ 解析(切片搬到 public/fonts/serif-sc/,带内容哈希)。
// 原因:TASK-home-first-load-20260930 T3-b / BUG-1128——Next 打包的字体带每次部署都变的 ?dpl=,
// 每次发布都要重新下载;「url() 指向的文件必须存在」这条要求不变。
const file = source.startsWith("/") ? new URL(`../public${source}`, import.meta.url) : new URL(source, serifCssUrl);
assert.ok(existsSync(file), `${source} is declared in serif-sc.css but missing`);
}
families.add(family!);
}
return families;
}
/** Families vendored via next/font/local, keyed by the CSS variable they expose. */
function vendoredVariables(): string[] {
return [...layout.matchAll(/variable:\s*"(--[a-z0-9-]+)"/g)].map((m) => m[1]);
}
test("every quoted family in the UI font stacks is either vendored or a system face", () => {
// 原值:带引号的 family 只能属于 SYSTEM_FACES(next/font/local 的 Inter 以 var() 出现,不带引号)
// 新值:也可以是 serif-sc.css 里 @font-face 声明过、且 url() 文件都存在的 family
// 原因:TASK-serif-headings-20260928 标题改用自托管宋体;「声明的 family 必须真能加载」这半条原样保留,
// 只是把「可加载」扩展到自托管 @font-face。
const selfHosted = selfHostedFamilies();
for (const token of ["--font-display", "--font-body"]) {
const stack = stackValue(token);
for (const family of quotedFamilies(stack)) {
assert.ok(
SYSTEM_FACES.has(family) || selfHosted.has(family),
`${token} names "${family}", which is neither vendored (next/font/local or serif-sc.css) nor a known system face. `
+ "A family that cannot load is dead configuration: it makes the stack look intentional while the "
+ "browser silently falls through to the next entry. Vendor it into src/app/fonts/ with an OFL/SIL "
+ "licence file beside it, or drop the name. See BUG-737.",
);
}
}
});
test("the UI font stacks resolve through the vendored Inter variable", () => {
const vendored = vendoredVariables();
assert.ok(vendored.includes("--font-inter"), "layout.tsx must vendor Inter through next/font/local");
for (const token of ["--font-display", "--font-body"]) {
assert.match(
stackValue(token),
/var\(--font-inter, Inter\)/,
`${token} must route through the vendored Inter variable rather than naming a face it cannot load`,
);
}
});
// 原值:test("no CJK serif is reachable from the display stack")——栈里不得有 Songti / STSong / SimSun /
// Noto Serif / Source Han Serif,也不得以通用 serif 结尾(BUG-737:CJK 不用衬线)。
// 新值:--font-display 第一个 family 是自托管的 "Jyotisha Serif SC";系统宋体与通用 serif 仍然禁止。
// 原因:TASK-serif-headings-20260928 决策 1 推翻「CJK 不用衬线」,但「不得落到系统宋体」保留:
// 自托管切片没到或字不在收录范围时,标题退回无衬线,不退回 SimSun。
test("the display stack leads with the self-hosted serif and never reaches a system 宋体", () => {
const display = stackValue("--font-display");
assert.equal(quotedFamilies(display)[0], "Jyotisha Serif SC", "--font-display must lead with the self-hosted heading face");
assert.match(display.trim(), /^"Jyotisha Serif SC",/);
for (const banned of ["Songti", "STSong", "SimSun", "Noto Serif CJK SC", "Noto Serif SC", "Source Han Serif"]) {
assert.doesNotMatch(
display,
new RegExp(banned.replace(/ /g, "\\s")),
`--font-display must not reach "${banned}": a system 宋体 is exactly BUG-737 (SimSun on Windows)`,
);
}
// The generic `serif` keyword too — but not the `sans-serif` it is a suffix of.
assert.doesNotMatch(
display,
/(^|[\s,])serif\b/,
"--font-display must not end in the generic serif family; the UA default for CJK there is 宋体",
);
assert.match(display.trim(), /sans-serif$/, "an unloaded slice falls back to sans, not to a system serif");
assert.equal(
display.replace(/^"Jyotisha Serif SC",\s*/, ""),
stackValue("--font-body"),
"after the heading face, display falls through to exactly the body stack",
);
});
test("display rank comes from weight, not from a second family", () => {
// Written when display and body shared one sans stack (BUG-737). Headings now
// lead with "Jyotisha Serif SC", but until a slice arrives (and for characters
// outside its 6500) they still render in the body stack, so every display rule
// keeps carrying its own weight. The self-hosted face only ships SemiBold,
// declared as font-weight 500 700, so 500 and 600 both hit it.
const displayRules = [...css.matchAll(/font-family:\s*var\(--font-display\)/g)];
assert.ok(displayRules.length > 10, "sanity: the display token should still be in wide use");
assert.doesNotMatch(
css,
/font-family:\s*var\(--font-display\);\s*font-size:[^;]*;\s*font-weight:\s*400\s*;/,
"a --font-display rule still sits at weight 400; in the sans fallback that is body rank",
);
});