Files
margin-mail/scripts/docs-check.mjs
T

73 lines
2.7 KiB
JavaScript

#!/usr/bin/env node
// The prose gate. Three rules, checked over every markdown file in the repository:
//
// 1. No em dash and no en dash, anywhere, including inside code fences. This is a house rule and
// it is absolute: a comma, a colon, a semicolon, brackets or a full stop, whichever fits.
// 2. No directory tree. A tree in a committed file is stale the day somebody adds a file, and
// naming the one path that matters is what a reader actually needed.
// 3. No broken relative link. A link to a file that is not there is worse than no link, because
// it reads as though the detail exists somewhere.
//
// Run by `just docs` and by CI, and worth running before any hand-off.
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const SKIP = new Set(["node_modules", "dist", "target", ".git", "gen", ".playwright-mcp"]);
function markdownFiles(dir) {
const found = [];
for (const entry of readdirSync(dir)) {
if (SKIP.has(entry)) continue;
const path = join(dir, entry);
if (statSync(path).isDirectory()) found.push(...markdownFiles(path));
else if (entry.endsWith(".md")) found.push(path);
}
return found;
}
// A run of box-drawing characters, or two or more lines of the ASCII form. One stray `|--` in a
// table is not a tree; four lines of them under a fenced block is.
const TREE_GLYPHS = /[├└│┬─]{2,}/;
const ASCII_TREE = /^\s*[|`+]--\s/;
const problems = [];
for (const path of markdownFiles(root)) {
const shown = relative(root, path);
const lines = readFileSync(path, "utf8").split("\n");
let asciiTreeRun = 0;
lines.forEach((line, i) => {
const at = `${shown}:${i + 1}`;
const dash = line.match(/[—–]/);
if (dash) problems.push(`${at}: ${dash[0] === "—" ? "em" : "en"} dash: ${line.trim()}`);
if (TREE_GLYPHS.test(line)) problems.push(`${at}: directory tree: ${line.trim()}`);
if (ASCII_TREE.test(line)) {
asciiTreeRun += 1;
if (asciiTreeRun === 3) problems.push(`${at}: directory tree`);
} else {
asciiTreeRun = 0;
}
for (const [, target] of line.matchAll(/\]\(([^)#\s]+)(?:#[^)\s]*)?\)/g)) {
if (/^[a-z]+:/i.test(target) || target.startsWith("/")) continue;
const resolved = resolve(dirname(path), decodeURIComponent(target));
if (!existsSync(resolved)) problems.push(`${at}: link goes nowhere: ${target}`);
}
});
}
if (problems.length === 0) {
console.log("docs: clean");
process.exit(0);
}
console.error("docs: problems found");
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);