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