Files
wait4xandGLM-5.3 1fdd0cc162 perf(build): 按页字体 CSS 子集化 + gallery 响应式图片阶梯
- subset-font-css.mjs post-build:扫每页文本(含 attr)+ ui.ts 全
  词条 + Latin/标点永保三防线,命中 unicode-range 才留 @font-face,
  子集按内容哈希去重共享,孤儿原 CSS 清出——全站字体 CSS 6.7→2.6MB
  (61%),mian-sha 声明 101→48/家族
- build.sh 挂载(存在性守卫:车道在途文件不断发布链——20:31 事故
  纪律)
- GalleryPage srcset 阶梯 320/560/720→480/800/1200(首图
  fetchpriority 维持)
- Lighthouse:mian-sha 61→78、gallery 74→79、en 70→83、terminal
  74→88(A11y/BP/SEO 持平;unused-css 370→36KB)
- 过程坑:v1 重写 href 丢前导斜杠致嵌套页 404——v2 只换文件名

车道:op-gitea(A/B 渲染等价证明:serif 916 字 0-miss;分数复采
入 /tmp/e2e-shots/lighthouse-ap/);收编验收:serif 字形
fonts.check 实测 true、srcset 产出、E2E 31/31

验证: pnpm astro build + subset 跑通 35 页;E2E 31/31 PASS;
      staging 部署后 stamp 复核

Spec: none (构建管线性能二连:post-build 子集化+图片阶梯)

Co-authored-by: GLM-5.3
2026-10-25 21:05:15 +08:00

