Repository Wiki
LyraVoid/Mizuki

构建流程与产物校验脚本

本文档介绍 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 行):

  1. node scripts/update-anime.mjs — 拉取/更新番剧数据;
  2. astro build — 生成静态站点到 dist/;
  3. node scripts/check-global-style-loading.mjs — 校验关键页面加载的 CSS 是否包含必需的设计令牌与组件规则;
  4. pagefind --site dist — 基于 dist/ 生成搜索索引;
  5. 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

Loading diagram...

流程要点:

  • predev / prebuild 均为 node scripts/sync-content.js || true,即内容同步失败不会阻塞构建(|| true 吞掉非零退出码),而产物校验脚本失败则会硬失败——这是"软依赖(内容)"与"硬门禁(回归)"的刻意对比。
  • check-global-style-loading.mjs 读取的是 dist/ 下的产物 HTML 与 CSS,而非源码 src/,因此它能捕获"构建把样式丢掉了"这类问题。
  • 字体校验排在 pagefind 之后,是流水线的最后一道门。

构建脚本链路解析

build 脚本的完整定义如下:

json
"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 行)允许在本地单独触发字体校验,无需完整构建。

其他会进入同一工具链的校验入口(供交叉参考):

json
"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 校验环节引入构建工具依赖。

项目根与产物目录定位

js
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 全部取回,包含两部分:

js
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 的正则同时兼容双引号、单引号与无引号三种属性写法:

js
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 目录

js
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

这段是脚本的边界处理核心:

  1. 先剥离 ?query / #fragment,并对 URL 做 decodeURIComponent,避免编码后的路径骗过前缀判断;
  2. 绝对协议 URL(http://、https://、//cdn...)与 data: URI 直接返回空字符串——它们不是本地产物,跳过而非报错(外部 CSS 不在本脚本的守护范围内);
  3. 以 / 开头的站点根路径去掉前缀后与 distDirectory 拼接;相对路径直接拼接;
  4. 用 path.relative 逆运算验证结果仍位于 dist/ 之内,若出现 .. 前缀或绝对路径则抛错。这一步防住了 href="../../etc/passwd" 一类的路径穿越,使脚本即便读到异常 HTML 也不会越权读取仓库外文件。

页面断言表

脚本用一个声明式的 pages 数组描述"每个页面必须渲染出的标记与必须存在的 CSS 规则":

js
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。

校验循环与失败输出

js
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 与字体校验不会执行。全部通过时输出:

text
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

Loading diagram...

顺序背后的原因:样式校验必须发生在 astro build 之后(产物才存在)、pagefind 之前(避免为坏产物生成索引);字体校验放在最后,作为部署前的最终门禁。

Configuration Options

校验脚本本身不读取任何外部配置文件,其"配置面"就是源码内的两张声明式断言表(见上文 pages 数组)。与构建/校验链路相关的可配置项都来自 package.json scripts:

选项 / 脚本类型默认值(命令)说明
buildnpm scriptnode scripts/update-anime.mjs && astro build && node scripts/check-global-style-loading.mjs && pagefind --site dist && node scripts/check-font-loading.mjs完整构建+校验流水线,&& 串联,任一步失败即中断
prebuildnpm scriptnode scripts/sync-content.js || true构建前内容同步,软失败(不阻塞构建)
predevnpm scriptnode scripts/sync-content.js || true开发服务器前的内容同步
checknpm scriptastro checkAstro 诊断检查(不构建产物)
type-checknpm scripttsc --noEmitTypeScript 类型检查
check-fontsnpm scriptnode scripts/check-font-loading.mjs独立运行字体加载校验
lintnpm scriptbiome check --write ./srcBiome 检查并自动修复 src/
formatnpm scriptbiome format --write ./srcBiome 格式化 src/
testnpm scriptnode --experimental-strip-types --test tests/*.test.mjs && node tests/crypto.test.mjs回归测试(markdown 增强、布局、图片加载、音乐播放器加载、crypto)
preinstallnpm scriptnpx 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 卡片标记必须渲染"的断言:

js
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

单独运行字体校验(不触发完整构建)

bash
pnpm run check-fonts

Source: package.json

运行完整构建流水线

bash
pnpm run build

Source: 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