// 构建后处理:把 next/font 生成的 Noto Sans SC 字体声明(6 字重 × ~101 个 // unicode-range 切片 ≈ 606 条 @font-face,压缩后 ~200 KiB)从渲染阻塞的 CSS // chunk 中抽到 public/fonts/noto-sans-sc.css,由 app/layout.tsx 注入的脚本异步 // 加载。next/font 机制原样保留:woff2 仍在 /_next/static/media/(immutable), // 度量校正 fallback 面与 Jakarta/JetBrains 声明仍留在阻塞 CSS。 // // 挂载点:package.json 的 postbuild(构建后必跑)与 predev(有上次构建产物时 // 让 dev 也有字体;无产物则静默跳过)。 // // 用法:node scripts/extract-font-css.mjs import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const staticDir = join(root, ".next", "static"); const outDir = join(root, "public", "fonts"); const outFile = join(outDir, "noto-sans-sc.css"); // 递归收集 .next/static 下所有产物 CSS(chunks 与 css 目录都扫,与 bundler 无关) function collectCssFiles(dir) { if (!existsSync(dir)) return []; const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, entry.name); if (entry.isDirectory()) out.push(...collectCssFiles(p)); else if (entry.isFile() && entry.name.endsWith(".css")) out.push(p); } return out; } // @font-face 内无嵌套花括号(压缩产物),逐块提取 const FACE_RE = /@font-face\{[^{}]*\}/g; // font-family 值精确等于 "Noto Sans SC"(产物中可能不带引号); // "Noto Sans SC Fallback" 因值不等而自然排除,必须留在阻塞 CSS 兜度量 function isNotoFace(block) { const m = block.match(/font-family\s*:\s*([^;}]+)/); if (!m) return false; return m[1].trim().replace(/^["']|["']$/g, "") === "Noto Sans SC"; } const cssFiles = collectCssFiles(staticDir); let extracted = []; const touched = new Map(); // file -> 重写后的内容 for (const file of cssFiles) { const content = readFileSync(file, "utf8"); const faces = content.match(FACE_RE); if (!faces) continue; const noto = faces.filter(isNotoFace); if (noto.length === 0) continue; // 抽出块里的相对 URL(../media/)改为绝对路径:CSS 将从 /fonts/ 引用 extracted.push(...noto.map((b) => b.replaceAll("../media/", "/_next/static/media/"))); let next = content; for (const b of noto) next = next.replace(b, ""); touched.set(file, next); } if (extracted.length === 0) { if (existsSync(outFile) || !existsSync(staticDir)) { // 已抽取过 / 上游产物无字体声明(无构建产物时的 predev 场景):静默跳过 console.log("[extract-font-css] 无可抽取的 Noto Sans SC 声明,跳过"); process.exit(0); } // 有构建产物却扫不到字体声明:bundler 行为可能变化,必须响亮失败 console.error( "[extract-font-css] 构建产物中未找到 Noto Sans SC @font-face,字体加载机制可能已变化,请人工检查 .next/static 下的 CSS" ); process.exit(1); } mkdirSync(outDir, { recursive: true }); writeFileSync(outFile, extracted.join("\n") + "\n"); for (const [file, content] of touched) writeFileSync(file, content); console.log( `[extract-font-css] 已抽出 ${extracted.length} 条 @font-face → public/fonts/noto-sans-sc.css(涉及 ${touched.size} 个 chunk)` );