145 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
/**
* 按页字体 CSS 子集化(车道 A 任务 1):post-build 对 dist 每页扫描文本,
* 仅保留 unicode-range 命中页面实际用字的 @font-face 声明。
*
* 为什么裁声明而非字体文件:woff2 切片本就由浏览器按 unicode-range 按需拉取,
* 裁掉的是 CSS 字节与主线程解析成本(@fontsource CJK 每家族 ~100 条全量下发)。
*
* 动态文本三道防线(宁多勿缺):
* ① aria-label/placeholder/title 等 attr 一并入扫(去掉 script/style/svg 后全串扫描)
* ② src/i18n/ui.ts 全部词条字符并入(命令面板/常驻 UI)
* ③ Basic Latin/Latin-1/通用标点区(U+0020-024F, U+2000-206F)的 @font-face 永保
* ——终端输出/命令面板的动态拉丁内容不可枚举
*/
import { readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, existsSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { createHash } from 'node:crypto';
const distRoot = process.argv[2] ?? 'dist';
const repoRoot = process.cwd();
const SAFE_LO = 0x20, SAFE_HI = 0x24f; // Latin + Latin-1 + 拉丁扩展
const SAFE2_LO = 0x2000, SAFE2_HI = 0x206f; // 通用标点
const skipRe = /<(script|style|svg)\b[\s\S]*?<\/\1>/gi;
/** 解析 unicode-range 值 → [lo,hi] 数组(含 U+3??? 通配形态) */
function parseRanges(spec) {
const out = [];
for (const tok of spec.split(',')) {
const m = tok.trim().match(/^u\+([0-9a-f?]+)(?:-([0-9a-f]+))?$/i);
if (!m) continue;
if (m[1].includes('?')) {
const q = m[1].split('?').length - 1;
const base = parseInt(m[1].replace(/\?/g, '0'), 16);
out.push([base, base + (1 << (4 * q)) - 1]);
} else {
const lo = parseInt(m[1], 16);
const hi = m[2] ? parseInt(m[2], 16) : lo;
out.push([lo, hi]);
}
}
return out;
}
/** 收集"需要保真渲染"的字符集(CJK 等非安全区字符才有判定价值) */
function collectChars(text) {
const s = new Set();
for (const ch of text) {
const c = ch.codePointAt(0);
if (c > SAFE2_HI) s.add(c); // 安全区以下/之内的拉丁与标点走永保规则
}
return s;
}
function rangeHit(ranges, chars) {
for (const [lo, hi] of ranges) {
if ((lo <= SAFE_HI && hi >= SAFE_LO) || (lo <= SAFE2_HI && hi >= SAFE2_LO)) return true; // 防线③
for (const c of chars) if (c >= lo && c <= hi) return true;
}
return false;
}
function walkHtml(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walkHtml(p));
else if (e.name.endsWith('.html')) out.push(p);
}
return out;
}
// ---------- 主流程 ----------
const uiPath = join(repoRoot, 'src/i18n/ui.ts');
const uiChars = existsSync(uiPath) ? collectChars(readFileSync(uiPath, 'utf8')) : new Set();
const htmls = walkHtml(distRoot);
// 字体 CSS 识别:含 unicode-range 的 @font-face
const astroDir = join(distRoot, '_astro');
const fontCss = readdirSync(astroDir).filter((f) => f.endsWith('.css'))
.filter((f) => {
const t = readFileSync(join(astroDir, f), 'utf8');
return t.includes('@font-face') && t.includes('unicode-range');
});
const parsed = new Map(); // cssFile → { blocks: [{block, ranges}], other }
for (const f of fontCss) {
const text = readFileSync(join(astroDir, f), 'utf8');
const blocks = [];
let idx = 0;
const re = /@font-face\s*\{[^}]*\}/g;
let m;
while ((m = re.exec(text))) {
const block = m[0];
const ur = block.match(/unicode-range:\s*([^;}]+)/i);
blocks.push({ block, ranges: ur ? parseRanges(ur[1]) : null });
idx = m.index + m[0].length;
}
// 非声明部分(注释/其他规则)原样保留一份
const other = text.replace(re, '\u0000').split('\u0000');
parsed.set(f, { blocks, other });
}
const written = new Map(); // 内容 hash → 新文件名(同子集去重共享)
let totalBefore = 0, totalAfter = 0;
for (const html of htmls) {
const raw = readFileSync(html, 'utf8');
const text = raw.replace(skipRe, ' ');
const chars = collectChars(text);
for (const c of uiChars) chars.add(c);
if (chars.size === 0) continue;
let out = raw;
for (const f of fontCss) {
const hrefs = [...new Set(raw.match(new RegExp(`[^"'\\s]*${f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^"'\\s]*`, 'g')) ?? [])];
if (hrefs.length === 0) continue;
const { blocks, other } = parsed.get(f);
const keep = blocks.filter((b) => !b.ranges || rangeHit(b.ranges, chars));
if (keep.length === blocks.length) continue; // 全命中无需子集
const body = other[0] ?? '';
const css = keep.map((b) => b.block).join('') + body;
const h = createHash('sha256').update(css).digest('hex').slice(0, 8);
const name = `fs-${f.replace(/\.css$/, '').replace(/[^a-z0-9-]/gi, '')}-${h}.css`;
if (!written.has(h)) {
writeFileSync(join(astroDir, name), css);
written.set(h, name);
}
for (const href of hrefs) out = out.split(href).join(href.replace(f, written.get(h)));
totalBefore += readFileSync(join(astroDir, f), 'utf8').length;
totalAfter += css.length;
}
if (out !== raw) writeFileSync(html, out);
}
// 原字体 CSS 已无引用则清出 dist(构建仓 rsync 整目录,孤儿只会白占体积)
for (const f of fontCss) {
const stillUsed = htmls.some((h) => readFileSync(h, 'utf8').includes(f));
if (!stillUsed) unlinkSync(join(astroDir, f));
}
const pages = htmls.length;
console.log(`[subset-font-css] pages=${pages} fontCss=${fontCss.length} ` +
`subsets=${written.size} bytes ${totalBefore} -> ${totalAfter} ` +
`(${totalBefore ? Math.round((1 - totalAfter / totalBefore) * 100) : 0}% 裁减)`);