Files
docs/scripts/lint-mdx-headings.mjs
Jack Carter b968695737 ci: validate PRs with build and MDX heading linter (#743)
* ci: validate PRs with build and MDX heading linter

Adds a pull_request workflow running npm run lint:mdx, npm run build,
and npm run lint so heading-hierarchy bugs and broken builds get caught
before merge rather than after.

The new linter (scripts/lint-mdx-headings.mjs) enforces that the first
heading is h1 and that heading levels never jump by more than one. Also
fixes three existing pages that had no h1 title — two were using a
legacy export const title pattern, one was missing a title entirely.

* ci: use npm install since lockfile is gitignored

package-lock.json is in .gitignore, so npm ci and setup-node's npm
cache both fail on a fresh CI checkout. Match the Dockerfile pattern
(npm install, no cache) instead.

* ci: drop ESLint step; project config is broken

`npm run lint` fails with 'Converting circular structure to JSON'
under ESLint 9.x — the repo has no .eslintrc or eslint.config file,
so the legacy resolver hits the React plugin's circular reference.
This is pre-existing (build_n_push.yml never ran lint, so it stayed
hidden); fixing it needs flat-config migration and is out of scope.
Drop the step until that lands.
2026-05-12 15:18:07 +02:00

98 lines
2.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Lints MDX files for heading-hierarchy violations:
* - the first heading on a page must be h1 (the page title)
* - heading levels may not jump by more than 1 going down (h1 -> h3 is invalid; h3 -> h1 is fine)
*
* Skips src/pages/ipa/resources/ — those are auto-generated from the OpenAPI spec.
* Headings inside fenced code blocks are ignored.
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.join(__dirname, '..')
const PAGES_DIR = path.join(ROOT, 'src/pages')
const SKIP_PREFIX = 'ipa/resources'
function findMdxFiles(dir, basePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true })
const results = []
for (const e of entries) {
const rel = basePath ? `${basePath}/${e.name}` : e.name
if (e.isDirectory()) {
if (rel === SKIP_PREFIX || rel.startsWith(`${SKIP_PREFIX}/`)) continue
results.push(...findMdxFiles(path.join(dir, e.name), rel))
} else if (e.name.endsWith('.mdx')) {
results.push({ relPath: rel, filePath: path.join(dir, e.name) })
}
}
return results
}
function checkHeadings(content) {
const lines = content.split('\n')
const violations = []
let inCodeBlock = false
let prevLevel = 0
let prevLine = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (/^```/.test(line)) {
inCodeBlock = !inCodeBlock
continue
}
if (inCodeBlock) continue
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
if (!match) continue
const level = match[1].length
const text = match[2]
if (prevLevel === 0) {
if (level !== 1) {
violations.push({
line: i + 1,
message: `first heading is h${level} ("${text}"); expected h1`,
})
}
} else if (level > prevLevel + 1) {
violations.push({
line: i + 1,
message: `h${level} ("${text}") skips a level (previous was h${prevLevel} on line ${prevLine})`,
})
}
prevLevel = level
prevLine = i + 1
}
return violations
}
const files = findMdxFiles(PAGES_DIR)
let totalViolations = 0
for (const { relPath, filePath } of files) {
const content = fs.readFileSync(filePath, 'utf-8')
const violations = checkHeadings(content)
if (violations.length > 0) {
console.error(`\nsrc/pages/${relPath}`)
for (const v of violations) {
console.error(` line ${v.line}: ${v.message}`)
}
totalViolations += violations.length
}
}
if (totalViolations > 0) {
console.error(`\n${totalViolations} heading hierarchy violation(s) found.`)
process.exit(1)
}
console.log(`Checked ${files.length} MDX files — no heading hierarchy violations.`)