@norskvideo/ctl-dev-kit 0.2.54 → 0.2.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conventions/bot-prs.yml +57 -5
- package/conventions/check-drift.ts +56 -1
- package/conventions/docs-site.md +96 -17
- package/conventions/docs.md +52 -5
- package/conventions/personas.md +25 -0
- package/conventions/publish-docs-site.yml +247 -0
- package/conventions/sync-drift.ts +53 -2
- package/create-product/docs-site.ts +9 -2
- package/create-product/turnkey.ts +121 -14
- package/doc-guide/doc-guide.d.ts +104 -9
- package/doc-guide/doc-guide.js +210 -25
- package/doc-guide/guides-config.d.ts +6 -0
- package/doc-guide/guides-config.js +4 -0
- package/doc-guide/live-browser.d.ts +2 -0
- package/doc-guide/live-browser.js +5 -1
- package/docs/guide-stubs.ts +304 -0
- package/docs/journeys.ts +217 -0
- package/docs/light-pages.ts +205 -0
- package/docs-theme/components/InstallProduct.astro +51 -0
- package/package.json +2 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Recorded guides: the placeholder a docs site builds against when a guide's
|
|
3
|
+
// capture has not run, and the gate that refuses to publish one.
|
|
4
|
+
//
|
|
5
|
+
// A site's recorded guides (the Playwright doc-guide output under a gitignored
|
|
6
|
+
// `docs/generated/`) reach the site as SYMLINKS under its content dir --
|
|
7
|
+
// `website/src/content/docs/guides/<slug> -> ../../../../../docs/generated/<slug>`
|
|
8
|
+
// -- and adding a guide means adding one. The symlinks are the one declaration
|
|
9
|
+
// of which guides exist: there is no list restated here and no name glob (the
|
|
10
|
+
// `zero-to-hero-*` glob this once carried skipped a Manager slug, and the site
|
|
11
|
+
// failed to build for a guide the gate was never even asked about). Each
|
|
12
|
+
// symlink also names the tree it stubs into, so one module serves every site:
|
|
13
|
+
// norsk-ctl's, the Manager's, and each product's.
|
|
14
|
+
//
|
|
15
|
+
// stub <content-dir>... write a placeholder index.md for every declared
|
|
16
|
+
// guide whose target is missing, creating the
|
|
17
|
+
// tree, never touching a file that exists -- so
|
|
18
|
+
// `docs:site` builds on a checkout with no captures.
|
|
19
|
+
// list <content-dir>... the stub file each declared guide would get
|
|
20
|
+
// (absolute, each once), writing nothing; a
|
|
21
|
+
// Makefile takes its prerequisites from this.
|
|
22
|
+
// check [--allow-stub <name>[,<name>]] [--allow-missing <dir>[,<dir>]]
|
|
23
|
+
// [--content <content-dir>]... <generated-dir>...
|
|
24
|
+
// exit 1 naming each placeholder it would publish.
|
|
25
|
+
// A generated dir given directly must be populated
|
|
26
|
+
// (an unpopulated tree is not a passing one; the
|
|
27
|
+
// release run's captures were not restored).
|
|
28
|
+
// --content names a site's content dir instead:
|
|
29
|
+
// its symlink targets are the guides, and a site
|
|
30
|
+
// that declares none has nothing to refuse -- a
|
|
31
|
+
// product site publishes from committed prose and
|
|
32
|
+
// must stay green with no captures anywhere.
|
|
33
|
+
// --allow-stub names guides whose stub is a
|
|
34
|
+
// `::warning::` rather than a refusal (the cloud
|
|
35
|
+
// guides, captured weekly while the site publishes
|
|
36
|
+
// nightly); --allow-missing names generated dirs
|
|
37
|
+
// that may not exist yet.
|
|
38
|
+
// summary [--content <dir>]... <generated-dir>...
|
|
39
|
+
// a Markdown table for the job summary: each
|
|
40
|
+
// guide, live capture or stub, and what it was
|
|
41
|
+
// captured against (the page's front matter).
|
|
42
|
+
//
|
|
43
|
+
// Why the gate: a placeholder is right for a local build and wrong for a
|
|
44
|
+
// deploy. A publish that runs without the captures restored would REPLACE the
|
|
45
|
+
// live guides with placeholders, and nothing else would notice -- the build is
|
|
46
|
+
// green, the links resolve, the pages are just empty. The sentence `stub`
|
|
47
|
+
// writes is what `check` refuses, and both live here so they cannot drift.
|
|
48
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
50
|
+
|
|
51
|
+
/** The sentence a placeholder carries, and the one `check` refuses. */
|
|
52
|
+
export const GUIDE_STUB_MARKER = "Content for this guide is recorded by the doc-guide run";
|
|
53
|
+
|
|
54
|
+
/** Generated directories that are not recorded guides: `cli docgen` writes
|
|
55
|
+
* cli-reference as help-all.md, so it has no index.md to stub or to be a
|
|
56
|
+
* stub of. */
|
|
57
|
+
export const NOT_A_GUIDE = new Set(["cli-reference"]);
|
|
58
|
+
|
|
59
|
+
/** A guide a site declares: the symlink's name, and the index.md its target
|
|
60
|
+
* implies. */
|
|
61
|
+
export interface DeclaredGuide {
|
|
62
|
+
name: string;
|
|
63
|
+
/** Absolute path of the symlink. */
|
|
64
|
+
link: string;
|
|
65
|
+
/** Absolute path of the target's index.md -- which may not exist. */
|
|
66
|
+
stub: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GuideFile {
|
|
70
|
+
path: string;
|
|
71
|
+
/** The guide directory's name -- what `--allow-stub` matches. */
|
|
72
|
+
name: string;
|
|
73
|
+
text: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isGeneratedTarget(target: string): boolean {
|
|
77
|
+
return target.split("/").includes("generated");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The guides a site declares: every symlink under each content dir that
|
|
81
|
+
* points into a `generated` tree. A content dir that does not exist yet is
|
|
82
|
+
* skipped. Resolved as path arithmetic, not realpath: on a fresh checkout the
|
|
83
|
+
* target's parents do not exist. */
|
|
84
|
+
export function declaredGuides(contentDirs: readonly string[]): DeclaredGuide[] {
|
|
85
|
+
const out: DeclaredGuide[] = [];
|
|
86
|
+
for (const contentDir of contentDirs) {
|
|
87
|
+
const dir = resolve(contentDir);
|
|
88
|
+
if (!existsSync(dir)) continue;
|
|
89
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
90
|
+
.filter((e) => e.isSymbolicLink() && !NOT_A_GUIDE.has(e.name))
|
|
91
|
+
.map((e) => e.name)
|
|
92
|
+
.sort();
|
|
93
|
+
for (const name of entries) {
|
|
94
|
+
const link = join(dir, name);
|
|
95
|
+
const target = readlinkSync(link);
|
|
96
|
+
if (!isGeneratedTarget(target)) continue;
|
|
97
|
+
out.push({ name, link, stub: resolve(dirname(link), target, "index.md") });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The same stub reached through two sites' symlinks is one file. */
|
|
104
|
+
function uniqueByStub(guides: DeclaredGuide[]): DeclaredGuide[] {
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
return guides.filter((g) => !seen.has(g.stub) && seen.add(g.stub));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function listGuideStubs(contentDirs: readonly string[]): string[] {
|
|
110
|
+
return uniqueByStub(declaredGuides(contentDirs)).map((g) => g.stub);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function stubText(name: string): string {
|
|
114
|
+
return `---\ntitle: ${name}\n---\n\n${GUIDE_STUB_MARKER}. Run \`bun run docs:guides\` locally to populate it.\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Write a placeholder for every declared guide whose index.md is missing.
|
|
118
|
+
* A file that exists -- a real capture, or an earlier placeholder -- is never
|
|
119
|
+
* rewritten. */
|
|
120
|
+
export function stubGuides(contentDirs: readonly string[]): { written: string[]; existing: string[] } {
|
|
121
|
+
const written: string[] = [];
|
|
122
|
+
const existing: string[] = [];
|
|
123
|
+
for (const guide of uniqueByStub(declaredGuides(contentDirs))) {
|
|
124
|
+
if (existsSync(guide.stub)) {
|
|
125
|
+
existing.push(guide.stub);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
mkdirSync(dirname(guide.stub), { recursive: true });
|
|
129
|
+
writeFileSync(guide.stub, stubText(guide.name));
|
|
130
|
+
written.push(guide.stub);
|
|
131
|
+
}
|
|
132
|
+
return { written, existing };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function stubbedGuides(files: readonly { path: string; text: string }[]): string[] {
|
|
136
|
+
if (files.length === 0) throw new Error("no guides found -- docs/generated was never populated");
|
|
137
|
+
return files.filter((f) => f.text.includes(GUIDE_STUB_MARKER)).map((f) => f.path);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a rendered page says it was captured against (the doc-guide renderer
|
|
141
|
+
* writes the block; the values are quoted strings). */
|
|
142
|
+
export function capturedAgainst(text: string): { norsk?: string; norskMgr?: string; when?: string } {
|
|
143
|
+
const head = text.split(/^---$/m)[1] ?? "";
|
|
144
|
+
const block = /^capturedAgainst:\n((?:[ ]{2}\w+: "[^"\n]*"\n)+)/m.exec(head)?.[1] ?? "";
|
|
145
|
+
const out: Record<string, string> = {};
|
|
146
|
+
for (const m of block.matchAll(/^[ ]{2}(\w+): "([^"\n]*)"$/gm)) out[m[1]] = m[2];
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The job-summary table: one row per guide. */
|
|
151
|
+
export function summaryTable(files: readonly GuideFile[]): string {
|
|
152
|
+
const rows = files.map((f) => {
|
|
153
|
+
if (f.text.includes(GUIDE_STUB_MARKER)) return `| \`${f.path}\` | **stub** | — |`;
|
|
154
|
+
const a = capturedAgainst(f.text);
|
|
155
|
+
const against = [a.norskMgr && `norsk-mgr ${a.norskMgr}`, a.norsk && `Norsk Manager ${a.norsk}`]
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join(", ");
|
|
158
|
+
return `| \`${f.path}\` | live capture | ${a.when ? `${a.when}${against ? ` — ${against}` : ""}` : "(no capturedAgainst)"} |`;
|
|
159
|
+
});
|
|
160
|
+
return ["| guide | content | captured |", "| --- | --- | --- |", ...rows].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A guide directory without its index.md is as unpublishable as a stub. */
|
|
164
|
+
function readGuide(path: string, name: string, file: string): GuideFile {
|
|
165
|
+
try {
|
|
166
|
+
return { path, name, text: readFileSync(file, "utf8") };
|
|
167
|
+
} catch {
|
|
168
|
+
return { path, name, text: GUIDE_STUB_MARKER };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export class GuideTreeMissing extends Error {}
|
|
173
|
+
|
|
174
|
+
export interface ReadGuidesOptions {
|
|
175
|
+
/** Generated dirs that may not exist yet: a warning, not a refusal. */
|
|
176
|
+
allowMissing?: ReadonlySet<string>;
|
|
177
|
+
warn?: (message: string) => void;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every guide under each generated dir. A dir that does not exist is a
|
|
181
|
+
* restore failure (GuideTreeMissing) unless it is allowed missing: for a
|
|
182
|
+
* section whose captures come from a run that may not have happened yet,
|
|
183
|
+
* refusing would take the whole site down with it. */
|
|
184
|
+
export function readGuides(dirs: readonly string[], opts: ReadGuidesOptions = {}): GuideFile[] {
|
|
185
|
+
const allowMissing = opts.allowMissing ?? new Set();
|
|
186
|
+
const warn = opts.warn ?? ((m) => console.error(m));
|
|
187
|
+
const files: GuideFile[] = [];
|
|
188
|
+
for (const dir of dirs) {
|
|
189
|
+
let names: string[] = [];
|
|
190
|
+
try {
|
|
191
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
192
|
+
.filter((e) => e.isDirectory() && !NOT_A_GUIDE.has(e.name))
|
|
193
|
+
.map((e) => e.name)
|
|
194
|
+
.sort();
|
|
195
|
+
} catch {
|
|
196
|
+
if (allowMissing.has(resolve(dir))) {
|
|
197
|
+
warn(`::warning::guide check: ${dir} has never been captured -- its pages will be stubs`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
throw new GuideTreeMissing(`${dir} does not exist -- the release run's captures were not restored`);
|
|
201
|
+
}
|
|
202
|
+
// Paths are reported under the package's name so two roots' guides cannot
|
|
203
|
+
// be confused (norsk-ctl's and norsk-mgr's roots are both called
|
|
204
|
+
// docs/generated; the package dir two levels up tells them apart).
|
|
205
|
+
const label = dirs.length > 1 ? `${basename(resolve(dir, "../.."))}/${basename(dir)}/` : "";
|
|
206
|
+
for (const name of names) files.push(readGuide(`${label}${name}/index.md`, name, join(dir, name, "index.md")));
|
|
207
|
+
}
|
|
208
|
+
return files;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The guides a site declares, read from their symlink targets. Reported
|
|
212
|
+
* relative to the working directory, which is where the workflow runs. */
|
|
213
|
+
export function readDeclaredGuides(contentDirs: readonly string[], cwd = process.cwd()): GuideFile[] {
|
|
214
|
+
return uniqueByStub(declaredGuides(contentDirs)).map((g) => readGuide(relative(cwd, g.stub), g.name, g.stub));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function usage(): never {
|
|
218
|
+
console.error(
|
|
219
|
+
[
|
|
220
|
+
"usage: guide-stubs.ts stub <content-dir>...",
|
|
221
|
+
" guide-stubs.ts list <content-dir>...",
|
|
222
|
+
" guide-stubs.ts check [--allow-stub <name>[,<name>]] [--allow-missing <dir>[,<dir>]] [--content <dir>]... <generated-dir>...",
|
|
223
|
+
" guide-stubs.ts summary [--content <dir>]... <generated-dir>...",
|
|
224
|
+
].join("\n"),
|
|
225
|
+
);
|
|
226
|
+
process.exit(2);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (import.meta.main) {
|
|
230
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
231
|
+
const allowStub = new Set<string>();
|
|
232
|
+
const allowMissing = new Set<string>();
|
|
233
|
+
const contentDirs: string[] = [];
|
|
234
|
+
const dirs: string[] = [];
|
|
235
|
+
for (let i = 0; i < rest.length; i++) {
|
|
236
|
+
const arg = rest[i] as string;
|
|
237
|
+
if (arg === "--allow-stub") {
|
|
238
|
+
for (const n of (rest[++i] ?? "").split(",")) if (n) allowStub.add(n);
|
|
239
|
+
} else if (arg === "--allow-missing") {
|
|
240
|
+
for (const d of (rest[++i] ?? "").split(",")) if (d) allowMissing.add(resolve(d));
|
|
241
|
+
} else if (arg === "--content") {
|
|
242
|
+
contentDirs.push(rest[++i] ?? usage());
|
|
243
|
+
} else if (arg.startsWith("-")) usage();
|
|
244
|
+
else dirs.push(arg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (cmd === "stub" || cmd === "list") {
|
|
248
|
+
if (contentDirs.length > 0 || dirs.length === 0) usage();
|
|
249
|
+
if (cmd === "list") {
|
|
250
|
+
for (const f of listGuideStubs(dirs)) console.log(f);
|
|
251
|
+
} else {
|
|
252
|
+
for (const f of stubGuides(dirs).written) console.log(`stubbed ${f}`);
|
|
253
|
+
}
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
if ((cmd !== "check" && cmd !== "summary") || (dirs.length === 0 && contentDirs.length === 0)) usage();
|
|
257
|
+
|
|
258
|
+
let files: GuideFile[];
|
|
259
|
+
try {
|
|
260
|
+
files = [...readGuides(dirs, { allowMissing }), ...readDeclaredGuides(contentDirs)];
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (!(e instanceof GuideTreeMissing)) throw e;
|
|
263
|
+
console.error(`::error::guide check: ${e.message}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (cmd === "summary") {
|
|
268
|
+
console.log(summaryTable(files));
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (files.length === 0 && dirs.length === 0) {
|
|
273
|
+
console.log(`guide check: no recorded guides are declared by ${contentDirs.join(", ")}; nothing to refuse.`);
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let stubs: string[];
|
|
278
|
+
try {
|
|
279
|
+
stubs = stubbedGuides(files);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`::error::guide check: ${(e as Error).message}`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A tolerated stub is a warning; a tree of nothing but tolerated stubs is
|
|
286
|
+
// still an unpopulated tree.
|
|
287
|
+
const tolerated = files.filter((f) => stubs.includes(f.path) && allowStub.has(f.name)).map((f) => f.path);
|
|
288
|
+
const refused = stubs.filter((s) => !tolerated.includes(s));
|
|
289
|
+
for (const s of tolerated) {
|
|
290
|
+
console.error(
|
|
291
|
+
`::warning::guide check: ${s} is a placeholder -- publishing without it (no capture yet, or none saved)`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (refused.length > 0) {
|
|
295
|
+
console.error(`::error::guide check: ${refused.length} guide(s) are placeholders, refusing to publish:`);
|
|
296
|
+
for (const s of refused) console.error(` ${s}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
if (tolerated.length === files.length) {
|
|
300
|
+
console.error("::error::guide check: every guide is a placeholder -- docs/generated was never populated");
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
console.log(`guide check: ${files.length} guide(s), ${tolerated.length} tolerated stub(s), the rest real.`);
|
|
304
|
+
}
|
package/docs/journeys.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The journey-table lint (documentation plan, October 2026, "Who we write for").
|
|
3
|
+
// Each product's docs/personas.md carries one table saying where each of the
|
|
4
|
+
// four readers starts, which pages they read in order, and where they are
|
|
5
|
+
// handed off — ctl's manual for the Administrator and the Integrator. The
|
|
6
|
+
// table is the product's statement of its journeys; this checks that it exists,
|
|
7
|
+
// covers every persona once, keeps each route to five pages, names only pages
|
|
8
|
+
// that exist, and always ends in a hand-off. A site path `/docs/x/` resolves to
|
|
9
|
+
// website/src/content/docs/x.{md,mdx} or x/index.{md,mdx}; a bare `name.md`
|
|
10
|
+
// resolves under docs/; an https link is a hand-off and is not resolved.
|
|
11
|
+
//
|
|
12
|
+
// Usage: bun journeys.ts [--root <dir>] [--personas docs/personas.md]
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export const PERSONAS = ["Evaluator", "Builder", "Integrator", "Administrator"] as const;
|
|
17
|
+
export const MAX_PAGES = 5;
|
|
18
|
+
|
|
19
|
+
export interface JourneyRow {
|
|
20
|
+
persona: string;
|
|
21
|
+
startsAt: string[];
|
|
22
|
+
next: string[];
|
|
23
|
+
handsOffTo: string;
|
|
24
|
+
/** 1-based line of the row in the personas file. */
|
|
25
|
+
line: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface JourneyTable {
|
|
29
|
+
rows: JourneyRow[];
|
|
30
|
+
/** 1-based line of the header row. */
|
|
31
|
+
line: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface JourneyFinding {
|
|
35
|
+
file: string;
|
|
36
|
+
line: number;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CELL_PATH = /`([^`]+)`|\]\(([^)]+)\)|(^|\s)(\/docs\/\S*|\S+\.mdx?)(?=\s|,|$)/g;
|
|
41
|
+
|
|
42
|
+
/** Every path-like reference in a table cell: backticked, a markdown link
|
|
43
|
+
* target, or a bare `/docs/...` or `*.md` token. */
|
|
44
|
+
export function cellPaths(cell: string): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const m of cell.matchAll(CELL_PATH)) {
|
|
47
|
+
const p = (m[1] ?? m[2] ?? m[4] ?? "").trim();
|
|
48
|
+
if (p) out.push(p);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A persona cell may be bold or a link to its section; the name is the text. */
|
|
54
|
+
function plainText(cell: string): string {
|
|
55
|
+
return cell
|
|
56
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
|
57
|
+
.replace(/[*_`]/g, "")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function splitRow(line: string): string[] {
|
|
62
|
+
return line
|
|
63
|
+
.trim()
|
|
64
|
+
.replace(/^\|/, "")
|
|
65
|
+
.replace(/\|$/, "")
|
|
66
|
+
.split("|")
|
|
67
|
+
.map((c) => c.trim());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseJourneyTable(markdown: string): JourneyTable | undefined {
|
|
71
|
+
const lines = markdown.split("\n");
|
|
72
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
73
|
+
const line = lines[i] ?? "";
|
|
74
|
+
if (!line.trim().startsWith("|")) continue;
|
|
75
|
+
const header = splitRow(line).map((c) => c.toLowerCase());
|
|
76
|
+
const col = (want: string) => header.findIndex((h) => h.startsWith(want));
|
|
77
|
+
const persona = col("persona");
|
|
78
|
+
const startsAt = col("starts at");
|
|
79
|
+
const then = col("then");
|
|
80
|
+
const handsOff = col("hands off");
|
|
81
|
+
if (persona < 0 || startsAt < 0 || then < 0 || handsOff < 0) continue;
|
|
82
|
+
if (!/^\s*\|?\s*:?-+/.test(lines[i + 1] ?? "")) continue;
|
|
83
|
+
const rows: JourneyRow[] = [];
|
|
84
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
85
|
+
const row = lines[j] ?? "";
|
|
86
|
+
if (!row.trim().startsWith("|")) break;
|
|
87
|
+
const cells = splitRow(row);
|
|
88
|
+
rows.push({
|
|
89
|
+
persona: plainText(cells[persona] ?? ""),
|
|
90
|
+
startsAt: cellPaths(cells[startsAt] ?? ""),
|
|
91
|
+
next: cellPaths(cells[then] ?? ""),
|
|
92
|
+
handsOffTo: cells[handsOff] ?? "",
|
|
93
|
+
line: j + 1,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { rows, line: i + 1 };
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const SITE_DOCS = "website/src/content/docs";
|
|
102
|
+
|
|
103
|
+
function hasReaders(root: string): boolean {
|
|
104
|
+
return (
|
|
105
|
+
existsSync(join(root, SITE_DOCS)) ||
|
|
106
|
+
existsSync(join(root, "website", "astro.config.mjs")) ||
|
|
107
|
+
existsSync(join(root, "docs", "quickstart.md"))
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Where a route's page lives in the repo, or undefined for a link that is a
|
|
112
|
+
* hand-off rather than a page (https, mailto). */
|
|
113
|
+
export function candidatePaths(ref: string): string[] | undefined {
|
|
114
|
+
const bare = ref.replace(/[#?].*$/, "");
|
|
115
|
+
if (/^[a-z]+:/i.test(bare)) return undefined;
|
|
116
|
+
if (bare.startsWith("/docs/") || bare === "/docs") {
|
|
117
|
+
const rel = bare.replace(/^\/docs\/?/, "").replace(/\/$/, "");
|
|
118
|
+
const base = rel === "" ? "index" : rel;
|
|
119
|
+
return [
|
|
120
|
+
`${SITE_DOCS}/${base}.mdx`,
|
|
121
|
+
`${SITE_DOCS}/${base}.md`,
|
|
122
|
+
`${SITE_DOCS}/${base}/index.mdx`,
|
|
123
|
+
`${SITE_DOCS}/${base}/index.md`,
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
if (bare.startsWith("docs/")) return [bare];
|
|
127
|
+
return [`docs/${bare}`, bare];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function checkJourneys(root: string, personasFile = "docs/personas.md"): JourneyFinding[] {
|
|
131
|
+
const file = join(root, personasFile);
|
|
132
|
+
if (!existsSync(file)) {
|
|
133
|
+
// A repo with no site and no quickstart has no readers to route: a
|
|
134
|
+
// backend-only turnkey ships a handover to one customer and is not asked
|
|
135
|
+
// for four journeys. Anything with a Start here page is.
|
|
136
|
+
if (!hasReaders(root)) return [];
|
|
137
|
+
return [
|
|
138
|
+
{
|
|
139
|
+
file: personasFile,
|
|
140
|
+
line: 1,
|
|
141
|
+
message: "missing; every product keeps its persona mapping and journey table here (conventions/personas.md).",
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
const table = parseJourneyTable(readFileSync(file, "utf8"));
|
|
146
|
+
if (!table) {
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
file: personasFile,
|
|
150
|
+
line: 1,
|
|
151
|
+
message:
|
|
152
|
+
'no journey table: expected a table with columns Persona | Starts at | Then, in order | Hands off to (documentation plan, "Who we write for").',
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
const findings: JourneyFinding[] = [];
|
|
157
|
+
const seen = new Map<string, number>();
|
|
158
|
+
for (const row of table.rows) {
|
|
159
|
+
seen.set(row.persona, (seen.get(row.persona) ?? 0) + 1);
|
|
160
|
+
const pages = [...row.startsAt, ...row.next];
|
|
161
|
+
if (pages.length > MAX_PAGES) {
|
|
162
|
+
findings.push({
|
|
163
|
+
file: personasFile,
|
|
164
|
+
line: row.line,
|
|
165
|
+
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.`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const ref of pages) {
|
|
169
|
+
const candidates = candidatePaths(ref);
|
|
170
|
+
if (!candidates) continue;
|
|
171
|
+
if (!candidates.some((c) => existsSync(join(root, c)))) {
|
|
172
|
+
findings.push({
|
|
173
|
+
file: personasFile,
|
|
174
|
+
line: row.line,
|
|
175
|
+
message: `${row.persona}: ${ref} is not a page in this repo (looked for ${candidates.join(", ")}).`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!/\S/.test(row.handsOffTo) || cellPaths(row.handsOffTo).length === 0) {
|
|
180
|
+
findings.push({
|
|
181
|
+
file: personasFile,
|
|
182
|
+
line: row.line,
|
|
183
|
+
message: `${row.persona}: no hand-off. Every route ends in a link: the next section, or norsk-ctl's manual.`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const persona of PERSONAS) {
|
|
188
|
+
const n = seen.get(persona) ?? 0;
|
|
189
|
+
if (n === 0)
|
|
190
|
+
findings.push({ file: personasFile, line: table.line, message: `${persona} has no row in the journey table.` });
|
|
191
|
+
else if (n > 1)
|
|
192
|
+
findings.push({
|
|
193
|
+
file: personasFile,
|
|
194
|
+
line: table.line,
|
|
195
|
+
message: `${persona} has ${n} rows in the journey table; one route each.`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return findings;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (import.meta.main) {
|
|
202
|
+
const argv = process.argv.slice(2);
|
|
203
|
+
let root = process.cwd();
|
|
204
|
+
let personas = "docs/personas.md";
|
|
205
|
+
for (let i = 0; i < argv.length; i++) {
|
|
206
|
+
if (argv[i] === "--root") root = argv[++i] ?? root;
|
|
207
|
+
else if (argv[i] === "--personas") personas = argv[++i] ?? personas;
|
|
208
|
+
}
|
|
209
|
+
const findings = checkJourneys(root, personas);
|
|
210
|
+
if (findings.length === 0) {
|
|
211
|
+
console.log("journeys: OK");
|
|
212
|
+
} else {
|
|
213
|
+
console.error(`journeys: ${findings.length} finding${findings.length === 1 ? "" : "s"}`);
|
|
214
|
+
for (const f of findings) console.error(` - ${f.file}:${f.line}: ${f.message}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
}
|