构建流程与产物校验脚本
本文档介绍 Mizuki(基于 Astro 的站点工程)的构建流水线,以及构建完成后用于校验产物(dist/)的两个脚本:scripts/check-global-style-loading.mjs 与 scripts/check-font-loading.mjs。这些脚本在 pnpm build 链路中顺序执行,用于在部署前拦截"全局样式丢失""字体未加载"等静态回归问题。
Purpose and Scope
本页覆盖以下内容:
package.json中build脚本的完整链路与各阶段的职责划分;- 产物校验脚本的设计动机(为什么在构建后做 HTML/CSS 级别的断言);
scripts/check-global-style-loading.mjs的实现细节:HTML 解析、样式表收集、路径逃逸防护、页面断言表;scripts/check-font-loading.mjs的入口、npm 别名(check-fonts)以及在链路中的位置;- 失败模式:脚本以非零退出码(
throw new Error)中止构建,从而阻止 Pagefind 索引与部署。
以下主题属于兄弟页面,不在本页展开:
- 内容同步与
sync-content.js(见内容仓库相关页面); - 内容渲染管线与 Markdown 增强(
tests/markdown-enhancements.test.mjs,见内容渲染页面); - 部署与
AUTO_BUILD_TRIGGER(见部署页面); astro check/tsc --noEmit的类型检查细节(属于工具链配置页面)。
Overview
Mizuki 的 build 脚本并非单一的 astro build,而是一条由数据刷新、静态构建、产物校验、站内搜索索引组成的流水线(见 package.json 第 17 行):
node scripts/update-anime.mjs— 拉取/更新番剧数据;astro build— 生成静态站点到dist/;node scripts/check-global-style-loading.mjs— 校验关键页面加载的 CSS 是否包含必需的设计令牌与组件规则;pagefind --site dist— 基于dist/生成搜索索引;node scripts/check-font-loading.mjs— 校验字体加载。
设计意图很明确:Astro 的构建过程会压缩、合并并按页面切分 CSS,一次配置改动(例如把全局样式错误地改成作用域样式、或破坏了关键选择器)可能在构建阶段"成功",却在浏览器中表现为整站无样式。产物校验脚本在 CI/部署的最末端对真实 HTML 与真实 CSS 文本做字符串级断言,把这类静默回归变成硬失败。
其他与校验相关的 npm 别名:
check:astro check(Astro 诊断检查);check-fonts:node scripts/check-font-loading.mjs(可独立运行字体校验);type-check:tsc --noEmit;test:运行tests/下的回归测试(markdown 增强、布局、图片加载、音乐播放器加载、crypto)。
Architecture
流程要点:
predev/prebuild均为node scripts/sync-content.js || true,即内容同步失败不会阻塞构建(|| true吞掉非零退出码),而产物校验脚本失败则会硬失败——这是"软依赖(内容)"与"硬门禁(回归)"的刻意对比。check-global-style-loading.mjs读取的是dist/下的产物 HTML 与 CSS,而非源码src/,因此它能捕获"构建把样式丢掉了"这类问题。- 字体校验排在
pagefind之后,是流水线的最后一道门。
构建脚本链路解析
build 脚本的完整定义如下:
"build": "node scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs",Source: package.json
- 链路使用
&&串联,任意一步非零退出即中断后续步骤; pagefind --site dist位于两个校验脚本之间,意味着一旦样式校验失败,索引生成不会执行,部署产物不完整、不会误发布;- 相关别名脚本:
"check-fonts": "node scripts/check-font-loading.mjs"(第 24 行)允许在本地单独触发字体校验,无需完整构建。
其他会进入同一工具链的校验入口(供交叉参考):
"check": "astro check",
"type-check": "tsc --noEmit",
"lint": "biome check --write ./src",Source: package.json
preinstall 中还带有 npx only-allow pnpm,强制包管理器一致性,属于工具链的辅助约束。
产物校验脚本:check-global-style-loading.mjs
该脚本是一个无第三方依赖的 Node ESM 脚本,只用 node:fs/promises、node:path、node:url 三个内置模块完成全部工作,避免在 CI 校验环节引入构建工具依赖。
项目根与产物目录定位
1const projectRoot = path.resolve(
2 path.dirname(fileURLToPath(import.meta.url)),
3 "..",
4);
5const distDirectory = path.join(projectRoot, "dist");Source: scripts/check-global-style-loading.mjs
通过 import.meta.url 反解脚本自身位置再回溯一级得到项目根,保证脚本从任何工作目录(CI 的 bash -c、pnpm 的临时 cwd)调用时都能正确定位 dist/。这是构建脚本中常见的健壮性写法:不要信任 process.cwd()。
样式表收集:loadPageStyles
loadPageStyles(htmlPath) 负责把一个页面实际加载的 CSS 全部取回,包含两部分:
1const html = await readFile(path.join(distDirectory, htmlPath), "utf8");
2const stylesheetUrls = [...html.matchAll(/<link\b[^>]*>/gi)]
3 .map(([tag]) => ({
4 href: getAttribute(tag, "href"),
5 rel: getAttribute(tag, "rel"),
6 }))
7 .filter(
8 ({ href, rel }) =>
9 href && rel?.split(/\s+/).some((value) => value === "stylesheet"),
10 )
11 .map(({ href }) => href);
12
13const inlineStyles = [
14 ...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi),
15]
16 .map((match) => match[1])
17 .join("\n");Source: scripts/check-global-style-loading.mjs
实现细节与设计意图:
- 用正则
/<link\b[^>]*>/gi抓取<link>标签,再用getAttribute(tag, name)提取href/rel属性;rel以空白切分后判断是否包含stylesheet,兼容rel="preload stylesheet"之类的多值写法。 - 内联
<style>块与外链样式合并为同一个文本池loadedCss,后续断言只需对这一个字符串做includes检查——这保证了"规则在页面真正可见"这一语义,而不是"规则存在于某个源文件"。 - 外链样式用
Promise.all并行读取。
getAttribute 的正则同时兼容双引号、单引号与无引号三种属性写法:
1function getAttribute(tag, name) {
2 const match = tag.match(
3 new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"),
4 );
5 return match?.[1] ?? match?.[2] ?? match?.[3];
6}Source: scripts/check-global-style-loading.mjs
注意 \\b${name} 直接把参数插值进正则——调用方传入的都是字面量属性名(href、rel),不存在注入面;但若未来扩展该函数接收动态输入,需要先转义。
路径安全:拒绝逃逸 dist 目录
1const pathname = decodeURIComponent(stylesheetUrl.split(/[?#]/, 1)[0]);
2if (
3 /^(?:[a-z]+:)?\/\//i.test(pathname) ||
4 pathname.startsWith("data:")
5) {
6 return "";
7}
8
9const assetPath = path.resolve(
10 distDirectory,
11 pathname.startsWith("/") ? pathname.slice(1) : pathname,
12);
13const relativePath = path.relative(distDirectory, assetPath);
14if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
15 throw new Error(
16 `Stylesheet escapes dist directory: ${stylesheetUrl}`,
17 );
18}Source: scripts/check-global-style-loading.mjs
这段是脚本的边界处理核心:
- 先剥离
?query/#fragment,并对 URL 做decodeURIComponent,避免编码后的路径骗过前缀判断; - 绝对协议 URL(
http://、https://、//cdn...)与data:URI 直接返回空字符串——它们不是本地产物,跳过而非报错(外部 CSS 不在本脚本的守护范围内); - 以
/开头的站点根路径去掉前缀后与distDirectory拼接;相对路径直接拼接; - 用
path.relative逆运算验证结果仍位于dist/之内,若出现..前缀或绝对路径则抛错。这一步防住了href="../../etc/passwd"一类的路径穿越,使脚本即便读到异常 HTML 也不会越权读取仓库外文件。
页面断言表
脚本用一个声明式的 pages 数组描述"每个页面必须渲染出的标记与必须存在的 CSS 规则":
1const pages = [
2 {
3 name: "Homepage",
4 htmlPath: "index.html",
5 requiredMarkup: [],
6 requiredRules: [
7 ["--page-bg:", "page background variable"],
8 ["--card-bg:", "card background variable"],
9 ["--radius-large:", "shared radius variable"],
10 ["#banner-carousel", "banner layout styles"],
11 [".widget-container", "responsive widget styles"],
12 ],
13 },
14 {
15 name: "About page",
16 htmlPath: "about/index.html",
17 requiredMarkup: [["card-github", "rendered GitHub repository card"]],
18 requiredRules: [
19 [".card-github", "GitHub repository card styles"],
20 [".custom-md .image-grid", "extended Markdown layout styles"],
21 ],
22 },
23];Source: scripts/check-global-style-loading.mjs
断言分为两类:
requiredMarkup:产物 HTML 中必须包含的子串(例如card-github,验证自定义组件确实被渲染出来);requiredRules:页面加载的 CSS 文本中必须包含的子串——首页要求三个设计令牌(--page-bg:、--card-bg:、--radius-large:)与两个关键选择器(#banner-carousel、.widget-container);关于页要求.card-github与扩展 Markdown 的.custom-md .image-grid规则。
键值对的第二个元素是"人类可读描述",失败时直接进入错误信息,这让 CI 日志可读而非只有裸 token。
校验循环与失败输出
1for (const page of pages) {
2 const { html, loadedCss, stylesheetUrls } = await loadPageStyles(page.htmlPath);
3 const missingMarkup = page.requiredMarkup
4 .filter(([token]) => !html.includes(token))
5 .map(([, description]) => description);
6 const missingRules = page.requiredRules
7 .filter(([token]) => !loadedCss.includes(token))
8 .map(([, description]) => description);
9
10 if (missingMarkup.length > 0 || missingRules.length > 0) {
11 const loadedStylesheets = stylesheetUrls.join(", ") || "none";
12 const missing = [
13 ...missingMarkup.map((description) => `${description} markup`),
14 ...missingRules,
15 ];
16 throw new Error(
17 `${page.name} is missing: ${missing.join(", ")}. ` +
18 `Loaded stylesheets: ${loadedStylesheets}`,
19 );
20 }
21}Source: scripts/check-global-style-loading.mjs
失败信息同时列出"缺了什么"与"实际加载了哪些样式表"(Loaded stylesheets: ...,为空时输出 none),这两条线索足以快速定位是样式表没有被 <link> 引用,还是被引用但内容缺失。顶层 throw 使进程以非零码退出,&& 链随即中断,pagefind 与字体校验不会执行。全部通过时输出:
Verified homepage styles across N linked stylesheet(s).
Verified about page styles across N linked stylesheet(s).Source: scripts/check-global-style-loading.mjs
字体校验:check-font-loading.mjs
scripts/check-font-loading.mjs 在本页范围仅作入口级说明:
- 它是
build链路的最后一步(位于pagefind之后); - 它有独立的 npm 别名
check-fonts,可在不完整构建的情况下单独运行; - 与样式校验脚本相同的失败语义:任何断言不满足即抛错、构建失败。
Core Flow
顺序背后的原因:样式校验必须发生在 astro build 之后(产物才存在)、pagefind 之前(避免为坏产物生成索引);字体校验放在最后,作为部署前的最终门禁。
Configuration Options
校验脚本本身不读取任何外部配置文件,其"配置面"就是源码内的两张声明式断言表(见上文 pages 数组)。与构建/校验链路相关的可配置项都来自 package.json scripts:
| 选项 / 脚本 | 类型 | 默认值(命令) | 说明 |
|---|---|---|---|
build | npm script | node scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs | 完整构建+校验流水线,&& 串联,任一步失败即中断 |
prebuild | npm script | node scripts/sync-content.js || true | 构建前内容同步,软失败(不阻塞构建) |
predev | npm script | node scripts/sync-content.js || true | 开发服务器前的内容同步 |
check | npm script | astro check | Astro 诊断检查(不构建产物) |
type-check | npm script | tsc --noEmit | TypeScript 类型检查 |
check-fonts | npm script | node scripts/check-font-loading.mjs | 独立运行字体加载校验 |
lint | npm script | biome check --write ./src | Biome 检查并自动修复 src/ |
format | npm script | biome format --write ./src | Biome 格式化 src/ |
test | npm script | node --experimental-strip-types --test tests/*.test.mjs && node tests/crypto.test.mjs | 回归测试(markdown 增强、布局、图片加载、音乐播放器加载、crypto) |
preinstall | npm script | npx only-allow pnpm | 强制使用 pnpm |
| 校验目标目录 | 脚本内常量 | path.join(projectRoot, "dist") | check-global-style-loading.mjs 固定读取 dist/,不可通过环境变量覆盖 |
| 校验页面清单 | 脚本内常量 | pages 数组 | 首页与 about 页两页断言;新增页面需改源码 |
API Reference
以下接口均为模块内部函数(未导出),列出以便阅读与扩展脚本时参考。
getAttribute(tag: string, name: string): string | undefined
从一段 HTML 标签文本中提取指定属性的值。
Parameters:
tag(string): 完整的标签文本,如<link rel="stylesheet" href="/a.css">name(string): 属性名,如"href"、"rel"(会被直接插入正则,只应传字面量)
Returns: 属性值字符串;属性不存在或值为空时返回 undefined。兼容双引号、单引号、无引号三种形式,匹配大小写不敏感。
Source: scripts/check-global-style-loading.mjs
loadPageStyles(htmlPath: string): Promise<{ html: string; loadedCss: string; stylesheetUrls: string[] }>
读取 dist/ 下指定 HTML 页面,并收集该页面实际加载的全部 CSS 文本。
Parameters:
htmlPath(string): 相对dist/的 HTML 路径,如"index.html"、"about/index.html"
Returns:
html: 页面 HTML 原文;loadedCss: 内联<style>内容与所有本地外链样式表内容的拼接(以换行连接);stylesheetUrls: 页面引用的本地样式表 URL 列表(含站点根相对路径与相对路径)。
Throws:
Error: 样式表路径经path.relative校验逃逸出dist/时抛出Stylesheet escapes dist directory: <url>。
Source: scripts/check-global-style-loading.mjs
Usage Examples
新增一个需要校验的页面
直接在 pages 数组中追加条目即可,无需改动任何控制流。例如为关于页追加"GitHub 卡片标记必须渲染"的断言:
1{
2 name: "About page",
3 htmlPath: "about/index.html",
4 requiredMarkup: [["card-github", "rendered GitHub repository card"]],
5 requiredRules: [
6 [".card-github", "GitHub repository card styles"],
7 [".custom-md .image-grid", "extended Markdown layout styles"],
8 ],
9},Source: scripts/check-global-style-loading.mjs
单独运行字体校验(不触发完整构建)
pnpm run check-fontsSource: package.json
运行完整构建流水线
pnpm run buildSource: package.json
Failure Modes, Edge Cases & Concurrency
- 产物缺失 / HTML 不存在:
loadPageStyles内的readFile对dist/index.html等路径直接抛ENOENT,进程非零退出。这天然覆盖"忘了先astro build就单独跑校验"的误用场景。 - 样式表逃逸 dist:
path.relative双重判断(..前缀或绝对路径)后抛错,见上文路径安全小节。这是显式的安全边界,而非静默跳过。 - 外部 / data: 样式:协议 URL 与
data:URI 返回空字符串,被排除在断言池之外——脚本只保证本地产物内联 + 本地外链样式的正确性,不对外部 CDN CSS 负责。 - query / fragment / URL 编码:
split(/[?#]/, 1)剥离查询串与锚点,decodeURIComponent解码后再定位文件,避免%2e%2e之类的编码绕过。 - 正则解析而非 DOM 解析:
<link>/<style>用正则提取,未使用 HTML 解析器。对 Astro 生成的规范产物足够可靠;若未来产物中出现非常规写法(例如属性内含>),需要评估是否引入真正的解析器。 - 并发模型:外链样式表用
Promise.all并行读取,页面之间串行for...of顺序校验。脚本为单次构建的末端门禁,无跨进程共享状态,不存在并发一致性问题。 - 软失败与硬失败的边界:
prebuild的|| true使内容同步失败被吞掉,而两个校验脚本失败会中断整条&&链。这是有意为之:内容缺失可以容忍(站点仍可构建),样式/字体回归不可容忍(页面视觉损坏)。 - 失败可观测性:错误信息同时给出缺失项的人类可读描述与实际加载的样式表列表,为 CI 日志提供两维线索。
Performance / Operational Notes & Extension Points
- 零第三方依赖:脚本仅用
node:fs/promises、node:path、node:url,CI 环境无需额外安装步骤,也不会因依赖漂移而失效。 - 工作目录无关:基于
import.meta.url定位项目根,可被任何 CI runner / 任意 cwd 调用。 - 成本模型:断言为纯字符串
includes,两页 + 少量样式表的开销可忽略,不构成构建瓶颈;瓶颈仍在astro build与pagefind。 - 扩展点 1 — 新增页面断言:在
pages数组追加条目(name/htmlPath/requiredMarkup/requiredRules四字段),控制流零改动。 - 扩展点 2 — 新增校验维度:
loadPageStyles返回的html、loadedCss、stylesheetUrls三个产物可以支撑新的断言类型(如"必须引用 N 个样式表""禁止内联超大样式块")。 - 扩展点 3 — 独立脚本复用:
check-font-loading.mjs通过check-fonts别名独立可跑的模式,可作为后续新增校验脚本(如图片加载、脚本加载)的模板。 - 运维提示:本地排查样式断言失败时,先看错误信息中的
Loaded stylesheets列表——为none通常意味着<link>没有被注入;有列表但缺规则,则问题在 CSS 生成/切分环节。
Tests
package.json 的 test 脚本运行 tests/markdown-enhancements.test.mjs、tests/layout-regressions.test.mjs、tests/image-loading.test.mjs、tests/music-player-loading.test.mjs 与 node tests/crypto.test.mjs(使用 node --experimental-strip-types --test)。这些测试与产物校验脚本互为补充:测试在源码层锁定行为,check-*-loading.mjs 在产物层锁定渲染结果。两者的具体覆盖范围属于测试页面与内容渲染页面的范围,此处仅说明它们在工具链中的位置。
Source: package.json
Related Links
- package.json — 构建、校验、测试脚本的总入口
- scripts/check-global-style-loading.mjs — 全局样式加载校验脚本
- scripts/check-font-loading.mjs — 字体加载校验脚本
- docs/AUTO_BUILD_TRIGGER.md — 自动构建触发(部署侧)
- docs/CONTENT_RENDERING.md — 内容渲染管线(Markdown 增强的行为定义)