~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
96 lines · 3.3 KB Raw History
// 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)