#!/usr/bin/env node /** * CI: spot-check markdown links to repo-relative paths (../ or ./ from each file). * * Skips `packages/bare-os-seeder/kernel/**`: it is a byte-identical copy of `kernel/` * (see verify-kernel-seeder-parity.mjs). Links in those files are validated from the * canonical `kernel/**` paths only. */ import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const GLOBS = [ 'docs', 'handbook', 'developer-guide', 'scripts', 'users-manual', 'packages', 'kernel', ] const ROOT_MD_FILES = ['README.md', 'DOCUMENTATION.md', 'PEAR-RUN.md'] /** Copied into `kernel/lib/bare/README.md`; links are relative to that path. */ const SKIP_FILES = new Set(['packages/bare-os-bare-libs/README.kernel-lib-bare.md']) function isSkippedPath(relFromRoot) { const norm = relFromRoot.split(path.sep).join('/') return norm === 'packages/bare-os-seeder/kernel' || norm.startsWith('packages/bare-os-seeder/kernel/') } /** * @param {string} dir * @returns {string[]} */ function walkMd(dir) { const abs = path.join(root, dir) if (!fs.existsSync(abs)) return [] /** @type {string[]} */ const out = [] const st = fs.statSync(abs) if (st.isFile() && abs.endsWith('.md')) { out.push(abs) return out } if (!st.isDirectory()) return out for (const ent of fs.readdirSync(abs, { withFileTypes: true })) { if (ent.name === 'node_modules' || ent.name === '.git') continue const p = path.join(abs, ent.name) const relFromRoot = p.slice(root.length + 1) if (isSkippedPath(relFromRoot)) continue if (ent.isDirectory()) out.push(...walkMd(relFromRoot)) else if (ent.isFile() && ent.name.endsWith('.md')) out.push(p) } return out } /** * @param {string} file * @param {string[]} errors */ function collectMdLinkErrors(file, errors) { const rel = path.relative(root, file) if (SKIP_FILES.has(rel.split(path.sep).join('/')) || isSkippedPath(rel)) return const text = fs.readFileSync(file, 'utf8') const re = /\]\(([^)#\s]+\.md)(#[^)]*)?\)/g let m while ((m = re.exec(text))) { const target = m[1] if (target.startsWith('http')) continue const resolved = path.normalize(path.join(path.dirname(file), target)) if (!fs.existsSync(resolved)) { errors.push( `${path.relative(root, file)} → missing ${path.relative(root, resolved)}` ) } } } function main() { /** @type {string[]} */ const errors = [] for (const g of GLOBS) { for (const file of walkMd(g)) { collectMdLinkErrors(file, errors) } } for (const name of ROOT_MD_FILES) { const file = path.join(root, name) if (fs.existsSync(file)) collectMdLinkErrors(file, errors) } if (errors.length) { console.error('verify-doc-links: broken relative .md links:') for (const e of errors.slice(0, 80)) console.error(' ', e) if (errors.length > 80) console.error(` … and ${errors.length - 80} more`) process.exit(1) } console.log('verify-doc-links: OK') } main()