diff --git a/.github/scripts/docs-lint/index.mjs b/.github/scripts/docs-lint/index.mjs new file mode 100644 index 00000000..8bce140b --- /dev/null +++ b/.github/scripts/docs-lint/index.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * docs-lint — orchestrator for documentation lint rules. + * + * Usage: + * node .github/scripts/docs-lint/index.mjs # lint all synced doc paths + * node .github/scripts/docs-lint/index.mjs file [...] # lint specific files + * + * When run inside GitHub Actions (GITHUB_ACTIONS=true), emits ::error / ::warning + * workflow commands so issues appear inline as PR annotations. + * + * Adding a new rule module: + * 1. Drop a file in ./rules/ that exports `id` and `check({file, sourceLines, mdast, codeFenceMask, reporter})`. + * 2. Import + register it below. + */ + +import { readFile } from 'node:fs/promises' +import { glob } from 'glob' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkGfm from 'remark-gfm' + +import { Reporter, printReport } from './lib/report.mjs' +import * as tables from './rules/tables.mjs' + +const RULES = [tables] + +const SYNC_PATHS = [ + 'docs/**/*.md', + 'reference/**/*.md', + 'custom_pages/**/*.md', + 'custom_blocks/**/*.md', +] + +const mdProcessor = unified().use(remarkParse).use(remarkGfm) + +function buildCodeFenceMask(sourceLines) { + const mask = new Array(sourceLines.length).fill(false) + let inFence = false + let marker = null + for (let i = 0; i < sourceLines.length; i++) { + const stripped = sourceLines[i].trimStart() + if (!inFence) { + const m = stripped.match(/^(```+|~~~+)/) + if (m) { + inFence = true + marker = m[1][0].repeat(3) + mask[i] = true + continue + } + } else { + mask[i] = true + if (stripped.startsWith(marker)) { + inFence = false + marker = null + } + } + } + return mask +} + +async function lintFile(file, reporter) { + const source = await readFile(file, 'utf8') + const sourceLines = source.split('\n') + const codeFenceMask = buildCodeFenceMask(sourceLines) + const mdast = mdProcessor.parse(source) + + const ctx = { file, sourceLines, mdast, codeFenceMask, reporter } + for (const rule of RULES) { + rule.check(ctx) + } +} + +async function main() { + const argv = process.argv.slice(2) + let files + if (argv.length > 0) { + // Filter to .md files in synced paths so the workflow can pass the full PR + // change set without us needing to filter externally. + files = argv.filter( + (f) => /\.md$/i.test(f) && SYNC_PATHS.some((p) => f.startsWith(p.split('/')[0] + '/')) + ) + } else { + files = (await Promise.all(SYNC_PATHS.map((p) => glob(p)))).flat() + } + files = files.sort() + + if (files.length === 0) { + console.log('No markdown files in synced paths to lint.') + return + } + + const reporter = new Reporter() + for (const f of files) { + try { + await lintFile(f, reporter) + } catch (e) { + console.error(`Error linting ${f}: ${e.message}`) + } + } + + if (reporter.issues.length === 0) { + console.log(`No issues found across ${files.length} file(s).`) + return + } + + printReport(reporter) + if (reporter.errorCount() > 0) process.exit(1) +} + +main().catch((e) => { + console.error(e) + process.exit(2) +}) diff --git a/.github/scripts/docs-lint/lib/report.mjs b/.github/scripts/docs-lint/lib/report.mjs new file mode 100644 index 00000000..19f37b68 --- /dev/null +++ b/.github/scripts/docs-lint/lib/report.mjs @@ -0,0 +1,70 @@ +/** + * Shared reporter for docs-lint. + * + * Each rule pushes issues onto a single `Reporter`. The orchestrator handles + * grouping, summary printing, GitHub Actions annotations, and exit codes. + */ + +const inActions = process.env.GITHUB_ACTIONS === 'true' + +export class Reporter { + constructor() { + this.issues = [] + } + + add({ file, line, col, rule, severity, message }) { + this.issues.push({ + file, + line: line ?? 0, + col: col ?? 0, + rule, + severity: severity ?? 'warning', + message, + }) + } + + errorCount() { + return this.issues.filter((i) => i.severity === 'error').length + } +} + +/** Emit a GitHub Actions workflow command for a single issue. */ +export function emitAnnotation(issue) { + const msg = `[${issue.rule}] ${issue.message}` + .replace(/%/g, '%25') + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + console.log( + `::${issue.severity} file=${issue.file},line=${issue.line},col=${issue.col},title=docs-lint::${msg}` + ) +} + +/** Print a human-readable report and (in CI) emit annotations. */ +export function printReport(reporter) { + const { issues } = reporter + if (issues.length === 0) return + + const byRule = {} + const byFile = {} + for (const i of issues) { + byRule[i.rule] = (byRule[i.rule] ?? 0) + 1 + ;(byFile[i.file] ||= []).push(i) + if (inActions) emitAnnotation(i) + } + + for (const file of Object.keys(byFile).sort()) { + console.log(file) + for (const i of byFile[file]) { + console.log(` ${i.line}:${i.col} [${i.severity}] [${i.rule}] ${i.message}`) + } + } + + console.log('\n=== Summary ===') + for (const rule of Object.keys(byRule).sort()) { + const sev = issues.find((i) => i.rule === rule).severity + console.log(` ${rule.padEnd(24)} ${String(byRule[rule]).padStart(5)} ${sev}`) + } + console.log( + ` ${'TOTAL'.padEnd(24)} ${String(issues.length).padStart(5)} ${reporter.errorCount()} errors across ${Object.keys(byFile).length} file(s)` + ) +} diff --git a/.github/scripts/docs-lint/rules/tables.mjs b/.github/scripts/docs-lint/rules/tables.mjs new file mode 100644 index 00000000..fd72f918 --- /dev/null +++ b/.github/scripts/docs-lint/rules/tables.mjs @@ -0,0 +1,178 @@ +/** + * Table rules for docs-lint. + * + * Rules and severity: + * error pipe-col-count Pipe row has different column count than header. + * error pipe-no-blank-above Pipe table has no blank line before it (won't render). + * error escaped-html-in-cell Cell content like \