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