fix(fonts): 字体切换真加载——按内容诉求注入,含瞬间排版类作用域缺口(5i T1)

用户实测「字族渲染没有成功,尤其是字体的切换」。根因不是字体没做,是加载门禁错:
BaseLayout 把 serif/kai/kuaile/hwmc 四份字体 CSS 全关在 `track === 'reading'` 分支里,
技术轨文章与全站页面(默认 track='tech')从不加载它们——于是 `.font-kai` 计算值虽是
`"LXGW WenKai Screen"`,但该 @font-face 根本不在文档字体集里,浏览器静默回退系统宋体。

- 新增 `src/lib/font-needs.ts`:`fontsFromText()` 纯函数 + `fontsForPage(pathname, track)`
  按路径取该页内容(文章正文 / home.yaml / about.yaml / opensource.yaml / 全部瞬间文本)
  与页面静态诉求(reading→serif;书架标题写死楷体→kai 等)算出所需字族集合
- BaseLayout 改为逐项按需注入(非阻塞 preload+onload 写法保留),noscript 同步;
  汇文明朝仍走 public 手工切片路径,network/font-display 未动
- **顺手补掉 5h 遗留的用户可见缺口**:瞬间富文本容器 class 是 `mom-text`,而全局排版类
  规则只覆盖 `.prose-*/.rich/.prose-body` → 瞬间里写 `font-kai`/`c-teal` 一直只有 class
  没有样式(新瞬间正是这么写的,用户看到的就是"没变色没换字")。global.css 给 38 处
  选择器组补 `.mom-text`
- 另修自写 bug:en 路由切分 `slice(3)` 残留前导 `/` 导致 en 页按需字体全部落空,改 `slice(4)`

验证: 构建 exit 0(content-lint 51 页 0/0/0);E2E 46/46;Playwright 实测
      技术轨文章 document.fonts 命中 LXGW WenKai Screen / WF-Hwmc / ZCOOL KuaiLe /
      Noto Serif SC,`.font-kai` 计算族 = LXGW 且 check=true;
      首页仍为基线(零增量);瞬间页因有 font-kai 内容而命中 LXGW;
      书评页只拉 serif(41 片,0 kai 0 汇文)

