79 lines
2.6 KiB
JavaScript
79 lines
2.6 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* Check internal doc links: resolve relative paths from each source file
|
||
|
|
* and report only targets that are missing (file or directory).
|
||
|
|
* Usage: node scripts/check-doc-links.mjs
|
||
|
|
*/
|
||
|
|
import { readdirSync, readFileSync, existsSync } from 'fs';
|
||
|
|
import { join, resolve, dirname, normalize } from 'path';
|
||
|
|
import { fileURLToPath } from 'url';
|
||
|
|
|
||
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const REPO_ROOT = resolve(__dirname, '..');
|
||
|
|
const DOCS = join(REPO_ROOT, 'docs');
|
||
|
|
|
||
|
|
function* walkMd(dir, prefix = '') {
|
||
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||
|
|
for (const e of entries) {
|
||
|
|
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
||
|
|
if (e.isDirectory()) {
|
||
|
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
||
|
|
yield* walkMd(join(dir, e.name), rel);
|
||
|
|
} else if (e.name.endsWith('.md')) {
|
||
|
|
yield { path: join(dir, e.name), rel: join(prefix, e.name) };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const linkRe = /\]\(([^)]+)\)/g;
|
||
|
|
|
||
|
|
function resolveTarget(fromDir, href) {
|
||
|
|
const pathOnly = href.replace(/#.*$/, '').trim();
|
||
|
|
if (!pathOnly || pathOnly.startsWith('http://') || pathOnly.startsWith('https://') || pathOnly.startsWith('mailto:')) return null;
|
||
|
|
if (pathOnly.startsWith('#')) return null;
|
||
|
|
if (pathOnly.startsWith('~/')) return null; // skip home-relative
|
||
|
|
let resolved;
|
||
|
|
if (pathOnly.startsWith('/')) {
|
||
|
|
// repo-root-relative: /docs/... or /reports/...
|
||
|
|
resolved = normalize(join(REPO_ROOT, pathOnly.slice(1)));
|
||
|
|
} else {
|
||
|
|
resolved = normalize(join(fromDir, pathOnly));
|
||
|
|
}
|
||
|
|
return resolved.startsWith(REPO_ROOT) ? resolved : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const broken = [];
|
||
|
|
const seen = new Set();
|
||
|
|
|
||
|
|
for (const { path: filePath, rel } of walkMd(DOCS)) {
|
||
|
|
const fromDir = dirname(filePath);
|
||
|
|
const content = readFileSync(filePath, 'utf8');
|
||
|
|
let m;
|
||
|
|
linkRe.lastIndex = 0;
|
||
|
|
while ((m = linkRe.exec(content)) !== null) {
|
||
|
|
const href = m[1];
|
||
|
|
const targetPath = resolveTarget(fromDir, href);
|
||
|
|
if (!targetPath) continue;
|
||
|
|
const key = `${rel} -> ${href}`;
|
||
|
|
if (seen.has(key)) continue;
|
||
|
|
seen.add(key);
|
||
|
|
if (!existsSync(targetPath)) {
|
||
|
|
broken.push({ source: rel, link: href, resolved: targetPath.replace(REPO_ROOT + '/', '') });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('=== Doc link check (docs/ only, relative links resolved from source file) ===\n');
|
||
|
|
if (broken.length === 0) {
|
||
|
|
console.log('No broken internal links found.');
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
console.log(`Found ${broken.length} broken link(s):\n`);
|
||
|
|
broken.forEach(({ source, link, resolved }) => {
|
||
|
|
console.log(` ${source}`);
|
||
|
|
console.log(` -> ${link}`);
|
||
|
|
console.log(` resolved: ${resolved}`);
|
||
|
|
console.log('');
|
||
|
|
});
|
||
|
|
process.exit(1);
|