本章要回答的问题: Astro 已经写出 dist 后,怎样确认发布目录没有越界文件、每个页面可定位、源码文字没有被导航标记改坏?
调用者是 site/package.json 的 build 脚本末尾的 node scripts/verify.mjs。输入是 dist/、.generated/sources.json 以及构建生成的 HTML;输出是成功日志,或第一个失败不变量的异常和非零退出码。它不是浏览器防火墙、HTTP 路由器或鉴权系统。
1. 收集静态产物并准备拒绝规则
site/scripts/verify.mjs · L1–L6 · 打开完整源码
此文件的私有运行环境规则已在公开展示中隐藏;行号保持对应本机源码。
/* Public view: redacted lines; not a complete executable copy. */ import fs from 'node:fs';
import path from 'node:path';
function files(dir) { return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(path.join(dir,e.name)):[path.join(dir,e.name)]); }
const all=files('dist');
const html=all.filter(f=>f.endsWith('.html'));
/* [REDACTED: private operational rule] */
脚本使用 Node 的 fs 与 path。files(dir) 递归读取目录条目:目录继续递归,普通文件加入路径数组,flatMap 把子目录结果摊平。all 收集整个 dist,html 再筛出以 .html 结尾的文件。
denied 是内容拒绝名单,匹配本机绝对路径、特定构建环境片段和令牌名称。它服务于发布边界,而不是做替换;后面命中即抛错。脚本默认在 site 目录运行,所以 dist、dist/_headers 和 .generated 都按该工作目录解析。
2. 文件名、内容和 sitemap 三层检查
site/scripts/verify.mjs · L7–L12 · 打开完整源码
for(const f of all){
if(/\.(wasm|log|jsonl|map|env|md)$/.test(f))throw new Error(`Unexpected published file: ${f}`);
const text=fs.readFileSync(f,'utf8');
for(const re of denied)if(re.test(text))throw new Error(`Private detail: ${f} (${re})`);
}
if(all.some(f=>/sitemap/i.test(f)))throw new Error('Do not publish a sitemap');
每个静态文件先检查扩展名,.wasm、日志、jsonl、source map、env 和 Markdown 都被拒绝。随后统一按 UTF-8 读取正文,用每条 denied 规则扫描;错误只报告文件和规则,不把匹配到的秘密写进日志。
接着检查输出文件名中是否出现 sitemap。站点选择公开可请求但禁止搜索索引的策略,sitemap 会成为不必要的索引入口,所以直接拒绝。
这仍是一份显式拒绝清单:新增敏感模式不会自动被识别,必须同时维护准备阶段的白名单/脱敏规则和构建后规则。通过这里不等于完成全站 QA,HTML 和源码不变量还在后面。
3. 把 HTML 链接映射回静态文件
site/scripts/verify.mjs · L13–L25 · 打开完整源码
for(const f of html){
const text=fs.readFileSync(f,'utf8');
if(!/<meta\s+name="robots"\s+content="noindex, nofollow, noarchive, nosnippet"/.test(text))throw new Error(`Missing noindex: ${f}`);
for(const [,link] of text.matchAll(/href="([^"\s]+)"/g)){
if(!link.startsWith('/')&&!link.startsWith('#'))continue;
const [url,hash]=link.split('#');
const target=url ? path.join('dist',url.replace(/^\//,''),url.endsWith('/')?'index.html':'') : f;
if(!fs.existsSync(target))throw new Error(`Broken local link: ${f} -> ${link}`);
if(hash&&target.endsWith('.html')){
const body=fs.readFileSync(target,'utf8');
if(!body.includes(`id="${decodeURIComponent(hash)}"`))throw new Error(`Missing fragment: ${f} -> ${link}`);
}
}
脚本逐个读取 HTML,要求布局输出完整的 robots meta:noindex, nofollow, noarchive, nosnippet。随后用 matchAll 找双引号 href,只处理 / 开头的站内链接和 # 开头的页内链接;外部 URL、表单 action 和运行时脚本生成的链接不在此检查范围。
链接在第一个 # 处分成路径和 hash。带路径的目标会从 dist 下拼出,尾斜杠补成 index.html;只有 hash 时目标就是当前 HTML。existsSync 确认静态文件存在,防止导航或按钮指向没有输出的页面。
目标是 HTML 且存在 hash 时,脚本解码片段并搜索精确的 id="..."。因此章节目录、页面前后导航、源码行号和 symbol 链接都必须有落点。这个过程是在文件系统上模拟静态 URL 映射,不是启动 HTTP 服务器。
4. 配置文件必须与页面策略对齐
site/scripts/verify.mjs · L26–L30 · 打开完整源码
}
const headers=fs.readFileSync('dist/_headers','utf8');
if(!headers.startsWith('/*\n')||!headers.includes('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet'))throw new Error('Missing global X-Robots-Tag');
const robots=fs.readFileSync('dist/robots.txt','utf8');
if(!robots.includes('User-agent: *\nAllow: /')||/^Disallow:\s*\//m.test(robots))throw new Error('Crawlers must be able to see noindex');
验证器读取 dist/_headers,要求全路径规则开头为 /*,并包含全局 X-Robots-Tag: noindex, nofollow, noarchive, nosnippet。再读取 dist/robots.txt,要求所有 crawler 能从根路径读取,同时不能出现 Disallow: /。
允许 crawler 读取是为了让它看见 noindex,而不是为了鼓励收录;HTML meta 和响应头才表达不建立索引。三者都不提供访问控制,发布后的真实 HTTP 头仍需由托管平台解析 _headers 才会生效。
5. 源码页必须保留文本和锚点
site/scripts/verify.mjs · L31–L40 · 打开完整源码
const sources=JSON.parse(fs.readFileSync('.generated/sources.json','utf8'));
const entity=(_match,name)=>({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[name] ?? String.fromCodePoint(parseInt(name.replace(/^#x?/i,''),name.startsWith('#x')?16:10)));
for(const source of sources){
const body=fs.readFileSync(`dist/code/${source.id}/index.html`,'utf8');
const code=source.code.replace(/\n$/,'');
for(let line=1;line<=code.split('\n').length;line++) if(!body.includes(`id="L${line}"`))throw new Error(`Missing line ${source.name}:${line}`);
for(const symbol of source.symbols) if(!body.includes(`id="${symbol.id}"`))throw new Error(`Missing symbol ${source.name}:${symbol.label}`);
const rendered=body.match(/<code[^>]*>([\s\S]*?)<\/code>/)?.[1].replace(/<[^>]*>/g,'').replace(/&(amp|lt|gt|quot|apos|#x[0-9a-f]+|#[0-9]+);/gi,entity);
if(rendered!==code)throw new Error(`Source text altered by navigation markup: ${source.name}`);
}
验证器从 .generated/sources.json 读取准备阶段产生的 source 对象。每个对象对应一个 dist/code/<id>/index.html;code 去掉一个末尾换行后,作为应当出现在页面中的可见源码。
它先按真实行数要求页面包含 L1 到最后一行的 id,再逐个要求每个 symbol id 存在。这样行号入口和函数目录不仅有 href,也有实际 DOM 落点。
最后取页面第一个 code 元素的内部 HTML,删除高亮标签,再解码常见命名实体和数字实体,与 source.code 严格比较。transformer 可以增加 <a>、symbol <span> 和高亮标签,却不能改变源码字符。比较失败就报告 Source text altered by navigation markup,把“导航层污染代码层”的问题拦在发布前。
这项检查依赖当前输出结构:源码页面第一个 code 元素应是代码文本,链接属性使用双引号,行号锚点使用 L<number>。如果模板改变了这些约定,验证器和页面实现需要一起更新,不能只修改错误消息。
6. 成功日志的含义和不含义
site/scripts/verify.mjs · L41–L41 · 打开完整源码
console.log(`Verified ${html.length} HTML pages: noindex, internal links/fragments, privacy allowlist; ${all.length} static files.`);
所有检查通过后,脚本打印 HTML 页面数和静态文件总数。这个数字证明本次脚本走完了既定检查,不是内容质量评分,也不是托管平台已正确部署的证明。
可以把一次验收拆成四个观察结果:文件名没有越界、敏感片段没有出现、内部路径和 hash 有目标、源码页面的行号/symbol/可见文本保持一致。它们与 片段覆盖检查 互补:覆盖检查回答“讲义是否展示了清单中的非空源行”,这里回答“最终 HTML 是否仍完整、可定位且符合发布边界”。
最后要分清 noindex 与权限:知道 URL 的人仍可能请求静态文件。真正的访问控制必须在托管层或网关提供,不能从 robots、响应头或 404 页面推导出来。配置来源见 站点配置,完整内容和源码页的生成见 站点构建管线。
读完本章,应能回答:为什么 QA 要在 Astro build 之后运行?如何确认行号导航没有改写源码?哪些检查只覆盖静态文件文本,不能证明生产托管层的行为?