Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/scripts/docs-lint/index.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
70 changes: 70 additions & 0 deletions .github/scripts/docs-lint/lib/report.mjs
Original file line number Diff line number Diff line change
@@ -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)`
)
}
178 changes: 178 additions & 0 deletions .github/scripts/docs-lint/rules/tables.mjs
Original file line number Diff line number Diff line change
@@ -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 \<ul> renders as literal text.
* warning adjacent-tables Two pipe tables with same column count and no heading between them.
* warning html-blank-row Blank line after </tr> breaks GitHub/IDE preview.
*/

import { visit } from 'unist-util-visit'

export const id = 'tables'

export function check({ file, sourceLines, mdast, codeFenceMask, reporter }) {
checkPipe(mdast, file, sourceLines, reporter)
checkHtml(file, sourceLines, codeFenceMask, reporter)
}

function checkPipe(mdast, file, sourceLines, reporter) {
visit(mdast, 'table', (node, index, parent) => {
// 1. column count
const headerRow = node.children[0]
const expected = headerRow ? headerRow.children.length : 0
for (const row of node.children.slice(1)) {
if (row.children.length !== expected) {
reporter.add({
file,
line: row.position?.start.line,
col: row.position?.start.column,
rule: 'pipe-col-count',
severity: 'error',
message: `row has ${row.children.length} cells; header has ${expected}`,
})
}
}

// 2. blank line above
if (parent && index > 0) {
const prev = parent.children[index - 1]
const gap = node.position.start.line - prev.position.end.line
if (gap < 2) {
reporter.add({
file,
line: node.position.start.line,
col: 1,
rule: 'pipe-no-blank-above',
severity: 'error',
message: 'pipe table missing blank line above',
})
}
}

// 3. adjacent tables with the same shape — sometimes a true split, sometimes
// distinct tables that should have a heading between them. Either way the
// reader sees two unintroduced tables stacked, so flag it.
if (parent && index > 0) {
const prev = parent.children[index - 1]
if (prev.type === 'table' && prev.children[0].children.length === expected) {
reporter.add({
file,
line: node.position.start.line,
col: 1,
rule: 'adjacent-tables',
severity: 'warning',
message:
'pipe table follows another with the same column count — likely missing a heading between them, or a single table accidentally split by a blank line',
})
}
}

// 4. escaped HTML in cells — scan raw source (markdown strips the backslash before AST text)
const startLine = node.position.start.line
const endLine = node.position.end.line
for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
const src = sourceLines[lineNum - 1] ?? ''
const m = src.match(/\\<\/?[A-Za-z][\w-]*/)
if (m) {
reporter.add({
file,
line: lineNum,
col: (m.index ?? 0) + 1,
rule: 'escaped-html-in-cell',
severity: 'error',
message: `escaped HTML "${m[0]}" in cell — renders as literal text`,
})
}
}
})
}

function checkHtml(file, sourceLines, codeFenceMask, reporter) {
// Source-text scan: any blank line inside an HTML <table> block. CommonMark/GFM
// ends an HTML block at the first blank line, so a blank inside a <table> breaks
// the rest of the block in stricter renderers (GitHub, IDE previews) regardless
// of what surrounds it. We split this into two rules so the fixer can auto-handle
// the easy case and surface the harder one for manual review:
//
// html-blank-between-tags prev ends with `>` AND next starts with `<` — safe
// to delete (auto-fixable).
// html-blank-in-cell blank between cell content (text/blockquote/list) —
// needs editorial judgment to convert to <br />,
// inline emphasis, etc.
//
// Remark fragments multi-line HTML on blank lines into separate `html` nodes, so
// AST walks miss this — direct source scan is simpler and more reliable.
let inTable = false
let inPre = false
for (let i = 0; i < sourceLines.length; i++) {
if (codeFenceMask[i]) continue
const line = sourceLines[i]
const opensTable = /<table[\s>]/i.test(line)
const closesTable = /<\/table\s*>/i.test(line)
const opensPre = /<pre[\s>]/i.test(line)
const closesPre = /<\/pre\s*>/i.test(line)
if (!inTable) {
if (opensTable && !closesTable) inTable = true
continue
}
if (closesTable) {
inTable = false
inPre = false
continue
}
// Track <pre> separately. CommonMark HTML block type 1 (<pre>/<script>/
// <style>) does NOT terminate at a blank line, so blanks inside a <pre>
// are valid (typically code formatting). Skip them.
if (!inPre) {
if (opensPre && !closesPre) {
inPre = true
continue
}
} else {
if (closesPre) inPre = false
continue
}
if (line.trim() !== '') continue

let prev = ''
for (let j = i - 1; j >= 0; j--) {
if (codeFenceMask[j]) continue
if (sourceLines[j].trim() !== '') {
prev = sourceLines[j].trimEnd()
break
}
}
let next = ''
for (let j = i + 1; j < sourceLines.length; j++) {
if (codeFenceMask[j]) continue
if (sourceLines[j].trim() !== '') {
next = sourceLines[j].trimStart()
break
}
}

if (prev.endsWith('>') && next.startsWith('<')) {
reporter.add({
file,
line: i + 1,
col: 1,
rule: 'html-blank-between-tags',
severity: 'warning',
message: 'blank line between tags inside HTML table (breaks GitHub/IDE preview)',
})
} else {
reporter.add({
file,
line: i + 1,
col: 1,
rule: 'html-blank-in-cell',
severity: 'warning',
message:
'blank line inside HTML table cell content (breaks GitHub/IDE preview) — replace with <br /> or restructure',
})
}
}
}
Loading