Files
bare-operating-system/scripts/verify-doc-links.mjs
T
2026-04-04 08:40:17 -04:00

106 lines
3.1 KiB
JavaScript

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