Skip to content

Commit ca28373

Browse files
feat(ci): 添加 check-doc-paths.mjs 防止 docs rename 后遗漏 redirect
PR 中 content/docs/** 文件被 rename/delete 时,CI 自动校验旧 URL 是否已在 next.config.mjs 的 redirects 里有 source 覆盖(精确匹配或 :path* wildcard)。未覆盖则 exit 1 阻断合并,避免搜索引擎收录链接变 404。 - scripts/check-doc-paths.mjs:核心校验脚本(算法:git diff --name-status 提取旧路径 → URL 归一化 → source 覆盖检查 → 报错输出) - .github/workflows/content-check.yml:追加 Check doc path coverage step 豁免机制:在 next.config.mjs 里写 "# no-redirect-needed: <path>" 注释可跳过 对应路径的检查。双语文件(.en.md / .en.mdx)去重到同一 URL 只检查一次。
1 parent c5f37fa commit ca28373

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

.github/workflows/content-check.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,10 @@ jobs:
8383
# leetcode/ and _translated.md are exempt — see scripts/check-frontmatter-description.mjs
8484
- name: Check MDX frontmatter description
8585
run: pnpm check:frontmatter
86+
87+
# 拦截性检查:PR 中 content/docs/** 被 rename/delete 时,要求旧路径在 next.config.mjs
88+
# 有对应 redirect source 覆盖。未覆盖 → exit 1 阻断合并。
89+
- name: Check doc path coverage (301 redirect)
90+
run: node scripts/check-doc-paths.mjs
91+
env:
92+
GITHUB_BASE_REF: ${{ github.base_ref }}

scripts/check-doc-paths.mjs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env node
2+
/**
3+
* check-doc-paths.mjs — PR 中 content/docs/** rename/delete 的 301 覆盖校验
4+
*
5+
* 场景
6+
* 开发者 git mv 或删除 content/docs/ 下的文档文件时,旧 URL 必须有 redirect 兜底,
7+
* 否则搜索引擎和外部链接会直接 404。这个脚本在 CI 阶段拦截"漏写 redirect"的 PR。
8+
*
9+
* 算法
10+
* 1. git diff 找出本 PR 中被 rename/delete 的 content/docs/** 文件(旧路径)
11+
* 2. 把旧文件路径转换成归一化 URL(去 content/ 前缀、去语言后缀、去 index)
12+
* 3. 从 next.config.mjs 提取所有 source 字符串(含 wildcard :path* 规则)
13+
* 4. 若旧 URL 没有任何 source 覆盖 → 打印错误 + exit 1
14+
*
15+
* 用法
16+
* GITHUB_BASE_REF=main node scripts/check-doc-paths.mjs # CI 环境
17+
* node scripts/check-doc-paths.mjs # 本地,fallback 到 main
18+
*
19+
* 豁免
20+
* 如果文件注释里含有 # no-redirect-needed,该路径跳过检查(用于确认不需要兜底的场景)。
21+
* 在 next.config.mjs 的 redirects 块里写这个注释即可豁免对应旧路径。
22+
*
23+
* 退出码
24+
* 0 全部旧路径都有 redirect 覆盖(或 PR 无 docs 文件变更)
25+
* 1 有旧路径缺少 redirect
26+
*/
27+
28+
import { execSync } from "node:child_process";
29+
import fs from "node:fs";
30+
import path from "node:path";
31+
32+
const ROOT = process.cwd();
33+
const BASE_REF = process.env.GITHUB_BASE_REF ?? "main";
34+
35+
// ── 1. 获取 PR 中被 rename/delete 的 content/docs/** 旧路径 ──────────────────
36+
37+
function getDeletedDocFiles() {
38+
let output;
39+
try {
40+
// --diff-filter=RD:只取 Renamed 和 Deleted
41+
// --name-status:输出 "R100\told\tnew" 或 "D\tpath",这样 rename 时能拿到旧路径
42+
// --name-only 对 rename 只给新路径,无法检查旧 URL,必须用 --name-status
43+
output = execSync(
44+
`git diff origin/${BASE_REF}...HEAD --diff-filter=RD --name-status -- 'content/docs/**'`,
45+
{ cwd: ROOT, encoding: "utf-8" },
46+
).trim();
47+
} catch {
48+
// 没有远端 base ref 时(如本地测试),fallback 到 diff HEAD
49+
try {
50+
output = execSync(
51+
`git diff ${BASE_REF}...HEAD --diff-filter=RD --name-status -- 'content/docs/**'`,
52+
{ cwd: ROOT, encoding: "utf-8" },
53+
).trim();
54+
} catch {
55+
console.log("⚠️ 无法获取 git diff,跳过 doc path 检查");
56+
process.exit(0);
57+
}
58+
}
59+
60+
if (!output) return [];
61+
62+
// 解析 --name-status 输出:
63+
// "R100\tcontent/docs/old.mdx\tcontent/docs/new.mdx" → 取第二列(旧路径)
64+
// "D\tcontent/docs/old.mdx" → 取第二列
65+
const oldPaths = [];
66+
for (const line of output.split("\n")) {
67+
if (!line.trim()) continue;
68+
const parts = line.split("\t");
69+
const status = parts[0]; // "R100", "D", etc.
70+
if (status.startsWith("R") && parts[1]) {
71+
// rename:旧路径在第二列
72+
oldPaths.push(parts[1].trim());
73+
} else if (status === "D" && parts[1]) {
74+
// delete:路径在第二列
75+
oldPaths.push(parts[1].trim());
76+
}
77+
}
78+
return oldPaths.filter((f) => f.startsWith("content/docs/"));
79+
}
80+
81+
// ── 2. 旧文件路径 → 归一化 URL ──────────────────────────────────────────────
82+
83+
/**
84+
* 把文件路径转为 slug URL:
85+
* content/docs/community/dev-tips/git101.mdx → /docs/community/dev-tips/git101
86+
* content/docs/community/dev-tips/git101.en.mdx → /docs/community/dev-tips/git101
87+
* content/docs/section/index.mdx → /docs/section
88+
* content/docs/section/index.en.md → /docs/section
89+
*/
90+
function filePathToUrl(filePath) {
91+
// 去掉 content/ 前缀
92+
let url = filePath.replace(/^content\//, "/");
93+
94+
// 去掉双语后缀 .en.mdx / .en.md / .zh.mdx / .zh.md(顺序重要:先去语言再去扩展名)
95+
url = url.replace(/\.(en|zh)\.(mdx|md)$/, "");
96+
97+
// 去掉普通 .mdx / .md 后缀
98+
url = url.replace(/\.(mdx|md)$/, "");
99+
100+
// 去掉末尾 /index(index 文件的 URL 是父目录)
101+
url = url.replace(/\/index$/, "");
102+
103+
return url;
104+
}
105+
106+
// ── 3. 从 next.config.mjs 提取所有 source 字符串 ────────────────────────────
107+
108+
function extractSources(configPath) {
109+
const content = fs.readFileSync(configPath, "utf-8");
110+
111+
// 匹配 source: "..." 或 source: '...'(允许前后有空格)
112+
const sourceRegex = /source:\s*["']([^"']+)["']/g;
113+
const sources = [];
114+
let match;
115+
while ((match = sourceRegex.exec(content)) !== null) {
116+
sources.push(match[1]);
117+
}
118+
return sources;
119+
}
120+
121+
/**
122+
* 判断旧 URL 是否被某条 source 规则覆盖。
123+
*
124+
* 只处理两种 next.config.mjs 中实际出现的 redirect 模式:
125+
* - 精确匹配:source === url
126+
* - :path* wildcard:source 以 "/:path*" 结尾 → 前缀匹配
127+
*
128+
* 不处理 ":slug(.*)" 之类的复杂参数段 —— 那些是双语文件后缀 redirect(.en/.zh),
129+
* 和 doc path 覆盖无关,不应被当作通配前缀来误判。
130+
*/
131+
function isCovered(url, sources) {
132+
for (const source of sources) {
133+
// 精确匹配
134+
if (source === url) return true;
135+
136+
// :path* wildcard:"/docs/community/dev-tips/:path*" 覆盖该前缀下的所有子路径
137+
if (source.endsWith("/:path*")) {
138+
const prefix = source.slice(0, -"/:path*".length);
139+
if (url === prefix || url.startsWith(prefix + "/")) return true;
140+
}
141+
}
142+
return false;
143+
}
144+
145+
// ── 4. 检查 no-redirect-needed 豁免注释 ──────────────────────────────────────
146+
147+
/**
148+
* 如果 next.config.mjs 里存在注释 "# no-redirect-needed: <url>"(或包含该 URL 的行),
149+
* 则该 URL 豁免检查。格式宽松:只要注释行包含 no-redirect-needed 和该 url 片段即算豁免。
150+
*/
151+
function isExempted(url, configContent) {
152+
const lines = configContent.split("\n");
153+
return lines.some(
154+
(line) =>
155+
line.includes("no-redirect-needed") &&
156+
line.includes(url.replace(/^\//, "")),
157+
);
158+
}
159+
160+
// ── main ─────────────────────────────────────────────────────────────────────
161+
162+
const deletedFiles = getDeletedDocFiles();
163+
164+
if (deletedFiles.length === 0) {
165+
console.log("✅ check:doc-paths — 无 docs 文件 rename/delete,跳过检查");
166+
process.exit(0);
167+
}
168+
169+
const configPath = path.join(ROOT, "next.config.mjs");
170+
if (!fs.existsSync(configPath)) {
171+
console.error("❌ 找不到 next.config.mjs,无法校验 redirect");
172+
process.exit(1);
173+
}
174+
175+
const configContent = fs.readFileSync(configPath, "utf-8");
176+
const sources = extractSources(configPath);
177+
178+
// 计算旧 URL,去重(双语文件 .en.md 和 .md 会归一化到同一 URL)
179+
const urlSet = new Set();
180+
for (const f of deletedFiles) {
181+
urlSet.add(filePathToUrl(f));
182+
}
183+
184+
let hasError = false;
185+
186+
for (const url of urlSet) {
187+
if (isExempted(url, configContent)) {
188+
console.log(`⏭️ 豁免(no-redirect-needed):${url}`);
189+
continue;
190+
}
191+
if (isCovered(url, sources)) {
192+
console.log(`✅ redirect 已覆盖:${url}`);
193+
} else {
194+
// 找出对应的原始文件路径(可能有多个,如中英文双版本)
195+
const origFiles = deletedFiles
196+
.filter((f) => filePathToUrl(f) === url)
197+
.join(", ");
198+
console.error(`❌ 缺少 redirect 覆盖:${url}`);
199+
console.error(` 旧文件:${origFiles}`);
200+
console.error(
201+
` 请在 next.config.mjs 加 redirect,或确认此路径无需兜底(加注释 # no-redirect-needed: ${url.replace(/^\//, "")})`,
202+
);
203+
hasError = true;
204+
}
205+
}
206+
207+
if (hasError) {
208+
console.error("\n❌ check:doc-paths 未通过,请补充 redirect 后重提 PR");
209+
process.exit(1);
210+
} else {
211+
console.log("\n✅ check:doc-paths 通过,所有旧路径均有 redirect 覆盖");
212+
process.exit(0);
213+
}

0 commit comments

Comments
 (0)