From b968695737dd9149c713353b1ebe095ddb52415e Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Tue, 12 May 2026 15:18:07 +0200 Subject: [PATCH] ci: validate PRs with build and MDX heading linter (#743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .github/workflows/pr-build.yml | 33 +++++++ package.json | 3 +- scripts/lint-mdx-headings.mjs | 97 +++++++++++++++++++ src/pages/about-netbird/other.mdx | 2 +- .../understanding-nat-and-connectivity.mdx | 2 +- .../kubernetes/client-sidecar.mdx | 2 +- 6 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pr-build.yml create mode 100644 scripts/lint-mdx-headings.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 00000000..88d2c70d --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -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 diff --git a/package.json b/package.json index 0b7565fa..428da65d 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/lint-mdx-headings.mjs b/scripts/lint-mdx-headings.mjs new file mode 100644 index 00000000..3adc89ed --- /dev/null +++ b/scripts/lint-mdx-headings.mjs @@ -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.`) diff --git a/src/pages/about-netbird/other.mdx b/src/pages/about-netbird/other.mdx index 43eb4501..f14e91c2 100644 --- a/src/pages/about-netbird/other.mdx +++ b/src/pages/about-netbird/other.mdx @@ -1,6 +1,6 @@ import {Note} from "@/components/mdx"; -export const title = 'Other' +# Other ## Google Summer of Code Ideas - 2022 diff --git a/src/pages/about-netbird/understanding-nat-and-connectivity.mdx b/src/pages/about-netbird/understanding-nat-and-connectivity.mdx index ba2f960a..3a7f702f 100644 --- a/src/pages/about-netbird/understanding-nat-and-connectivity.mdx +++ b/src/pages/about-netbird/understanding-nat-and-connectivity.mdx @@ -1,6 +1,6 @@ import {Note} from "@/components/mdx"; -export const title = 'Understanding NAT and Connectivity' +# Understanding NAT and Connectivity ## What is NAT? diff --git a/src/pages/manage/integrations/kubernetes/client-sidecar.mdx b/src/pages/manage/integrations/kubernetes/client-sidecar.mdx index 8ee24c13..2d695c3a 100644 --- a/src/pages/manage/integrations/kubernetes/client-sidecar.mdx +++ b/src/pages/manage/integrations/kubernetes/client-sidecar.mdx @@ -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.