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.
This commit is contained in:
Jack Carter
2026-05-12 15:18:07 +02:00
committed by GitHub
parent 1af8f57301
commit b968695737
6 changed files with 135 additions and 4 deletions

33
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: pr-build
on: [pull_request]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true
jobs:
build:
name: lint and build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Lint MDX heading hierarchy
run: npm run lint:mdx
- name: Build
run: npm run build

View File

@@ -11,7 +11,8 @@
"gen": "swagger-codegen generate -i https://raw.githubusercontent.com/netbirdio/netbird/main/management/server/http/api/openapi.yml -l openapi -o generator/openapi && npx ts-node generator/index.ts gen --input generator/openapi/openapi.json --output src/pages/ipa/resources",
"gen:llm": "node scripts/generate-llm-docs.mjs",
"start": "next start",
"lint": "eslint src/"
"lint": "eslint src/",
"lint:mdx": "node scripts/lint-mdx-headings.mjs"
},
"browserslist": {
"production": [

View File

@@ -0,0 +1,97 @@
#!/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.`)

View File

@@ -1,6 +1,6 @@
import {Note} from "@/components/mdx";
export const title = 'Other'
# Other
## Google Summer of Code Ideas - 2022

View File

@@ -1,6 +1,6 @@
import {Note} from "@/components/mdx";
export const title = 'Understanding NAT and Connectivity'
# Understanding NAT and Connectivity
## What is NAT?

View File

@@ -1,4 +1,4 @@
## Client Sidecar
# Client Sidecar
In certain situations we may want to have a pod act like a peer in the Netbird network instead of exposing it through a routing peer. In these cases a Netbird client container has to be added as a sidecar to the pod.