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:
1 parent
029633f00f
commit
8d82aab213
4 files changed
+247
-48
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user