Skip to content

Commit c48ccf4

Browse files
committed
fix(docs): 恢复 Starter Kit 模板源并加模板同步守卫
1 parent c79c971 commit c48ccf4

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

‎docs/scripts/docs-check.mjs‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
import { fileURLToPath } from 'node:url';
17+
18+
const ROOT = resolve(process.cwd());
19+
const argOf = (flag) => {
20+
const i = process.argv.indexOf(flag);
21+
return i >= 0 ? process.argv[i + 1] : undefined;
22+
};
23+
const DOCS_DIR = argOf('--dir') ? resolve(argOf('--dir')) : join(ROOT, 'docs');
24+
const STRICT = process.argv.includes('--strict');
25+
const INCLUDE_ARCHIVE = process.argv.includes('--include-archive');
26+
const IGNORE_FILE = join(DOCS_DIR, '.docscheckignore');
27+
28+
/**
29+
* 读取豁免清单:docs/.docscheckignore,每行一个 glob 模式(匹配相对 DOCS_DIR 的路径)。
30+
* 用途:历史快照文档(如 versions/ 下已声明保留原貌的旧版本文档)豁免链接检查。
31+
*/
32+
function loadIgnorePatterns() {
33+
if (!existsSync(IGNORE_FILE)) return [];
34+
return readFileSync(IGNORE_FILE, 'utf8')
35+
.split(/\r?\n/)
36+
.map((l) => l.trim())
37+
.filter((l) => l && !l.startsWith('#'));
38+
}
39+
40+
/** 简单 glob 匹配(支持 ** 与 *,不处理 []/{}) */
41+
function globMatch(pattern, path) {
42+
const re = new RegExp(
43+
'^' +
44+
pattern
45+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
46+
.replace(/\*\*/g, '\u0000')
47+
.replace(/\*/g, '[^/]*')
48+
.replace(/\u0000/g, '.*') +
49+
'$',
50+
);
51+
return re.test(path);
52+
}
53+
54+
/** 收集目录下全部 markdown 文件(递归) */
55+
function collectMd(dir) {
56+
const out = [];
57+
for (const entry of readdirSync(dir)) {
58+
const full = join(dir, entry);
59+
if (entry === 'node_modules' || entry === '.git') continue;
60+
if (statSync(full).isDirectory()) out.push(...collectMd(full));
61+
else if (extname(full) === '.md') out.push(full);
62+
}
63+
return out;
64+
}
65+
66+
/** 提取 markdown 链接目标(忽略外部 URL/锚点/图片),区分相对与 file:// */
67+
function extractLinks(content) {
68+
const links = [];
69+
// 先剔除围栏代码块与行内代码:代码示例中的 ](...) 模式不是真实链接,
70+
// 不剔除会把配图格式示例等误判为失效链接(v0.10.1.md 实证,2026-08-21 十轮修复)
71+
const stripped = content.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, '');
72+
const re = /\[[^\]]*\]\(([^)]+)\)/g;
73+
let m;
74+
while ((m = re.exec(stripped)) !== null) {
75+
const target = m[1].trim();
76+
if (!target || target.startsWith('#')) continue;
77+
if (/^(https?:|mailto:|data:)/.test(target)) continue;
78+
if (target.startsWith('file://')) {
79+
links.push({ kind: 'fileurl', target });
80+
continue;
81+
}
82+
if (target.startsWith('/')) continue; // 绝对路径引用不检查
83+
links.push({ kind: 'rel', target: target.split('#')[0] });
84+
}
85+
return links;
86+
}
87+
88+
/** file:// URL 解码为仓库相对路径(Windows 盘符与 %20 编码) */
89+
function fileUrlToRel(target) {
90+
const decoded = decodeURIComponent(target.replace(/^file:\/\//, ''));
91+
const norm = decoded.replace(/\\/g, '/');
92+
const m = norm.match(/^([a-zA-Z]:)?(\/.*)$/);
93+
if (!m) return null;
94+
const abs = m[2];
95+
const rootNorm = ROOT.replace(/\\/g, '/').toLowerCase();
96+
const absLower = abs.toLowerCase();
97+
if (absLower.startsWith(rootNorm)) return abs.slice(rootNorm.length + 1);
98+
return null; // 仓库外引用
99+
}
100+
101+
const files = collectMd(DOCS_DIR);
102+
const ignorePatterns = loadIgnorePatterns();
103+
const isIgnored = (f) => {
104+
const rel = relative(DOCS_DIR, f).split(sep).join('/');
105+
return ignorePatterns.some((p) => globMatch(p, rel));
106+
};
107+
const activeFiles = files.filter((f) => {
108+
if (isIgnored(f)) return false;
109+
if (INCLUDE_ARCHIVE) return true;
110+
return !relative(ROOT, f).split(sep).includes('archive');
111+
});
112+
113+
const broken = []; // 真实失效(相对链接目标不存在)
114+
const fileUrlMissing = []; // file:// 指向仓库内但文件不存在
115+
const fileUrlWarn = []; // file:// 指向仓库内且存在(不可移植)
116+
const naming = [];
117+
const missingFromIndex = [];
118+
119+
for (const file of activeFiles) {
120+
const content = readFileSync(file, 'utf8');
121+
const dir = dirname(file);
122+
for (const { kind, target } of extractLinks(content)) {
123+
if (kind === 'rel') {
124+
const resolved = resolve(dir, target);
125+
if (!existsSync(resolved)) broken.push({ file: relative(ROOT, file), target });
126+
} else {
127+
const rel = fileUrlToRel(target);
128+
if (rel === null) continue; // 仓库外引用不判断
129+
if (existsSync(join(ROOT, rel))) fileUrlWarn.push({ file: relative(ROOT, file), target: rel });
130+
else fileUrlMissing.push({ file: relative(ROOT, file), target: rel });
131+
}
132+
}
133+
const name = basename(file);
134+
if (/\s/.test(name)) naming.push({ file: relative(ROOT, file), issue: '文件名含空格' });
135+
if (/重制|副本|_tmp|\.tmp|copy|-\d+$/i.test(name)) {
136+
naming.push({ file: relative(ROOT, file), issue: '疑似临时后缀命名' });
137+
}
138+
}
139+
140+
// 索引覆盖检查(按目录分组,仅检查有 README 的目录)
141+
const dirs = new Map();
142+
for (const file of activeFiles) {
143+
const dir = dirname(file);
144+
if (!dirs.has(dir)) dirs.set(dir, { readme: null, files: [] });
145+
const entry = dirs.get(dir);
146+
if (basename(file).toLowerCase() === 'readme.md') entry.readme = file;
147+
else entry.files.push(file);
148+
}
149+
for (const [dir, info] of dirs) {
150+
if (!info.readme) continue;
151+
const idxContent = readFileSync(info.readme, 'utf8');
152+
for (const file of info.files) {
153+
const name = basename(file);
154+
if (!idxContent.includes(name)) {
155+
missingFromIndex.push({ file: relative(ROOT, file), issue: `未收录于 ${relative(ROOT, info.readme)}` });
156+
}
157+
}
158+
}
159+
160+
let exitCode = 0;
161+
console.log(
162+
`docs-check: 扫描 ${files.length} 个 Markdown 文件(检查 ${activeFiles.length} 个,archive 快照与豁免清单除外)`,
163+
);
164+
165+
if (broken.length) {
166+
exitCode = 1;
167+
console.error(`\n❌ 失效相对链接 ${broken.length} 处:`);
168+
for (const b of broken.slice(0, 30)) console.error(` ${b.file} → ${b.target}`);
169+
if (broken.length > 30) console.error(` …另有 ${broken.length - 30} 处`);
170+
} else {
171+
console.log('✅ 相对链接全部有效');
172+
}
173+
174+
if (fileUrlMissing.length) {
175+
exitCode = 1;
176+
console.error(`\n❌ file:// 引用目标不存在 ${fileUrlMissing.length} 处:`);
177+
for (const b of fileUrlMissing.slice(0, 20)) console.error(` ${b.file} → ${b.target}`);
178+
} else {
179+
console.log(`✅ file:// 引用目标均存在${fileUrlWarn.length ? `(${fileUrlWarn.length} 处不可移植,建议改相对链接)` : ''}`);
180+
}
181+
182+
if (naming.length) {
183+
exitCode = 1;
184+
console.error(`\n❌ 命名不规范 ${naming.length} 处:`);
185+
for (const n of naming) console.error(` ${n.file}(${n.issue})`);
186+
} else {
187+
console.log('✅ 文件名规范');
188+
}
189+
190+
if (missingFromIndex.length) {
191+
console.warn(`\n⚠️ ${missingFromIndex.length} 个文件未收录于本目录索引:`);
192+
for (const m of missingFromIndex.slice(0, 20)) console.warn(` ${m.file}(${m.issue})`);
193+
if (STRICT) exitCode = 1;
194+
} else {
195+
console.log('✅ 索引覆盖完整');
196+
}
197+
198+
// 模板同步自检:docs/scripts/docs-check.mjs 是 Docs Starter Kit 的模板源(docs/README.md 的
199+
// 「复制即用」步骤拷贝它),scripts/ 下是本项目启用后的实例;两者必须逐字节一致,否则模板
200+
// 源与实际运行的行为漂移(c79c9719 曾误删模板源)。实例参与 lint-staged 与 CI,是真正被执行的
201+
// 那一份,因此守卫放在实例侧。模板不存在时跳过:本项目以外的实例不会有这个路径。
202+
const SELF_PATH = fileURLToPath(import.meta.url);
203+
const TEMPLATE_PATH = join(ROOT, 'docs/scripts/docs-check.mjs');
204+
const normalizeEol = (text) => text.replace(/\r\n/g, '\n'); // core.autocrlf=true:不归一化会在 Windows 检出上恒定误报(同 批 0-A tokens.drift.test.ts)
205+
if (!existsSync(TEMPLATE_PATH)) {
206+
console.log('⏭️ 模板自检跳过:docs/scripts/docs-check.mjs 不存在(非 Starter Kit 仓库属正常情况)');
207+
} else if (normalizeEol(readFileSync(SELF_PATH, 'utf8')) !== normalizeEol(readFileSync(TEMPLATE_PATH, 'utf8'))) {
208+
exitCode = 1;
209+
console.error(
210+
'\n❌ 模板源与实例已分叉:docs/scripts/docs-check.mjs ↔ scripts/docs-check.mjs —— ' +
211+
'请把改动同步到另一份(模板是 Starter Kit 的源,实例是本项目实际运行的那份)',
212+
);
213+
} else {
214+
console.log('✅ 模板源与实例一致');
215+
}
216+
217+
console.log(exitCode === 0 ? '\n✅ docs-check 通过' : '\n❌ docs-check 发现问题');
218+
process.exit(exitCode);

‎scripts/docs-check.mjs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
1515
import { join, resolve, relative, dirname, extname, basename, sep } from 'node:path';
16+
import { fileURLToPath } from 'node:url';
1617

1718
const ROOT = resolve(process.cwd());
1819
const argOf = (flag) => {
@@ -194,5 +195,24 @@ if (missingFromIndex.length) {
194195
console.log('✅ 索引覆盖完整');
195196
}
196197

198+
// 模板同步自检:docs/scripts/docs-check.mjs 是 Docs Starter Kit 的模板源(docs/README.md 的
199+
// 「复制即用」步骤拷贝它),scripts/ 下是本项目启用后的实例;两者必须逐字节一致,否则模板
200+
// 源与实际运行的行为漂移(c79c9719 曾误删模板源)。实例参与 lint-staged 与 CI,是真正被执行的
201+
// 那一份,因此守卫放在实例侧。模板不存在时跳过:本项目以外的实例不会有这个路径。
202+
const SELF_PATH = fileURLToPath(import.meta.url);
203+
const TEMPLATE_PATH = join(ROOT, 'docs/scripts/docs-check.mjs');
204+
const normalizeEol = (text) => text.replace(/\r\n/g, '\n'); // core.autocrlf=true:不归一化会在 Windows 检出上恒定误报(同 批 0-A tokens.drift.test.ts)
205+
if (!existsSync(TEMPLATE_PATH)) {
206+
console.log('⏭️ 模板自检跳过:docs/scripts/docs-check.mjs 不存在(非 Starter Kit 仓库属正常情况)');
207+
} else if (normalizeEol(readFileSync(SELF_PATH, 'utf8')) !== normalizeEol(readFileSync(TEMPLATE_PATH, 'utf8'))) {
208+
exitCode = 1;
209+
console.error(
210+
'\n❌ 模板源与实例已分叉:docs/scripts/docs-check.mjs ↔ scripts/docs-check.mjs —— ' +
211+
'请把改动同步到另一份(模板是 Starter Kit 的源,实例是本项目实际运行的那份)',
212+
);
213+
} else {
214+
console.log('✅ 模板源与实例一致');
215+
}
216+
197217
console.log(exitCode === 0 ? '\n✅ docs-check 通过' : '\n❌ docs-check 发现问题');
198218
process.exit(exitCode);

0 commit comments

Comments
 (0)