From 822db9472bac6aa1c4cc9283f792aea023f0d6d8 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 3 May 2026 01:16:22 +0200 Subject: [PATCH] feat(docs): add doc-link checker script --- scripts/check-doc-links.mjs | 115 ++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 scripts/check-doc-links.mjs diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs new file mode 100644 index 0000000..a47d2dd --- /dev/null +++ b/scripts/check-doc-links.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Doc-link checker: walks docs/ (and README.md, CLAUDE.md, AGENTS.md), + * extracts relative markdown links, and verifies that every target file + * (and optional #anchor) actually exists. + * + * Exits 0 if all links are valid, 1 if any are broken. + */ + +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { resolve, dirname, extname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +// Collect all .md files under a directory recursively +function collectMd(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = resolve(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + results.push(...collectMd(full)); + } else if (extname(entry) === '.md') { + results.push(full); + } + } + return results; +} + +// Convert a heading text to a GitHub-style anchor slug +function toSlug(text) { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-'); +} + +// Extract all heading slugs from a markdown file +function headingSlugs(filePath) { + const content = readFileSync(filePath, 'utf8'); + const slugs = new Set(); + for (const line of content.split('\n')) { + const m = line.match(/^#{1,6}\s+(.+)/); + if (m) slugs.add(toSlug(m[1])); + } + return slugs; +} + +const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g; + +function checkFile(filePath) { + const content = readFileSync(filePath, 'utf8'); + const failures = []; + let m; + while ((m = LINK_RE.exec(content)) !== null) { + const raw = m[1]; + // Skip external links and anchors-only + if (/^https?:\/\//.test(raw) || /^mailto:/.test(raw) || raw.startsWith('#')) continue; + + const [pathPart, anchor] = raw.split('#'); + const target = resolve(dirname(filePath), pathPart); + + if (!existsSync(target)) { + failures.push({ file: filePath, link: raw, reason: 'file not found' }); + continue; + } + + if (anchor) { + const slugs = headingSlugs(target); + if (!slugs.has(anchor)) { + failures.push({ file: filePath, link: raw, reason: `anchor #${anchor} not found` }); + } + } + } + return failures; +} + +const roots = [ + resolve(ROOT, 'docs'), + resolve(ROOT, 'README.md'), + resolve(ROOT, 'CLAUDE.md'), + resolve(ROOT, 'AGENTS.md'), +]; + +const files = []; +for (const r of roots) { + if (!existsSync(r)) continue; + const stat = statSync(r); + if (stat.isDirectory()) { + files.push(...collectMd(r)); + } else { + files.push(r); + } +} + +const allFailures = []; +for (const f of files) { + allFailures.push(...checkFile(f)); +} + +if (allFailures.length === 0) { + console.log(`āœ“ All doc links valid (${files.length} files checked)`); + process.exit(0); +} else { + console.error(`\nāœ— Broken doc links (${allFailures.length}):\n`); + for (const { file, link, reason } of allFailures) { + const rel = file.replace(ROOT + '/', ''); + console.error(` ${rel}\n → ${link} (${reason})`); + } + console.error(''); + process.exit(1); +}