#!/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}% 裁减)`);