一致性门禁 (parity-gates)
从 capture/ 中构建基线一次,然后每次构建都必须通过所有门禁,直到全部通过。
门禁只读取 baseline/,绝不访问线上站点,因此即便旧主机已经下线,构建仍然可以被验证。
黄金法则
默认构建等于线上站点,且门禁永远不会被软化。 没有任何标志可以将失败变为警告。由此衍生出四条规则:
- 绝不要手动编辑
baseline/。 唯一的例外是baseline/scripts.json中的keep字段,用来标记某个供应商被移除(将其设为false,并记录决策)。 - 差异是条目,而不是修改。 不可避免的差异记录在
exceptions.json中;改进记录在proposals.json中(仅在PROPOSALS=on时生效)。两者均在references/exceptions.md中有说明。 - 跳过不等于通过。 某个门禁无法检查某项目时,会在其摘要行中显示
N 个跳过 (原因)。每个跳过都必须报告。 - 报告错误门禁为工具包缺陷;绝不要在站点中修改门禁,也不要修改基线。
运行脚本
由 convert-site 创建的站点,脚本位于 scripts/ 中;在站点根目录下,以以下 npm 脚本方式运行,选项放在 -- 之后。
在非脚手架站点中,请在站点根目录下以以下 node 命令形式运行,其中 <skill-dir> 是此技能文件夹的绝对路径(位于已执行 npm install 的工具包副本中)。
每个脚本都支持 --help;请运行它而不是阅读源码。
退出码 0 表示通过,1 表示门禁失败或基线漂移,2 表示用法错误或缺少输入。
1. 基线(一次)
npm run baseline
node <skill-dir>/scripts/build-baseline.mjs --capture capture --out baseline # 备用命令
它需要 capture/inventory.json、media.json(来自捕获的 media.mjs)和 templates.json
(来自 templates.mjs),如果缺少其中之一,则退出码 2 并指明需要运行的步骤。
它会写入 baseline/{routes,seo,redirects,media,forms,scripts,meta,counts}.json 和 baseline/text/,
以及站点根目录下的 redirects.csv。没有目标值的短链接会被排除并在 stderr 中列出。
捕获链中的每个中间跳转都会获得单独的一行,直指最终目标
(例如 /old-services → /old-services/ → /services/ 也会生成 /old-services/,/services/,
注意标记为 hop of /old-services),除非该跳转本身就是一个构建路由,
使得指向该链中任何 URL 的链接都能一次跳转到位。
baseline/ 和 redirects.csv 一起提交。--check 会重新统计 baseline/ 并与 counts.json 对比(漂移则退出码 1)。
每个文件的格式定义见 references/contracts.md。
2. 重定向
redirects.csv(from,to,status,source,note)是站点重定向的唯一数据源。
source 为来自捕获的 crawl、sitemap、shortlink 或 seed;
你添加的行使用 decision,其 note 字段标明决策 ID。
切勿手动编辑编译后的输出;请使用以下命令进行编译:
npm run redirects:build
node <skill-dir>/scripts/build-redirects.mjs --csv redirects.csv --host vercel --out vercel.json --trailing-slash always # 备用命令
--host cloudflare --out public/_redirects 用于 Pages(Astro 会将其复制到 dist/ 中)。
Vercel:redirects 键被替换,其他所有键保持不变;trailingSlash 会被移除,
并以编译器自身的 308 重定向规则形式写入,这些规则放在站点规则之后,
确保无结尾斜杠的源路径仍然只需要一次跳转。
只有以静态文件扩展名结尾的路径才会被这些规则视为文件,
不区分大小写:包括 Web 类型、文档、媒体和归档类型(上传文件夹中的 docx、xlsx、csv、mov、wav、heic、gz 等),
以及 dist/ 中(否则是 public/)的所有扩展名,因此请在构建之后重新运行;
/upgrade-guide/3.5/ 是一个页面,会保持其结尾斜杠。
如果存在会被这些规则重定向的已构建文件,则重定向门禁会报告“已构建文件被重定向”并失败。
Cloudflare:查询行(/?p=7)无法被 _redirects 匹配,会输出到
cloudflare-query-redirects.csv,需要您手动创建为 Single Redirect(属于“需要您操作”的项目)。
目标路径与自身相同(例如 /?p=2 到 /)的查询行是无效的,在两个主机平台上都会被排除。
捕获到的行(除 decision 外的任何 source)代表一个线上 URL,不包含参数:
对于 Vercel,其中的每个 : 和 * 都会被转义
(例如 /foo:bar/ 写成 /foo\:bar/,/trackshttps://www.example.com/x/ 也是如此,
这是一个破损的线上链接,被捕获时仍被跟踪);// 保持不变。
只有 decision 行可以使用 :name 参数和 * 通配符,
但在这种情况下,数字之前的 :(如端口号)也会被转义。Vercel 拒绝裸的 :,
门禁对其的模拟也同样如此。
没有有效格式的源(没有前导 /、包含空白字符、控制字符;
在 Cloudflare 上还包括捕获到的 :<name> 或 *,因为 _redirects 无法对其进行转义)
会被丢弃,在 stderr 中列出,并作为 { source, reason } 写入 redirects-skipped.json。
该文件与 redirects.csv 并列(站点根目录),绝不会放在 public/ 下,因此不会被部署。
重定向门禁和 verify-live 会将此类行计为跳过,而不是通过。
当某路径应该完全没有重定向时,请改用“无重定向”异常(第 4 节):
{ "kind": "redirect", "path": "<source>", "reason": "...", "action": "none", "approvedBy": "...", "date": "..." }。
3. 验证
npm run build
npm run verify -- --endpoint https://forms.example.com/f/abc --thank-you /thank-you/
node <skill-dir>/scripts/verify/run-all.mjs --endpoint https://forms.example.com/f/abc --thank-you /thank-you/ # 备用命令
run-all 按字母顺序运行所有十个门禁,为每个门禁传递其接受的选项,
并在第一个失败时停止。共享选项:--paths(逗号分隔列表,* 匹配任意内容)、
--json、--origin(链接、SEO、站点地图:构建的目标主机)、--allow-extra(路由)、
--endpoint、--honeypot、--thank-you(表单;当基线中存在 POST 表单时,--endpoint 是必需的)、
--host、--config(重定向、链接)、--csv(重定向)、--media-plan(媒体)。
修复第一个失败,重新构建,重新运行。单独运行一个门禁:node <skill-dir>/scripts/verify/<gate>.mjs。
| 门禁 | 通过条件 | 跳过含义 |
|---|---|---|
forms | 每个线上表单都出现在其页面上,字段名称、顺序和方法相同;POST 表单的 action 是 --endpoint 并携带 --honeypot;GET 表单(如搜索框)保持线上的 action 路径,不需要这两个参数(“N 个搜索/GET 表单已比较”);如果 POST 表单的线上 action 位于其他可注册域名上(例如新闻通讯列表主机;www、http、子域名以及 WordPress 处理器如 admin-ajax.php 或 ?wpcf7 被视为同一站点),则保持原样,按其完整 action URL 进行像 GET 表单一样的比较,且不需要 --endpoint(“N 个表单保持原样(提交到 )”) | 没有 --thank-you:POST 表单的感谢页面未被检查 |
headers | 主机配置在所有路径上发送 nosniff、Referrer-Policy、X-Frame-Options 或 CSP frame-ancestors,并且当站点不可索引时,在每个路径上发送 X-Robots-Tag: noindex(或者对于 Vercel,在每个构建页面上设置 meta robots noindex);预览主机规则(https://*.pages.dev/*)仅针对预览主机生效;当 PUBLIC_INDEXABLE=true 时,禁止在所有路径上设置 noindex | |
links | 每个内部 href 和 src 解析到一个已构建的页面或文件、一个重定向源或基线媒体文件,或者通过编译后的规则(vercel.json 或 dist/_redirects:包括斜杠规则和所有 redirects.csv 行)在一个跳转内到达目标构建页面,如同线上被重定向一样(“N 个链接通过重定向解析”);两次跳转或缺少目标则失败;如果没有主机配置,/about 在构建服务于 /about/ 的情况下也会失败 | |
media | 每个基线媒体文件都由构建以相同的字节数提供(--paths 通过路径或引用它的页面来选择文件):在 dist/ 中(计划模式为 repo);在计划的 prefix 下,在存储清单(store)中或仍在线上(live-temporary);在前缀之外,在所有模式下都在 dist/ 中。当 PUBLIC_INDEXABLE=true 时,前缀下的每个文件使用 live-temporary 都会失败(发布构建可能无法从线上站点提供媒体;没有前缀的计划覆盖所有文件),并且仍然检查前缀外的文件是否在 dist/ 中 | 暂存构建的计划模式 live-temporary:前缀下的文件从线上获取,不被检查 |
redirects | 每个基线重定向和 CSV 行在一个跳转内以相同状态码响应,并且目标在 dist/ 中返回 200(或者是外部链接、或者位于基线 meta.json 中 excluded 前缀下:此时仅检查第一个跳转);没有源同时也是一个已构建的文件;没有已构建的页面或文件被从其自身的 URL 重定向(“built page redirected by rule ”;没有异常覆盖它,请修复规则);具有“无重定向”异常(kind: "redirect",action: "none")的路径根本不会被重定向 | 无效的查询行、Cloudflare 上的任何查询行,或 redirects-skipped.json 中列出了原因(not compiled)且没有有效格式的源 |
routes | 每个页面、文章、归档和 Feed 都以 200 状态返回并且已构建,noindex 路由仍然标记为 noindex,没有额外的构建内容(捕获自身的脚本位于 baseline/js.json 中、样式表位于 baseline/css.json 中的线上路径、线上站点地图名称位于 baseline/sitemaps.json 中以及启动图标集不被视为额外内容);捕获时状态码不是 200、3xx、404 或 410 的路由会失败,报告为 not captured: <status>,除非 routes 异常跳过它或为其指定了构建替代项 | routes 异常中带有 to: "skip":该路由从未被捕获 |
scripts | 每个保留的供应商脚本恰好出现在其线上页面中,并具有相同的 ID;已移除的供应商脚本不出现在任何页面上;没有新增的供应商脚本 | |
seo | 每页的 Title、description、canonical、robots、lang、h1、h2、og 和 JSON-LD @types 相等;站点 URL 位于站点的源(origin)上。除非 PUBLIC_INDEXABLE=true,否则 robots 必须携带暂存的 noindex, nofollow,其余内容与线上相比除这两项外保持一致;发布构建时,robots 会进行完全比较 | |
sitemaps | dist/(Astro 的 sitemap-index.xml,或 baseline/sitemaps.json 中的线上名称,例如 wp-sitemap.xml 或 sitemap_index.xml)中的每个顶级站点地图都恰好列出源(origin)上可索引的 200 页面(百分比转义比较时不区分大小写:%c3 等于 %C3);robots.txt 中的每行 Sitemap: 指向上线源(origin)并命中了 dist/ 中的一个文件,或者是线上 robots.txt(capture/robots.txt)逐字携带的行;每个 Feed 都已构建并包含正确数量的条目 | |
text | 每个页面的可见文本与 baseline/text/ 中的内容相等(单词差异对比,显示前后各 5 个单词) |
使用 --exclude 进行的捕获,其路径前缀记录在 baseline/meta.json 的 excluded 字段中:
links 门禁不检查位于此类前缀下的链接(就像链接到其他主机的链接一样,报告为 "N links under excluded paths ... not checked"),
site-maps 门禁接受位于此类前缀下的 robots.txt Sitemap: 行——前提是线上的 robots.txt 逐字携带了该行(同一主机上多站点子站点的站点地图)。
4. 差异与决策
当构建无法或不应与线上匹配时,请阅读 references/exceptions.md。
简要说来:一个异常条目格式为 { path, field, from, to, reason, approvedBy, date };
from: "" 表示构建新增的值,to: "" 表示构建移除的值;
不再匹配的异常,或命名了门禁未检查的路径的异常,会使门禁失败。
线上站点的每个异常(如缺少 H1、无效标签、断开的链接)默认都会被复现,
并且会有一个决策行(id、topic、choice、default、reason、cost if wrong)来说明这一点以及修复方案。
有一种记录使用 kind 而不是 field:
{ "kind": "redirect", "path": "<source>", "reason": "...", "action": "none", "approvedBy": "...", "date": "..." }
表示“此路径有意无重定向”(针对一个不应产生重定向的破损链接所记录的线上重定向)。
每个键都是必需的:它移除了一个线上行为,因此像任何异常一样需要签核。
重定向门禁会检查该路径,只有在规则不重定向它时才通过;
如果门禁不检查该路径,则失败并显示 path not checked;
verify-live 会跳过它并给出原因。其他所有门禁都忽略此记录。
5. 暂存、提案与发布构建
PUBLIC_INDEXABLE 和 PROPOSALS 会改变 Astro 的构建内容,
因此请务必在构建时设置它们,而不仅仅是在门禁运行时设置;
在未设置这些变量的构建上运行门禁,检查的是错误的站点。
仅当构建本身是为特定主机创建时,才传递 --origin;
脚手架构建的 site 为线上源(origin),因此其 canonical 和站点地图保持不变。
npm run build && npm run verify -- --endpoint ... --thank-you ... # 暂存
PROPOSALS=on npm run build && npm run verify:proposals -- --endpoint ... --thank-you ... # 提案
PUBLIC_INDEXABLE=true npm run build && npm run verify:release -- --endpoint ... --thank-you ... # 发布
在暂存部署之后,通过 HTTP 对照基线检查它。发布部署以相同方式检查,但不使用 --expect-noindex:
npm run verify:live -- --base https://my-site.vercel.app --expect-noindex
node <skill-dir>/scripts/verify-live.mjs --base https://my-site.vercel.app --expect-noindex # 备用命令
--bypass "$VERCEL_AUTOMATION_BYPASS_SECRET" 仅在暂存被有意保护时使用(绕过值来自环境变量,绝不要粘贴到聊天中);
公开的暂存别名则不需要。
它会检查每条基线路由是否返回 200(或使用 --sample 抽样)、每个重定向是否在一个跳转内返回相同类型的状态码、
页面上的 noindex 设置是否与 --expect-noindex 一致、检查站点地图,并检查 20 个媒体文件的字节数
(或使用 --media-manifest,其 url 可以是存储的绝对 URL,也可以是在 <base><path> 处检查的站点路径)。
列在 redirects-skipped.json 中的重定向或被“无重定向”异常覆盖的重定向
(--skipped、--exceptions;默认都从站点根目录读取)会被跳过,并附上原因。
每个请求在 30 秒后超时,这是检查失败而不是封锁。退出码 3 表示被封锁:请等待并重新运行。
退出码 3 并附带 deployment protection: 表示 Vercel 以登录重定向(302 到 vercel.com/sso-api)应答:
请使用项目的生产别名(默认公开)而不是原始部署 URL;仅当暂存被有意保护时,
…
[内容因长度截断]