#!/usr/bin/env bun
// The journey-table lint (documentation plan, October 2026, "Who we write for").
// Each product's docs/personas.md carries one table saying where each of the
// four readers starts, which pages they read in order, and where they are
// handed off — ctl's manual for the Administrator and the Integrator. The
// table is the product's statement of its journeys; this checks that it exists,
// covers every persona once, keeps each route to five pages, names only pages
// that exist, and always ends in a hand-off. A site path `/docs/x/` resolves to
// website/src/content/docs/x.{md,mdx} or x/index.{md,mdx}; a bare `name.md`
// resolves under docs/; an https link is a hand-off and is not resolved.
//
// Usage: bun journeys.ts [--root
] [--personas docs/personas.md]
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export const PERSONAS = ["Evaluator", "Builder", "Integrator", "Administrator"] as const;
export const MAX_PAGES = 5;
export interface JourneyRow {
persona: string;
startsAt: string[];
next: string[];
handsOffTo: string;
/** 1-based line of the row in the personas file. */
line: number;
}
export interface JourneyTable {
rows: JourneyRow[];
/** 1-based line of the header row. */
line: number;
}
export interface JourneyFinding {
file: string;
line: number;
message: string;
}
const CELL_PATH = /`([^`]+)`|\]\(([^)]+)\)|(^|\s)(\/docs\/\S*|\S+\.mdx?)(?=\s|,|$)/g;
/** Every path-like reference in a table cell: backticked, a markdown link
* target, or a bare `/docs/...` or `*.md` token. */
export function cellPaths(cell: string): string[] {
const out: string[] = [];
for (const m of cell.matchAll(CELL_PATH)) {
const p = (m[1] ?? m[2] ?? m[4] ?? "").trim();
if (p) out.push(p);
}
return out;
}
/** A persona cell may be bold or a link to its section; the name is the text. */
function plainText(cell: string): string {
return cell
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[*_`]/g, "")
.trim();
}
function splitRow(line: string): string[] {
return line
.trim()
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
}
export function parseJourneyTable(markdown: string): JourneyTable | undefined {
const lines = markdown.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i] ?? "";
if (!line.trim().startsWith("|")) continue;
const header = splitRow(line).map((c) => c.toLowerCase());
const col = (want: string) => header.findIndex((h) => h.startsWith(want));
const persona = col("persona");
const startsAt = col("starts at");
const then = col("then");
const handsOff = col("hands off");
if (persona < 0 || startsAt < 0 || then < 0 || handsOff < 0) continue;
if (!/^\s*\|?\s*:?-+/.test(lines[i + 1] ?? "")) continue;
const rows: JourneyRow[] = [];
for (let j = i + 2; j < lines.length; j++) {
const row = lines[j] ?? "";
if (!row.trim().startsWith("|")) break;
const cells = splitRow(row);
rows.push({
persona: plainText(cells[persona] ?? ""),
startsAt: cellPaths(cells[startsAt] ?? ""),
next: cellPaths(cells[then] ?? ""),
handsOffTo: cells[handsOff] ?? "",
line: j + 1,
});
}
return { rows, line: i + 1 };
}
return undefined;
}
const SITE_DOCS = "website/src/content/docs";
function hasReaders(root: string): boolean {
return (
existsSync(join(root, SITE_DOCS)) ||
existsSync(join(root, "website", "astro.config.mjs")) ||
existsSync(join(root, "docs", "quickstart.md"))
);
}
/** Where a route's page lives in the repo, or undefined for a link that is a
* hand-off rather than a page (https, mailto). */
export function candidatePaths(ref: string): string[] | undefined {
const bare = ref.replace(/[#?].*$/, "");
if (/^[a-z]+:/i.test(bare)) return undefined;
if (bare.startsWith("/docs/") || bare === "/docs") {
const rel = bare.replace(/^\/docs\/?/, "").replace(/\/$/, "");
const base = rel === "" ? "index" : rel;
return [
`${SITE_DOCS}/${base}.mdx`,
`${SITE_DOCS}/${base}.md`,
`${SITE_DOCS}/${base}/index.mdx`,
`${SITE_DOCS}/${base}/index.md`,
];
}
if (bare.startsWith("docs/")) return [bare];
return [`docs/${bare}`, bare];
}
export function checkJourneys(root: string, personasFile = "docs/personas.md"): JourneyFinding[] {
const file = join(root, personasFile);
if (!existsSync(file)) {
// A repo with no site and no quickstart has no readers to route: a
// backend-only turnkey ships a handover to one customer and is not asked
// for four journeys. Anything with a Start here page is.
if (!hasReaders(root)) return [];
return [
{
file: personasFile,
line: 1,
message: "missing; every product keeps its persona mapping and journey table here (conventions/personas.md).",
},
];
}
const table = parseJourneyTable(readFileSync(file, "utf8"));
if (!table) {
return [
{
file: personasFile,
line: 1,
message:
'no journey table: expected a table with columns Persona | Starts at | Then, in order | Hands off to (documentation plan, "Who we write for").',
},
];
}
const findings: JourneyFinding[] = [];
const seen = new Map();
for (const row of table.rows) {
seen.set(row.persona, (seen.get(row.persona) ?? 0) + 1);
const pages = [...row.startsAt, ...row.next];
if (pages.length > MAX_PAGES) {
findings.push({
file: personasFile,
line: row.line,
message: `${row.persona}: ${pages.length} pages on the route; the limit is ${MAX_PAGES}. A sixth page is in the wrong section or should not exist.`,
});
}
for (const ref of pages) {
const candidates = candidatePaths(ref);
if (!candidates) continue;
if (!candidates.some((c) => existsSync(join(root, c)))) {
findings.push({
file: personasFile,
line: row.line,
message: `${row.persona}: ${ref} is not a page in this repo (looked for ${candidates.join(", ")}).`,
});
}
}
if (!/\S/.test(row.handsOffTo) || cellPaths(row.handsOffTo).length === 0) {
findings.push({
file: personasFile,
line: row.line,
message: `${row.persona}: no hand-off. Every route ends in a link: the next section, or norsk-ctl's manual.`,
});
}
}
for (const persona of PERSONAS) {
const n = seen.get(persona) ?? 0;
if (n === 0)
findings.push({ file: personasFile, line: table.line, message: `${persona} has no row in the journey table.` });
else if (n > 1)
findings.push({
file: personasFile,
line: table.line,
message: `${persona} has ${n} rows in the journey table; one route each.`,
});
}
return findings;
}
if (import.meta.main) {
const argv = process.argv.slice(2);
let root = process.cwd();
let personas = "docs/personas.md";
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--root") root = argv[++i] ?? root;
else if (argv[i] === "--personas") personas = argv[++i] ?? personas;
}
const findings = checkJourneys(root, personas);
if (findings.length === 0) {
console.log("journeys: OK");
} else {
console.error(`journeys: ${findings.length} finding${findings.length === 1 ? "" : "s"}`);
for (const f of findings) console.error(` - ${f.file}:${f.line}: ${f.message}`);
process.exit(1);
}
}