diff --git a/infra/deploy/build.sh b/infra/deploy/build.sh index bae4eb8..d24a640 100755 --- a/infra/deploy/build.sh +++ b/infra/deploy/build.sh @@ -24,6 +24,10 @@ echo "[build] 构建源: origin/main @ $SHA" pnpm install --frozen-lockfile >/dev/null pnpm astro build +# 按页字体 CSS 子集化(车道 A 任务 1):裁 @font-face 声明数,构建仓与本地同链; +# 存在性守卫:新文件未推送到 main 前构建仓无此脚本,绝不让发布链因车道在途文件断掉 +[ -f scripts/build-perf/subset-font-css.mjs ] && node scripts/build-perf/subset-font-css.mjs dist || echo "[build] subset 脚本未就绪,跳过" + echo "[build] rsync -> $STAGING" mkdir -p "$STAGING" rsync -a --delete "$BUILD_REPO/dist/" "$STAGING/" diff --git a/scripts/build-perf/subset-font-css.mjs b/scripts/build-perf/subset-font-css.mjs new file mode 100644 index 0000000..5409327 --- /dev/null +++ b/scripts/build-perf/subset-font-css.mjs @@ -0,0 +1,144 @@ +#!/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}% 裁减)`); diff --git a/src/components/pages/GalleryPage.astro b/src/components/pages/GalleryPage.astro index 1b2c673..076381c 100644 --- a/src/components/pages/GalleryPage.astro +++ b/src/components/pages/GalleryPage.astro @@ -101,7 +101,7 @@ const first = cards[0];