#!/usr/bin/env bun // Recorded guides: the placeholder a docs site builds against when a guide's // capture has not run, and the gate that refuses to publish one. // // A site's recorded guides (the Playwright doc-guide output under a gitignored // `docs/generated/`) reach the site as SYMLINKS under its content dir -- // `website/src/content/docs/guides/ -> ../../../../../docs/generated/` // -- and adding a guide means adding one. The symlinks are the one declaration // of which guides exist: there is no list restated here and no name glob (the // `zero-to-hero-*` glob this once carried skipped a Manager slug, and the site // failed to build for a guide the gate was never even asked about). Each // symlink also names the tree it stubs into, so one module serves every site: // norsk-ctl's, the Manager's, and each product's. // // stub ... write a placeholder index.md for every declared // guide whose target is missing, creating the // tree, never touching a file that exists -- so // `docs:site` builds on a checkout with no captures. // list ... the stub file each declared guide would get // (absolute, each once), writing nothing; a // Makefile takes its prerequisites from this. // check [--allow-stub [,]] [--allow-missing [,]] // [--content ]... ... // exit 1 naming each placeholder it would publish. // A generated dir given directly must be populated // (an unpopulated tree is not a passing one; the // release run's captures were not restored). // --content names a site's content dir instead: // its symlink targets are the guides, and a site // that declares none has nothing to refuse -- a // product site publishes from committed prose and // must stay green with no captures anywhere. // --allow-stub names guides whose stub is a // `::warning::` rather than a refusal (the cloud // guides, captured weekly while the site publishes // nightly); --allow-missing names generated dirs // that may not exist yet. // summary [--content ]... ... // a Markdown table for the job summary: each // guide, live capture or stub, and what it was // captured against (the page's front matter). // // Why the gate: a placeholder is right for a local build and wrong for a // deploy. A publish that runs without the captures restored would REPLACE the // live guides with placeholders, and nothing else would notice -- the build is // green, the links resolve, the pages are just empty. The sentence `stub` // writes is what `check` refuses, and both live here so they cannot drift. import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; /** The sentence a placeholder carries, and the one `check` refuses. */ export const GUIDE_STUB_MARKER = "Content for this guide is recorded by the doc-guide run"; /** Generated directories that are not recorded guides: `cli docgen` writes * cli-reference as help-all.md, so it has no index.md to stub or to be a * stub of. */ export const NOT_A_GUIDE = new Set(["cli-reference"]); /** A guide a site declares: the symlink's name, and the index.md its target * implies. */ export interface DeclaredGuide { name: string; /** Absolute path of the symlink. */ link: string; /** Absolute path of the target's index.md -- which may not exist. */ stub: string; } export interface GuideFile { path: string; /** The guide directory's name -- what `--allow-stub` matches. */ name: string; text: string; } function isGeneratedTarget(target: string): boolean { return target.split("/").includes("generated"); } /** The guides a site declares: every symlink under each content dir that * points into a `generated` tree. A content dir that does not exist yet is * skipped. Resolved as path arithmetic, not realpath: on a fresh checkout the * target's parents do not exist. */ export function declaredGuides(contentDirs: readonly string[]): DeclaredGuide[] { const out: DeclaredGuide[] = []; for (const contentDir of contentDirs) { const dir = resolve(contentDir); if (!existsSync(dir)) continue; const entries = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isSymbolicLink() && !NOT_A_GUIDE.has(e.name)) .map((e) => e.name) .sort(); for (const name of entries) { const link = join(dir, name); const target = readlinkSync(link); if (!isGeneratedTarget(target)) continue; out.push({ name, link, stub: resolve(dirname(link), target, "index.md") }); } } return out; } /** The same stub reached through two sites' symlinks is one file. */ function uniqueByStub(guides: DeclaredGuide[]): DeclaredGuide[] { const seen = new Set(); return guides.filter((g) => !seen.has(g.stub) && seen.add(g.stub)); } export function listGuideStubs(contentDirs: readonly string[]): string[] { return uniqueByStub(declaredGuides(contentDirs)).map((g) => g.stub); } export function stubText(name: string): string { return `---\ntitle: ${name}\n---\n\n${GUIDE_STUB_MARKER}. Run \`bun run docs:guides\` locally to populate it.\n`; } /** Write a placeholder for every declared guide whose index.md is missing. * A file that exists -- a real capture, or an earlier placeholder -- is never * rewritten. */ export function stubGuides(contentDirs: readonly string[]): { written: string[]; existing: string[] } { const written: string[] = []; const existing: string[] = []; for (const guide of uniqueByStub(declaredGuides(contentDirs))) { if (existsSync(guide.stub)) { existing.push(guide.stub); continue; } mkdirSync(dirname(guide.stub), { recursive: true }); writeFileSync(guide.stub, stubText(guide.name)); written.push(guide.stub); } return { written, existing }; } export function stubbedGuides(files: readonly { path: string; text: string }[]): string[] { if (files.length === 0) throw new Error("no guides found -- docs/generated was never populated"); return files.filter((f) => f.text.includes(GUIDE_STUB_MARKER)).map((f) => f.path); } /** What a rendered page says it was captured against (the doc-guide renderer * writes the block; the values are quoted strings). */ export function capturedAgainst(text: string): { norsk?: string; norskMgr?: string; when?: string } { const head = text.split(/^---$/m)[1] ?? ""; const block = /^capturedAgainst:\n((?:[ ]{2}\w+: "[^"\n]*"\n)+)/m.exec(head)?.[1] ?? ""; const out: Record = {}; for (const m of block.matchAll(/^[ ]{2}(\w+): "([^"\n]*)"$/gm)) out[m[1]] = m[2]; return out; } /** The job-summary table: one row per guide. */ export function summaryTable(files: readonly GuideFile[]): string { const rows = files.map((f) => { if (f.text.includes(GUIDE_STUB_MARKER)) return `| \`${f.path}\` | **stub** | — |`; const a = capturedAgainst(f.text); const against = [a.norskMgr && `norsk-mgr ${a.norskMgr}`, a.norsk && `Norsk Manager ${a.norsk}`] .filter(Boolean) .join(", "); return `| \`${f.path}\` | live capture | ${a.when ? `${a.when}${against ? ` — ${against}` : ""}` : "(no capturedAgainst)"} |`; }); return ["| guide | content | captured |", "| --- | --- | --- |", ...rows].join("\n"); } /** A guide directory without its index.md is as unpublishable as a stub. */ function readGuide(path: string, name: string, file: string): GuideFile { try { return { path, name, text: readFileSync(file, "utf8") }; } catch { return { path, name, text: GUIDE_STUB_MARKER }; } } export class GuideTreeMissing extends Error {} export interface ReadGuidesOptions { /** Generated dirs that may not exist yet: a warning, not a refusal. */ allowMissing?: ReadonlySet; warn?: (message: string) => void; } /** Every guide under each generated dir. A dir that does not exist is a * restore failure (GuideTreeMissing) unless it is allowed missing: for a * section whose captures come from a run that may not have happened yet, * refusing would take the whole site down with it. */ export function readGuides(dirs: readonly string[], opts: ReadGuidesOptions = {}): GuideFile[] { const allowMissing = opts.allowMissing ?? new Set(); const warn = opts.warn ?? ((m) => console.error(m)); const files: GuideFile[] = []; for (const dir of dirs) { let names: string[] = []; try { names = readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !NOT_A_GUIDE.has(e.name)) .map((e) => e.name) .sort(); } catch { if (allowMissing.has(resolve(dir))) { warn(`::warning::guide check: ${dir} has never been captured -- its pages will be stubs`); continue; } throw new GuideTreeMissing(`${dir} does not exist -- the release run's captures were not restored`); } // Paths are reported under the package's name so two roots' guides cannot // be confused (norsk-ctl's and norsk-mgr's roots are both called // docs/generated; the package dir two levels up tells them apart). const label = dirs.length > 1 ? `${basename(resolve(dir, "../.."))}/${basename(dir)}/` : ""; for (const name of names) files.push(readGuide(`${label}${name}/index.md`, name, join(dir, name, "index.md"))); } return files; } /** The guides a site declares, read from their symlink targets. Reported * relative to the working directory, which is where the workflow runs. */ export function readDeclaredGuides(contentDirs: readonly string[], cwd = process.cwd()): GuideFile[] { return uniqueByStub(declaredGuides(contentDirs)).map((g) => readGuide(relative(cwd, g.stub), g.name, g.stub)); } function usage(): never { console.error( [ "usage: guide-stubs.ts stub ...", " guide-stubs.ts list ...", " guide-stubs.ts check [--allow-stub [,]] [--allow-missing [,]] [--content ]... ...", " guide-stubs.ts summary [--content ]... ...", ].join("\n"), ); process.exit(2); } if (import.meta.main) { const [cmd, ...rest] = process.argv.slice(2); const allowStub = new Set(); const allowMissing = new Set(); const contentDirs: string[] = []; const dirs: string[] = []; for (let i = 0; i < rest.length; i++) { const arg = rest[i] as string; if (arg === "--allow-stub") { for (const n of (rest[++i] ?? "").split(",")) if (n) allowStub.add(n); } else if (arg === "--allow-missing") { for (const d of (rest[++i] ?? "").split(",")) if (d) allowMissing.add(resolve(d)); } else if (arg === "--content") { contentDirs.push(rest[++i] ?? usage()); } else if (arg.startsWith("-")) usage(); else dirs.push(arg); } if (cmd === "stub" || cmd === "list") { if (contentDirs.length > 0 || dirs.length === 0) usage(); if (cmd === "list") { for (const f of listGuideStubs(dirs)) console.log(f); } else { for (const f of stubGuides(dirs).written) console.log(`stubbed ${f}`); } process.exit(0); } if ((cmd !== "check" && cmd !== "summary") || (dirs.length === 0 && contentDirs.length === 0)) usage(); let files: GuideFile[]; try { files = [...readGuides(dirs, { allowMissing }), ...readDeclaredGuides(contentDirs)]; } catch (e) { if (!(e instanceof GuideTreeMissing)) throw e; console.error(`::error::guide check: ${e.message}`); process.exit(1); } if (cmd === "summary") { console.log(summaryTable(files)); process.exit(0); } if (files.length === 0 && dirs.length === 0) { console.log(`guide check: no recorded guides are declared by ${contentDirs.join(", ")}; nothing to refuse.`); process.exit(0); } let stubs: string[]; try { stubs = stubbedGuides(files); } catch (e) { console.error(`::error::guide check: ${(e as Error).message}`); process.exit(1); } // A tolerated stub is a warning; a tree of nothing but tolerated stubs is // still an unpopulated tree. const tolerated = files.filter((f) => stubs.includes(f.path) && allowStub.has(f.name)).map((f) => f.path); const refused = stubs.filter((s) => !tolerated.includes(s)); for (const s of tolerated) { console.error( `::warning::guide check: ${s} is a placeholder -- publishing without it (no capture yet, or none saved)`, ); } if (refused.length > 0) { console.error(`::error::guide check: ${refused.length} guide(s) are placeholders, refusing to publish:`); for (const s of refused) console.error(` ${s}`); process.exit(1); } if (tolerated.length === files.length) { console.error("::error::guide check: every guide is a placeholder -- docs/generated was never populated"); process.exit(1); } console.log(`guide check: ${files.length} guide(s), ${tolerated.length} tolerated stub(s), the rest real.`); }