Spec: 2026-11-02_blog-5i-fonts-polish (T1)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-03 11:28:51 +08:00
1 parent 029633f00f
commit 8d82aab213
4 files changed
+247 -48

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
/**
* 字体按需加载(Phase 5i T1):把「轨道门禁」换成「内容诉求驱动」。
*
* ## 为什么要有这个文件(2026-11-02 用户实测踩的坑,勿删)
*
* 旧实现把 serif / kai / kuaile / 汇文四份字体 CSS 全关在
* `track === 'reading'` 分支里(BaseLayout)。但技术轨才是全站默认
* (`track` 缺省 `tech`),于是出现一类**最隐蔽的失效**:
*
* - 技术文里写 `<span class="font-kai">`:class 在、`font-family` 计算值也对
* (`"LXGW WenKai Screen", …`),但那份字体 CSS 从未加载 →
* `document.fonts` 里根本没有这个族 → 浏览器静默回退系统宋体。
* - 它不报错、不报警,用户看到的现象只有一句:「字体没换」。
*
* 教训:字体是「外部资源 + 静默回退」,门禁不能按页面所属栏目拍脑袋,
* 只能看这一页**真正声明的诉求**——扫出来的字族才加载,扫不到就不加载。
*
* ## 本文件的两类输入
*
* 1. **内容扫描**:正文 markdown、站点单例 yaml(页面文案/块)、瞬间文本里
* 出现的类名(含 renderRich 白名单放行的排版类)。
* 2. **页面静态需求**:某些页面把字族写死在组件里(书架卡标题用楷体、
* 关于页的 quote 块用 `.quote-line` 楷体),内容里扫不到类名,也要算。
*
* @module font-needs
*/
import { getCollection, getEntry } from 'astro:content';
/** 可独立加载的字体 CSS 包(sans / mono 常驻,不在此列) */
export type FontKey = 'serif' | 'kai' | 'kuaile' | 'huiwen';
/**
* 类名 → 需要的字体包。
*
* 只列「会让浏览器拉 webfont」的类;以下三类**不产生**字体请求,故不入表:
* - `font-hei-sys`:首选 Noto Sans SC(常驻已加载)
* - `font-fangsong`:纯系统回退(无 OFL 自托管包,docs 已注明)
* - 普通 `font-sans` / `font-mono`:常驻
*
* 另外把两个「文学排版类」也计入,因为它们各自改字体:
* `quote-line` → 楷体;`drop-cap` → 首字衬线。
*/
export const FONT_CLASS_NEEDS: Record<string, FontKey[]> = {
'font-serif': ['serif'],
'font-song': ['serif'], // 首选 "Noto Serif SC Variable"(= serif 包)
'font-kai': ['kai'],
'font-kai-sys': ['kai'], // 首选 "LXGW WenKai Screen"(= kai 包)
'font-huiwen': ['huiwen'],
'font-kuaile': ['kuaile'],
'font-art-kuaile': ['kuaile'], // 同一个 ZCOOL KuaiLe 族
'quote-line': ['kai'],
'drop-cap': ['serif'],
};
/** 拼接成一条正则:`\bfont-kai\b` 这类词边界匹配(避免 font-kuaile 误配 font-kuaile-x) */
const CLASS_PATTERN = new RegExp(
`\\b(?:${Object.keys(FONT_CLASS_NEEDS)
.map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|')})\\b`,
'g',
);
/**
* 从若干段文本里探测需要加载的字体包(纯函数,无 IO)。
*
* @param texts 页面上可能出现排版类的原始文本(正文 markdown / yaml 序列化 / 瞬间文本等)
* @returns 命中的字体包集合;空集表示本页只需常驻的 sans + mono
*
* @example
* fontsFromText(['<span class="font-kai">字</span>']); // Set { 'kai' }
* fontsFromText(['纯文本没有类名']); // Set {}
*/
export function fontsFromText(texts: (string | undefined | null)[]): Set<FontKey> {
const out = new Set<FontKey>();
for (const text of texts) {
if (!text) continue;
for (const hit of text.matchAll(CLASS_PATTERN)) {
for (const key of FONT_CLASS_NEEDS[hit[0]] ?? []) out.add(key);
}
}
return out;
}
/** 把 yaml 单例的 data 摊平成可扫描文本(序列化即可:类名都是字符串字面量) */
const flatten = (data: unknown): string => {
try {
return JSON.stringify(data);
} catch {
return '';
}
};
/** 路径归一:去 query/hash、去 index.html、保留结尾斜杠,便于前缀匹配 */
const normalizePath = (pathname: string): string => {
const p = pathname.split(/[?#]/)[0].replace(/\/index\.html$/, '/').replace(/\.html$/, '/');
const withSlash = p.endsWith('/') ? p : `${p}/`;
return withSlash.startsWith('/') ? withSlash : `/${withSlash}`;
};
/**
* 解析一个页面需要哪些字体包(构建期,供 BaseLayout 注入)。
*
* 规则 = **页面静态需求 ∪ 内容扫描结果**:
* - 书评轨(`track='reading'`)正文默认字族就是衬线 → serif 必载;
* - 书架页标题写死楷体、英文书架写死衬线 → 该两页固定 kai(+en 加 serif);
* - 关于页的 quote 块写死 `.quote-line` → 有 quote 块即需 kai;
* - 其余全部靠内容扫描(文章正文 / 页面单例 yaml / 瞬间文本)。
*
* 未识别的页面返回空集:只加载常驻 sans + mono,绝不凭栏目名猜字体。
*
* @param pathname 当前页面路径(`Astro.url.pathname`)
* @param track 正文轨(只影响书评默认衬线这一条静态需求)
* @returns 需要加载的字体包集合
*/
export async function fontsForPage(pathname: string, track: 'reading' | 'tech' = 'tech'): Promise<Set<FontKey>> {
const path = normalizePath(pathname);
const isEn = path.startsWith('/en/');
// 去掉语言前缀与开头斜杠,得到形如 'archives/x/' 的路由键。
// 注意 slice(4)('/en/' 四个字符)而不是 slice(3):后者会残留一个前导 '/',
// 导致 en 页全部匹配不上(2026-11-03 实测:en/moments、en/archives/* 的按需字体全丢)。
const route = isEn ? path.slice(4) : path.slice(1);
const needs = new Set<FontKey>();
// ① 书评轨:正文默认字体就是衬线(`.prose-reading { font-family: var(--font-serif) }`)
if (track === 'reading') needs.add('serif');
// ② 文章页:扫正文 markdown(中文缺译时回退中文正文,与页面的回落一致)
const article = route.match(/^archives\/([^/]+)\/$/);
if (article) {
const slug = article[1];
const enEntry = isEn ? await getEntry('postsEn', slug) : undefined;
const entry = enEntry ?? (await getEntry('posts', slug));
const texts = [entry?.body ?? ''];
for (const key of fontsFromText(texts)) needs.add(key);
return needs;
}
// ③ 书架页:书脊卡标题写死楷体(BooksPage.astro),英文书架再写死衬线
if (route === 'books/') {
needs.add('kai');
if (isEn) needs.add('serif');
return needs;
}
// ④ 首页 / 关于页 / 开源页:扫各自单例(页面文案与块内容都走 renderRich 白名单)
if (route === '') {
const home = await getEntry('siteHome', 'home');
for (const key of fontsFromText([flatten(home?.data)])) needs.add(key);
return needs;
}
if (route === 'about/') {
const about = await getEntry('siteAbout', 'about');
const text = flatten(about?.data);
for (const key of fontsFromText([text])) needs.add(key);
// quote 块在组件里写死 `.quote-line`(楷体),内容里没有类名字面量 → 按块型补
if (text.includes('"type":"quote"')) needs.add('kai');
return needs;
}
if (route === 'opensource/') {
const os = await getEntry('siteOpenSource', 'opensource');
for (const key of fontsFromText([flatten(os?.data)])) needs.add(key);
return needs;
}
// ⑤ 瞬间页:T2 起瞬间正文走富文本管线,可能带排版类 → 扫全部瞬间文本
if (route === 'moments/') {
const moments = await getCollection('moments');
for (const key of fontsFromText(moments.map((m) => m.data.text))) needs.add(key);
return needs;
}
// ⑥ 其余页面(归档列表 / 图库 / 终端 / 栈 / 纸面…):只吃常驻 sans + mono
return needs;
}