mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-08 01:08:43 +08:00
update doc (#6823)
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
校验 content/ 下所有 mdx/md 中图片与站内链接引用是否存在。
|
||||
存在不可解析的引用时,进程退出码 1。
|
||||
|
||||
参数:
|
||||
可选传入若干 mdx/md 文件路径(用于 lint-staged);不传则扫描全部 content/。
|
||||
|
||||
支持的引用形式:
|
||||
Markdown:
|
||||

|
||||
[text](url)
|
||||
JSX/HTML 属性:
|
||||
src|href|to = "url" | 'url' | {"url"}
|
||||
|
||||
URL 解析规则:
|
||||
/imgs/foo.png → public/imgs/foo.png
|
||||
/any/asset.svg → public/any/asset.svg
|
||||
/en/faq/app | /zh/faq/app → content/faq/app(.en.mdx | .mdx | /index.*)
|
||||
/faq/app → content/faq/app(.mdx | ...)
|
||||
../foo/bar.png → 相对当前 mdx 解析为文件
|
||||
../foo/bar.mdx → 相对当前 mdx 解析为路由
|
||||
../foo/bar → 相对当前 mdx 解析为路由
|
||||
page#section / page?x=1 → 仅校验 page
|
||||
|
||||
跳过:http(s)://, mailto:, tel:, data:, ftp:, ws(s)://, # 纯锚点。
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DOCUMENT_ROOT = path.resolve(__dirname, '..');
|
||||
const CONTENT_DIR = path.join(DOCUMENT_ROOT, 'content');
|
||||
const PUBLIC_DIR = path.join(DOCUMENT_ROOT, 'public');
|
||||
|
||||
const MDX_EXTS = ['.mdx', '.md'];
|
||||
const FILE_LIKE_EXTS = new Set([
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp',
|
||||
'.pdf', '.zip', '.json', '.yaml', '.yml', '.txt'
|
||||
]);
|
||||
|
||||
function getAllMdxFiles(dir) {
|
||||
const out = [];
|
||||
(function walk(p) {
|
||||
for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
|
||||
const full = path.join(p, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (MDX_EXTS.includes(path.extname(entry.name).toLowerCase())) out.push(full);
|
||||
}
|
||||
})(dir);
|
||||
return out;
|
||||
}
|
||||
|
||||
function fileExists(p) {
|
||||
try {
|
||||
return fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function routeExists(basePath) {
|
||||
const candidates = [
|
||||
basePath + '.mdx',
|
||||
basePath + '.en.mdx',
|
||||
basePath + '.md',
|
||||
path.join(basePath, 'index.mdx'),
|
||||
path.join(basePath, 'index.en.mdx'),
|
||||
path.join(basePath, 'index.md')
|
||||
];
|
||||
return candidates.some(fileExists);
|
||||
}
|
||||
|
||||
function isExternal(url) {
|
||||
return /^(https?:|mailto:|tel:|data:|ftp:|ws:|wss:)/i.test(url) || url.startsWith('#');
|
||||
}
|
||||
|
||||
function stripFragment(url) {
|
||||
return url.split('#')[0].split('?')[0];
|
||||
}
|
||||
|
||||
function normalizeRoutePath(p) {
|
||||
return p.replace(/\/+$/, '').replace(/\.(en\.)?(mdx|md)$/i, '');
|
||||
}
|
||||
|
||||
function extractRefs(content) {
|
||||
// 去除 ``` 代码块和 ` 行内代码,避免误报
|
||||
const stripped = content
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`\n]*`/g, '');
|
||||
|
||||
const refs = [];
|
||||
const push = (url, type, index) => {
|
||||
if (typeof url === 'string' && url.length > 0) refs.push({ url, type, index });
|
||||
};
|
||||
|
||||
const imgRe = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
||||
const linkRe = /(^|[^!])\[(?:[^\]]*?)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
||||
const attrRe = /\b(?:src|href|to)\s*=\s*(?:"([^"]+)"|'([^']+)'|\{\s*["']([^"']+)["']\s*\})/g;
|
||||
|
||||
let m;
|
||||
while ((m = imgRe.exec(stripped))) push(m[1], 'image', m.index);
|
||||
while ((m = linkRe.exec(stripped))) push(m[2], 'link', m.index);
|
||||
while ((m = attrRe.exec(stripped))) push(m[1] || m[2] || m[3], 'attr', m.index);
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function lineOf(content, index) {
|
||||
return content.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function isValidRef(url, mdxPath) {
|
||||
if (isExternal(url)) return { ok: true };
|
||||
const target = stripFragment(url);
|
||||
if (!target) return { ok: true };
|
||||
|
||||
// 绝对路径
|
||||
if (target.startsWith('/')) {
|
||||
if (target.startsWith('/imgs/')) {
|
||||
const p = path.join(PUBLIC_DIR, target);
|
||||
return fileExists(p) ? { ok: true } : { ok: false, expected: p };
|
||||
}
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
if (ext && !MDX_EXTS.includes(ext)) {
|
||||
const p = path.join(PUBLIC_DIR, target);
|
||||
return fileExists(p) ? { ok: true } : { ok: false, expected: p };
|
||||
}
|
||||
const langMatch = target.match(/^\/(en|zh)\/(.+)$/);
|
||||
const subRaw = langMatch ? langMatch[2] : target.slice(1);
|
||||
const sub = normalizeRoutePath(subRaw);
|
||||
const base = path.join(CONTENT_DIR, sub);
|
||||
return routeExists(base)
|
||||
? { ok: true }
|
||||
: { ok: false, expected: `${base}(.mdx | .en.mdx | /index.mdx)` };
|
||||
}
|
||||
// 相对路径
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
if (ext && !MDX_EXTS.includes(ext)) {
|
||||
const resolved = path.resolve(path.dirname(mdxPath), target);
|
||||
if (FILE_LIKE_EXTS.has(ext)) {
|
||||
return fileExists(resolved) ? { ok: true } : { ok: false, expected: resolved };
|
||||
}
|
||||
return fileExists(resolved) ? { ok: true } : { ok: false, expected: resolved };
|
||||
}
|
||||
const normalized = normalizeRoutePath(target);
|
||||
const resolved = path.resolve(path.dirname(mdxPath), normalized);
|
||||
return routeExists(resolved)
|
||||
? { ok: true }
|
||||
: { ok: false, expected: `${resolved}(.mdx | .en.mdx | /index.mdx)` };
|
||||
}
|
||||
|
||||
function isMetaFile(name) {
|
||||
return /^meta(\.en)?\.json$/.test(name);
|
||||
}
|
||||
|
||||
function isIndexFile(name) {
|
||||
return /^index(\.en)?\.mdx?$/i.test(name);
|
||||
}
|
||||
|
||||
function slugFromFile(name) {
|
||||
return name.replace(/\.(en\.)?mdx?$/i, '');
|
||||
}
|
||||
|
||||
function dirHasMeta(dir) {
|
||||
return fileExists(path.join(dir, 'meta.json')) || fileExists(path.join(dir, 'meta.en.json'));
|
||||
}
|
||||
|
||||
// 收集 dir 下所有「应被父级 meta 引用」的 slug。
|
||||
// - 子目录若有自己的 meta,则父级只需引用目录名,不下钻
|
||||
// - 子目录若无 meta,则父级须以 `subdir/file` 形式列出每个文件
|
||||
function collectExpectedSlugs(dir, prefix = '') {
|
||||
const out = new Set();
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (isMetaFile(entry.name)) continue;
|
||||
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
if (!/\.mdx?$/i.test(entry.name)) continue;
|
||||
if (isIndexFile(entry.name)) continue;
|
||||
const slug = slugFromFile(entry.name);
|
||||
out.add(prefix ? `${prefix}/${slug}` : slug);
|
||||
} else if (entry.isDirectory()) {
|
||||
const childPrefix = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (dirHasMeta(full)) {
|
||||
out.add(childPrefix);
|
||||
} else {
|
||||
for (const s of collectExpectedSlugs(full, childPrefix)) out.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkMetaCoverage() {
|
||||
const errors = [];
|
||||
|
||||
(function walk(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !isMetaFile(entry.name)) continue;
|
||||
const metaPath = path.join(dir, entry.name);
|
||||
let meta;
|
||||
try {
|
||||
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
errors.push({ file: metaPath, missing: [], parseError: e.message });
|
||||
continue;
|
||||
}
|
||||
const pages = Array.isArray(meta.pages) ? meta.pages : [];
|
||||
|
||||
const declared = new Set();
|
||||
const wildcards = new Set();
|
||||
for (const p of pages) {
|
||||
if (typeof p !== 'string') continue;
|
||||
if (/^---.*---$/.test(p)) continue;
|
||||
if (p.startsWith('...')) {
|
||||
wildcards.add(p.slice(3));
|
||||
continue;
|
||||
}
|
||||
declared.add(p);
|
||||
}
|
||||
|
||||
const expected = collectExpectedSlugs(dir);
|
||||
const missing = [];
|
||||
for (const slug of expected) {
|
||||
const topLevel = slug.split('/')[0];
|
||||
if (wildcards.has(topLevel)) continue;
|
||||
if (declared.has(slug)) continue;
|
||||
missing.push(slug);
|
||||
}
|
||||
if (missing.length > 0) errors.push({ file: metaPath, missing });
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
walk(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
})(CONTENT_DIR);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2).filter(Boolean);
|
||||
const targetFiles = args.length
|
||||
? args
|
||||
.map((p) => path.resolve(p))
|
||||
.filter((p) => MDX_EXTS.includes(path.extname(p).toLowerCase()) && fileExists(p))
|
||||
: getAllMdxFiles(CONTENT_DIR);
|
||||
|
||||
const refErrors = [];
|
||||
for (const mdx of targetFiles) {
|
||||
const content = fs.readFileSync(mdx, 'utf-8');
|
||||
const refs = extractRefs(content);
|
||||
for (const { url, type, index } of refs) {
|
||||
const result = isValidRef(url, mdx);
|
||||
if (!result.ok) {
|
||||
refErrors.push({
|
||||
file: mdx,
|
||||
line: lineOf(content, index),
|
||||
url,
|
||||
type,
|
||||
expected: result.expected
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// meta 覆盖校验始终全量执行(与 args 无关)
|
||||
const metaErrors = checkMetaCoverage();
|
||||
|
||||
const total = refErrors.length + metaErrors.length;
|
||||
if (total === 0) {
|
||||
console.log(`✅ 已校验 ${targetFiles.length} 个 mdx 文件 + 全部 meta.json,未发现问题`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (refErrors.length > 0) {
|
||||
console.error(`❌ 发现 ${refErrors.length} 处无效引用:\n`);
|
||||
for (const e of refErrors) {
|
||||
const src = path.relative(DOCUMENT_ROOT, e.file);
|
||||
console.error(` ${src}:${e.line}`);
|
||||
console.error(` [${e.type}] ${e.url}`);
|
||||
console.error(` ↳ 不存在: ${e.expected}`);
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
|
||||
if (metaErrors.length > 0) {
|
||||
console.error(`❌ 发现 ${metaErrors.length} 个 meta 文件存在问题:\n`);
|
||||
for (const e of metaErrors) {
|
||||
const src = path.relative(DOCUMENT_ROOT, e.file);
|
||||
if (e.parseError) {
|
||||
console.error(` ${src}`);
|
||||
console.error(` ↳ JSON 解析失败: ${e.parseError}`);
|
||||
} else {
|
||||
console.error(` ${src}`);
|
||||
console.error(` ↳ pages 中遗漏 ${e.missing.length} 项:`);
|
||||
for (const m of e.missing) console.error(` - ${m}`);
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user