#!/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);