|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * @ai-context |
| 4 | + * docs 体系一致性检查脚本:Markdown 相对链接完整性 + 索引引用存在性 + 命名规范。 |
| 5 | + * 用途:文档变更时本地/CI 快速验证,防止失效链接与索引漂移。 |
| 6 | + * Why: 历史重组曾致 100+ 失效链接;索引-文件漂移反复发生。本脚本把"链接有效、 |
| 7 | + * 索引 1:1"变成可自动校验的门禁(与 lint/test 同级)。 |
| 8 | + * 豁免规则:archive/ 为历史快照(按归档机制保留原貌,不检查链接);file:// |
| 9 | + * 绝对路径引用仅在目标文件确实不存在时报错(存在则警告"不可移植")。 |
| 10 | + * 用法:node scripts/docs-check.mjs [--dir docs] [--strict] [--include-archive] |
| 11 | + * 退出码:0 = 通过;1 = 发现问题(--strict 时索引缺失与 file:// 引用也算问题) |
| 12 | + */ |
| 13 | + |
| 14 | +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; |
| 15 | +import { join, resolve, relative, dirname, extname, basename, sep } from 'node:path'; |
| 16 | + |
| 17 | +const ROOT = resolve(process.cwd()); |
| 18 | +const argOf = (flag) => { |
| 19 | + const i = process.argv.indexOf(flag); |
| 20 | + return i >= 0 ? process.argv[i + 1] : undefined; |
| 21 | +}; |
| 22 | +const DOCS_DIR = argOf('--dir') ? resolve(argOf('--dir')) : join(ROOT, 'docs'); |
| 23 | +const STRICT = process.argv.includes('--strict'); |
| 24 | +const INCLUDE_ARCHIVE = process.argv.includes('--include-archive'); |
| 25 | +const IGNORE_FILE = join(DOCS_DIR, '.docscheckignore'); |
| 26 | + |
| 27 | +/** |
| 28 | + * 读取豁免清单:docs/.docscheckignore,每行一个 glob 模式(匹配相对 DOCS_DIR 的路径)。 |
| 29 | + * 用途:历史快照文档(如 versions/ 下已声明保留原貌的旧版本文档)豁免链接检查。 |
| 30 | + */ |
| 31 | +function loadIgnorePatterns() { |
| 32 | + if (!existsSync(IGNORE_FILE)) return []; |
| 33 | + return readFileSync(IGNORE_FILE, 'utf8') |
| 34 | + .split(/\r?\n/) |
| 35 | + .map((l) => l.trim()) |
| 36 | + .filter((l) => l && !l.startsWith('#')); |
| 37 | +} |
| 38 | + |
| 39 | +/** 简单 glob 匹配(支持 ** 与 *,不处理 []/{}) */ |
| 40 | +function globMatch(pattern, path) { |
| 41 | + const re = new RegExp( |
| 42 | + '^' + |
| 43 | + pattern |
| 44 | + .replace(/[.+^${}()|[\]\\]/g, '\\$&') |
| 45 | + .replace(/\*\*/g, '\u0000') |
| 46 | + .replace(/\*/g, '[^/]*') |
| 47 | + .replace(/\u0000/g, '.*') + |
| 48 | + '$', |
| 49 | + ); |
| 50 | + return re.test(path); |
| 51 | +} |
| 52 | + |
| 53 | +/** 收集目录下全部 markdown 文件(递归) */ |
| 54 | +function collectMd(dir) { |
| 55 | + const out = []; |
| 56 | + for (const entry of readdirSync(dir)) { |
| 57 | + const full = join(dir, entry); |
| 58 | + if (entry === 'node_modules' || entry === '.git') continue; |
| 59 | + if (statSync(full).isDirectory()) out.push(...collectMd(full)); |
| 60 | + else if (extname(full) === '.md') out.push(full); |
| 61 | + } |
| 62 | + return out; |
| 63 | +} |
| 64 | + |
| 65 | +/** 提取 markdown 链接目标(忽略外部 URL/锚点/图片),区分相对与 file:// */ |
| 66 | +function extractLinks(content) { |
| 67 | + const links = []; |
| 68 | + const re = /\[[^\]]*\]\(([^)]+)\)/g; |
| 69 | + let m; |
| 70 | + while ((m = re.exec(content)) !== null) { |
| 71 | + const target = m[1].trim(); |
| 72 | + if (!target || target.startsWith('#')) continue; |
| 73 | + if (/^(https?:|mailto:|data:)/.test(target)) continue; |
| 74 | + if (target.startsWith('file://')) { |
| 75 | + links.push({ kind: 'fileurl', target }); |
| 76 | + continue; |
| 77 | + } |
| 78 | + if (target.startsWith('/')) continue; // 绝对路径引用不检查 |
| 79 | + links.push({ kind: 'rel', target: target.split('#')[0] }); |
| 80 | + } |
| 81 | + return links; |
| 82 | +} |
| 83 | + |
| 84 | +/** file:// URL 解码为仓库相对路径(Windows 盘符与 %20 编码) */ |
| 85 | +function fileUrlToRel(target) { |
| 86 | + const decoded = decodeURIComponent(target.replace(/^file:\/\//, '')); |
| 87 | + const norm = decoded.replace(/\\/g, '/'); |
| 88 | + const m = norm.match(/^([a-zA-Z]:)?(\/.*)$/); |
| 89 | + if (!m) return null; |
| 90 | + const abs = m[2]; |
| 91 | + const rootNorm = ROOT.replace(/\\/g, '/').toLowerCase(); |
| 92 | + const absLower = abs.toLowerCase(); |
| 93 | + if (absLower.startsWith(rootNorm)) return abs.slice(rootNorm.length + 1); |
| 94 | + return null; // 仓库外引用 |
| 95 | +} |
| 96 | + |
| 97 | +const files = collectMd(DOCS_DIR); |
| 98 | +const ignorePatterns = loadIgnorePatterns(); |
| 99 | +const isIgnored = (f) => { |
| 100 | + const rel = relative(DOCS_DIR, f).split(sep).join('/'); |
| 101 | + return ignorePatterns.some((p) => globMatch(p, rel)); |
| 102 | +}; |
| 103 | +const activeFiles = files.filter((f) => { |
| 104 | + if (isIgnored(f)) return false; |
| 105 | + if (INCLUDE_ARCHIVE) return true; |
| 106 | + return !relative(ROOT, f).split(sep).includes('archive'); |
| 107 | +}); |
| 108 | + |
| 109 | +const broken = []; // 真实失效(相对链接目标不存在) |
| 110 | +const fileUrlMissing = []; // file:// 指向仓库内但文件不存在 |
| 111 | +const fileUrlWarn = []; // file:// 指向仓库内且存在(不可移植) |
| 112 | +const naming = []; |
| 113 | +const missingFromIndex = []; |
| 114 | + |
| 115 | +for (const file of activeFiles) { |
| 116 | + const content = readFileSync(file, 'utf8'); |
| 117 | + const dir = dirname(file); |
| 118 | + for (const { kind, target } of extractLinks(content)) { |
| 119 | + if (kind === 'rel') { |
| 120 | + const resolved = resolve(dir, target); |
| 121 | + if (!existsSync(resolved)) broken.push({ file: relative(ROOT, file), target }); |
| 122 | + } else { |
| 123 | + const rel = fileUrlToRel(target); |
| 124 | + if (rel === null) continue; // 仓库外引用不判断 |
| 125 | + if (existsSync(join(ROOT, rel))) fileUrlWarn.push({ file: relative(ROOT, file), target: rel }); |
| 126 | + else fileUrlMissing.push({ file: relative(ROOT, file), target: rel }); |
| 127 | + } |
| 128 | + } |
| 129 | + const name = basename(file); |
| 130 | + if (/\s/.test(name)) naming.push({ file: relative(ROOT, file), issue: '文件名含空格' }); |
| 131 | + if (/重制|副本|_tmp|\.tmp|copy|-\d+$/i.test(name)) { |
| 132 | + naming.push({ file: relative(ROOT, file), issue: '疑似临时后缀命名' }); |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +// 索引覆盖检查(按目录分组,仅检查有 README 的目录) |
| 137 | +const dirs = new Map(); |
| 138 | +for (const file of activeFiles) { |
| 139 | + const dir = dirname(file); |
| 140 | + if (!dirs.has(dir)) dirs.set(dir, { readme: null, files: [] }); |
| 141 | + const entry = dirs.get(dir); |
| 142 | + if (basename(file).toLowerCase() === 'readme.md') entry.readme = file; |
| 143 | + else entry.files.push(file); |
| 144 | +} |
| 145 | +for (const [dir, info] of dirs) { |
| 146 | + if (!info.readme) continue; |
| 147 | + const idxContent = readFileSync(info.readme, 'utf8'); |
| 148 | + for (const file of info.files) { |
| 149 | + const name = basename(file); |
| 150 | + if (!idxContent.includes(name)) { |
| 151 | + missingFromIndex.push({ file: relative(ROOT, file), issue: `未收录于 ${relative(ROOT, info.readme)}` }); |
| 152 | + } |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +let exitCode = 0; |
| 157 | +console.log( |
| 158 | + `docs-check: 扫描 ${files.length} 个 Markdown 文件(检查 ${activeFiles.length} 个,archive 快照与豁免清单除外)`, |
| 159 | +); |
| 160 | + |
| 161 | +if (broken.length) { |
| 162 | + exitCode = 1; |
| 163 | + console.error(`\n❌ 失效相对链接 ${broken.length} 处:`); |
| 164 | + for (const b of broken.slice(0, 30)) console.error(` ${b.file} → ${b.target}`); |
| 165 | + if (broken.length > 30) console.error(` …另有 ${broken.length - 30} 处`); |
| 166 | +} else { |
| 167 | + console.log('✅ 相对链接全部有效'); |
| 168 | +} |
| 169 | + |
| 170 | +if (fileUrlMissing.length) { |
| 171 | + exitCode = 1; |
| 172 | + console.error(`\n❌ file:// 引用目标不存在 ${fileUrlMissing.length} 处:`); |
| 173 | + for (const b of fileUrlMissing.slice(0, 20)) console.error(` ${b.file} → ${b.target}`); |
| 174 | +} else { |
| 175 | + console.log(`✅ file:// 引用目标均存在${fileUrlWarn.length ? `(${fileUrlWarn.length} 处不可移植,建议改相对链接)` : ''}`); |
| 176 | +} |
| 177 | + |
| 178 | +if (naming.length) { |
| 179 | + exitCode = 1; |
| 180 | + console.error(`\n❌ 命名不规范 ${naming.length} 处:`); |
| 181 | + for (const n of naming) console.error(` ${n.file}(${n.issue})`); |
| 182 | +} else { |
| 183 | + console.log('✅ 文件名规范'); |
| 184 | +} |
| 185 | + |
| 186 | +if (missingFromIndex.length) { |
| 187 | + console.warn(`\n⚠️ ${missingFromIndex.length} 个文件未收录于本目录索引:`); |
| 188 | + for (const m of missingFromIndex.slice(0, 20)) console.warn(` ${m.file}(${m.issue})`); |
| 189 | + if (STRICT) exitCode = 1; |
| 190 | +} else { |
| 191 | + console.log('✅ 索引覆盖完整'); |
| 192 | +} |
| 193 | + |
| 194 | +console.log(exitCode === 0 ? '\n✅ docs-check 通过' : '\n❌ docs-check 发现问题'); |
| 195 | +process.exit(exitCode); |
0 commit comments