#!/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);
}
}