#!/usr/bin/env bun // The "light pages" lint (documentation plan, October 2026, "Keeping it light"). // A product's Start here and Guides pages are written for the Evaluator and the // Builder, who do not know our engineering vocabulary and should not need to; // the rule in conventions/docs.md ("Voice") bans those words but had no scope // and no gate, so it was broken by the very indexes that cite it. This gives it // a scope — the light half of a site, and a repo's docs/quickstart.md — and a // gate: a word from the deny list in PROSE is a finding, and so is a page over // its word budget. Code (fenced and inline), HTML comments and MDX import lines // are not prose, because `norsk-ctl instance launch-template` is a command the // reader types, not a word they have to understand. // // Usage: // bun light-pages.ts [--root ] [--allow w,w] [--deny w,w] [--max-words N] [file...] // // With no files it lints defaultLightPages(root). `--allow` is how a repo keeps // a word that is its own vocabulary. `instance` is deliberately not on the list: // it is the label on norsk-ctl's own screens, so every product's reader sees it. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, posix, relative } from "node:path"; export interface LightFinding { file: string; line: number; message: string; } export interface LightPagesOptions { /** Words that must not appear in prose; defaults to DEFAULT_DENY. */ deny?: string[]; /** Words removed from the deny list for this repo. */ allow?: string[]; /** Prose word budget per page; unset means no budget. */ maxWords?: number; } /** The engineering vocabulary conventions/docs.md bans from reader-facing copy, * plus the words the October 2026 review found leaking onto quickstarts. */ export const DEFAULT_DENY = [ "daemon", "engine", "fixture", "fixtures", "manifest", "manifests", "compose", "harness", "playwright", "assertion", "assertions", "slug", "slugs", "ssr", "zod", "websocket", "websockets", "iframe", "oauth2", ]; const FENCE = /^\s*(```|~~~)/; const INLINE_CODE = /`[^`\n]*`/g; const HTML_COMMENT = //g; const MDX_COMMENT = /\{\/\*[\s\S]*?\*\/\}/g; const IMPORT_LINE = /^\s*import\s.*$/; /** The page with everything that is not prose blanked out, line structure kept * so findings can name a line. Frontmatter is prose: its description renders. */ function proseLines(content: string): string[] { const withoutComments = content .replace(HTML_COMMENT, (m) => m.replace(/[^\n]/g, " ")) .replace(MDX_COMMENT, (m) => m.replace(/[^\n]/g, " ")); const out: string[] = []; let inFence = false; let inFrontmatter = false; const lines = withoutComments.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ""; if (i === 0 && line.trim() === "---") { inFrontmatter = true; out.push(""); continue; } if (inFrontmatter) { if (line.trim() === "---") inFrontmatter = false; out.push(""); continue; } if (FENCE.test(line)) { inFence = !inFence; out.push(""); continue; } if (inFence || IMPORT_LINE.test(line)) { out.push(""); continue; } out.push(line.replace(INLINE_CODE, " ")); } return out; } function countWords(lines: string[]): number { let n = 0; for (const line of lines) { const stripped = line.replace(/<[^>]+>/g, " "); n += stripped.split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length; } return n; } export function checkLightPage(file: string, content: string, opts: LightPagesOptions = {}): LightFinding[] { const allow = new Set((opts.allow ?? []).map((w) => w.toLowerCase())); const deny = (opts.deny ?? DEFAULT_DENY).map((w) => w.toLowerCase()).filter((w) => !allow.has(w)); const findings: LightFinding[] = []; const lines = proseLines(content); if (deny.length > 0) { const pattern = new RegExp(`\\b(${deny.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "gi"); lines.forEach((line, i) => { const seen = new Set(); for (const m of line.matchAll(pattern)) { const word = (m[1] ?? "").toLowerCase(); if (seen.has(word)) continue; seen.add(word); findings.push({ file, line: i + 1, message: `"${m[1]}" is engineering vocabulary; this page is written for a reader who does not know it. Say what it is in the product's own words, or move the sentence to How-to or Reference.`, }); } }); } if (opts.maxWords !== undefined) { const words = countWords(lines); if (words > opts.maxWords) { findings.push({ file, line: 1, message: `${words} words of prose against a budget of ${opts.maxWords}. Move options and alternatives to How-to; keep the happy path.`, }); } } return findings; } /** A site's Start here pages and Guides, plus a repo's docs/quickstart.md: * the pages the lint applies to when the caller names none. */ export function defaultLightPages(root: string): string[] { const out: string[] = []; const site = join(root, "website", "src", "content", "docs"); for (const name of ["index.md", "index.mdx", "quickstart.md", "quickstart.mdx"]) { if (existsSync(join(site, name))) out.push(posix.join("website/src/content/docs", name)); } const guides = join(site, "guides"); if (existsSync(guides)) { const walk = (dir: string) => { for (const name of readdirSync(dir).sort()) { const p = join(dir, name); if (statSync(p).isDirectory()) walk(p); else if (/\.mdx?$/.test(name)) out.push(relative(root, p).split("\\").join("/")); } }; walk(guides); } if (existsSync(join(root, "docs", "quickstart.md"))) out.push("docs/quickstart.md"); return out; } export function checkLightPages(root: string, files: string[], opts: LightPagesOptions = {}): LightFinding[] { const findings: LightFinding[] = []; for (const file of files) { const content = readFileSync(join(root, file), "utf8"); findings.push(...checkLightPage(file, content, opts)); } return findings; } function parseArgs(argv: string[]): { root: string; files: string[]; opts: LightPagesOptions } { let root = process.cwd(); const files: string[] = []; const opts: LightPagesOptions = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i] ?? ""; const next = () => argv[++i] ?? ""; if (a === "--root") root = next(); else if (a === "--allow") opts.allow = next().split(",").filter(Boolean); else if (a === "--deny") opts.deny = next().split(",").filter(Boolean); else if (a === "--max-words") opts.maxWords = Number(next()); else files.push(a); } return { root, files, opts }; } if (import.meta.main) { const { root, files, opts } = parseArgs(process.argv.slice(2)); const targets = files.length > 0 ? files : defaultLightPages(root); const findings = checkLightPages(root, targets, opts); if (findings.length === 0) { console.log(`light-pages: OK (${targets.length} page${targets.length === 1 ? "" : "s"})`); } else { console.error(`light-pages: ${findings.length} finding${findings.length === 1 ? "" : "s"}`); for (const f of findings) console.error(` - ${f.file}:${f.line}: ${f.message}`); process.exit(1); } }