Skip to content

Commit 4b1077c

Browse files
committed
chore: 新增 docs:check 脚本与 lint-staged 文档检查门禁
1 parent 39a471e commit 4b1077c

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"learning:detect": "node scripts/session-detect.mjs",
1313
"learning:route": "node scripts/session-route.mjs",
1414
"learning:pipeline": "node scripts/session-detect.mjs && node scripts/session-route.mjs --status",
15+
"docs:check": "node scripts/docs-check.mjs",
1516
"prepare": "husky"
1617
},
1718
"devDependencies": {
@@ -30,6 +31,7 @@
3031
},
3132
"lint-staged": {
3233
"client/**/*.{ts,tsx}": "npm --prefix client exec oxlint --",
33-
"website/**/*.{ts,tsx}": "npm --prefix website exec eslint -- --config website/eslint.config.mjs"
34+
"website/**/*.{ts,tsx}": "npm --prefix website exec eslint -- --config website/eslint.config.mjs",
35+
"docs/**/*.md": "node scripts/docs-check.mjs"
3436
}
3537
}

‎scripts/docs-check.mjs‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)