johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// The docs: every relative link reaches a file, and every `#anchor` a
// heading in it, as GitHub and the site slug them; and every Mermaid block
// draws, with Pele as the pages do. Pele comes from web/node_modules, so
// `npm ci` in web/ first.
//
// node harness/docs.mjs
//
// Prints each problem as `file:line: what`, failing if any.
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const pele = join(root, 'web/node_modules/pele/package.json')
if (!existsSync(pele)) {
console.log('harness/docs.mjs: no web/node_modules/pele; run npm ci in web/ first')
process.exit(1)
}
const { render } = await import(
pathToFileURL(join(dirname(pele), JSON.parse(readFileSync(pele, 'utf8')).exports['.'].import))
)
const files = (d) =>
readdirSync(d, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? files(join(d, e.name)) : e.name.endsWith('.md') ? [join(d, e.name)] : [],
)
const docs = ['README.md', 'AGENTS.md', 'deploy/README.md']
.map((f) => join(root, f))
.concat(files(join(root, 'docs')))
// A fence's lines, and the rest, with the line each starts on.
const split = (text) => {
const prose = []
const fences = []
let fence = null
text.split('\n').forEach((line, i) => {
if (line.startsWith('```')) {
if (fence) (fences.push(fence), (fence = null))
else fence = { lang: line.slice(3).trim(), line: i + 1, body: [] }
} else if (fence) fence.body.push(line)
else prose.push([i + 1, line])
})
return { prose, fences }
}
const slugged = new Map()
const slugs = (file) => {
if (!slugged.has(file)) {
const seen = new Map()
const out = new Set()
for (const [, line] of split(readFileSync(file, 'utf8')).prose) {
const m = line.match(/^#{1,6}\s+(.*)/)
if (!m) continue
const slug = m[1]
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}_\- ]/gu, '')
.replaceAll(' ', '-')
const n = seen.get(slug) ?? 0
seen.set(slug, n + 1)
out.add(n ? `${slug}-${n}` : slug)
}
slugged.set(file, out)
}
return slugged.get(file)
}
const problems = []
let diagrams = 0
for (const file of docs) {
const name = relative(root, file)
const { prose, fences } = split(readFileSync(file, 'utf8'))
for (const [n, line] of prose) {
for (const [, url] of line.replace(/`[^`]*`/g, '').matchAll(/\]\(([^)\s]+)\)/g)) {
if (/^[a-z]+:/.test(url)) continue
const [path, anchor] = url.split('#')
const target = path ? resolve(dirname(file), path) : file
if (!existsSync(target)) problems.push(`${name}:${n}: no ${path}`)
else if (anchor && target.endsWith('.md') && !slugs(target).has(anchor))
problems.push(`${name}:${n}: no heading #${anchor} in ${relative(root, target)}`)
}
}
for (const fence of fences.filter((f) => f.lang === 'mermaid')) {
diagrams++
try {
render(fence.body.join('\n'))
} catch (e) {
problems.push(`${name}:${fence.line}: diagram does not draw: ${e.message}`)
}
}
}
for (const p of problems) console.log(p)
console.log(`${docs.length} pages, ${diagrams} diagrams, ${problems.length} problems`)
process.exit(problems.length ? 1 : 0)