@norskvideo/ctl-dev-kit 0.2.54 → 0.2.55

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.
@@ -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
+ }
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env bun
2
+ // The "light pages" lint (documentation plan, October 2026, "Keeping it light").
3
+ // A product's Start here and Guides pages are written for the Evaluator and the
4
+ // Builder, who do not know our engineering vocabulary and should not need to;
5
+ // the rule in conventions/docs.md ("Voice") bans those words but had no scope
6
+ // and no gate, so it was broken by the very indexes that cite it. This gives it
7
+ // a scope — the light half of a site, and a repo's docs/quickstart.md — and a
8
+ // gate: a word from the deny list in PROSE is a finding, and so is a page over
9
+ // its word budget. Code (fenced and inline), HTML comments and MDX import lines
10
+ // are not prose, because `norsk-ctl instance launch-template` is a command the
11
+ // reader types, not a word they have to understand.
12
+ //
13
+ // Usage:
14
+ // bun light-pages.ts [--root <dir>] [--allow w,w] [--deny w,w] [--max-words N] [file...]
15
+ //
16
+ // With no files it lints defaultLightPages(root). `--allow` is how a repo keeps
17
+ // a word that is its own vocabulary. `instance` is deliberately not on the list:
18
+ // it is the label on norsk-ctl's own screens, so every product's reader sees it.
19
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { join, posix, relative } from "node:path";
21
+
22
+ export interface LightFinding {
23
+ file: string;
24
+ line: number;
25
+ message: string;
26
+ }
27
+
28
+ export interface LightPagesOptions {
29
+ /** Words that must not appear in prose; defaults to DEFAULT_DENY. */
30
+ deny?: string[];
31
+ /** Words removed from the deny list for this repo. */
32
+ allow?: string[];
33
+ /** Prose word budget per page; unset means no budget. */
34
+ maxWords?: number;
35
+ }
36
+
37
+ /** The engineering vocabulary conventions/docs.md bans from reader-facing copy,
38
+ * plus the words the October 2026 review found leaking onto quickstarts. */
39
+ export const DEFAULT_DENY = [
40
+ "daemon",
41
+ "engine",
42
+ "fixture",
43
+ "fixtures",
44
+ "manifest",
45
+ "manifests",
46
+ "compose",
47
+ "harness",
48
+ "playwright",
49
+ "assertion",
50
+ "assertions",
51
+ "slug",
52
+ "slugs",
53
+ "ssr",
54
+ "zod",
55
+ "websocket",
56
+ "websockets",
57
+ "iframe",
58
+ "oauth2",
59
+ ];
60
+
61
+ const FENCE = /^\s*(```|~~~)/;
62
+ const INLINE_CODE = /`[^`\n]*`/g;
63
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
64
+ const MDX_COMMENT = /\{\/\*[\s\S]*?\*\/\}/g;
65
+ const IMPORT_LINE = /^\s*import\s.*$/;
66
+
67
+ /** The page with everything that is not prose blanked out, line structure kept
68
+ * so findings can name a line. Frontmatter is prose: its description renders. */
69
+ function proseLines(content: string): string[] {
70
+ const withoutComments = content
71
+ .replace(HTML_COMMENT, (m) => m.replace(/[^\n]/g, " "))
72
+ .replace(MDX_COMMENT, (m) => m.replace(/[^\n]/g, " "));
73
+ const out: string[] = [];
74
+ let inFence = false;
75
+ let inFrontmatter = false;
76
+ const lines = withoutComments.split("\n");
77
+ for (let i = 0; i < lines.length; i++) {
78
+ const line = lines[i] ?? "";
79
+ if (i === 0 && line.trim() === "---") {
80
+ inFrontmatter = true;
81
+ out.push("");
82
+ continue;
83
+ }
84
+ if (inFrontmatter) {
85
+ if (line.trim() === "---") inFrontmatter = false;
86
+ out.push("");
87
+ continue;
88
+ }
89
+ if (FENCE.test(line)) {
90
+ inFence = !inFence;
91
+ out.push("");
92
+ continue;
93
+ }
94
+ if (inFence || IMPORT_LINE.test(line)) {
95
+ out.push("");
96
+ continue;
97
+ }
98
+ out.push(line.replace(INLINE_CODE, " "));
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function countWords(lines: string[]): number {
104
+ let n = 0;
105
+ for (const line of lines) {
106
+ const stripped = line.replace(/<[^>]+>/g, " ");
107
+ n += stripped.split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)).length;
108
+ }
109
+ return n;
110
+ }
111
+
112
+ export function checkLightPage(file: string, content: string, opts: LightPagesOptions = {}): LightFinding[] {
113
+ const allow = new Set((opts.allow ?? []).map((w) => w.toLowerCase()));
114
+ const deny = (opts.deny ?? DEFAULT_DENY).map((w) => w.toLowerCase()).filter((w) => !allow.has(w));
115
+ const findings: LightFinding[] = [];
116
+ const lines = proseLines(content);
117
+ if (deny.length > 0) {
118
+ const pattern = new RegExp(`\\b(${deny.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`, "gi");
119
+ lines.forEach((line, i) => {
120
+ const seen = new Set<string>();
121
+ for (const m of line.matchAll(pattern)) {
122
+ const word = (m[1] ?? "").toLowerCase();
123
+ if (seen.has(word)) continue;
124
+ seen.add(word);
125
+ findings.push({
126
+ file,
127
+ line: i + 1,
128
+ 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.`,
129
+ });
130
+ }
131
+ });
132
+ }
133
+ if (opts.maxWords !== undefined) {
134
+ const words = countWords(lines);
135
+ if (words > opts.maxWords) {
136
+ findings.push({
137
+ file,
138
+ line: 1,
139
+ message: `${words} words of prose against a budget of ${opts.maxWords}. Move options and alternatives to How-to; keep the happy path.`,
140
+ });
141
+ }
142
+ }
143
+ return findings;
144
+ }
145
+
146
+ /** A site's Start here pages and Guides, plus a repo's docs/quickstart.md:
147
+ * the pages the lint applies to when the caller names none. */
148
+ export function defaultLightPages(root: string): string[] {
149
+ const out: string[] = [];
150
+ const site = join(root, "website", "src", "content", "docs");
151
+ for (const name of ["index.md", "index.mdx", "quickstart.md", "quickstart.mdx"]) {
152
+ if (existsSync(join(site, name))) out.push(posix.join("website/src/content/docs", name));
153
+ }
154
+ const guides = join(site, "guides");
155
+ if (existsSync(guides)) {
156
+ const walk = (dir: string) => {
157
+ for (const name of readdirSync(dir).sort()) {
158
+ const p = join(dir, name);
159
+ if (statSync(p).isDirectory()) walk(p);
160
+ else if (/\.mdx?$/.test(name)) out.push(relative(root, p).split("\\").join("/"));
161
+ }
162
+ };
163
+ walk(guides);
164
+ }
165
+ if (existsSync(join(root, "docs", "quickstart.md"))) out.push("docs/quickstart.md");
166
+ return out;
167
+ }
168
+
169
+ export function checkLightPages(root: string, files: string[], opts: LightPagesOptions = {}): LightFinding[] {
170
+ const findings: LightFinding[] = [];
171
+ for (const file of files) {
172
+ const content = readFileSync(join(root, file), "utf8");
173
+ findings.push(...checkLightPage(file, content, opts));
174
+ }
175
+ return findings;
176
+ }
177
+
178
+ function parseArgs(argv: string[]): { root: string; files: string[]; opts: LightPagesOptions } {
179
+ let root = process.cwd();
180
+ const files: string[] = [];
181
+ const opts: LightPagesOptions = {};
182
+ for (let i = 0; i < argv.length; i++) {
183
+ const a = argv[i] ?? "";
184
+ const next = () => argv[++i] ?? "";
185
+ if (a === "--root") root = next();
186
+ else if (a === "--allow") opts.allow = next().split(",").filter(Boolean);
187
+ else if (a === "--deny") opts.deny = next().split(",").filter(Boolean);
188
+ else if (a === "--max-words") opts.maxWords = Number(next());
189
+ else files.push(a);
190
+ }
191
+ return { root, files, opts };
192
+ }
193
+
194
+ if (import.meta.main) {
195
+ const { root, files, opts } = parseArgs(process.argv.slice(2));
196
+ const targets = files.length > 0 ? files : defaultLightPages(root);
197
+ const findings = checkLightPages(root, targets, opts);
198
+ if (findings.length === 0) {
199
+ console.log(`light-pages: OK (${targets.length} page${targets.length === 1 ? "" : "s"})`);
200
+ } else {
201
+ console.error(`light-pages: ${findings.length} finding${findings.length === 1 ? "" : "s"}`);
202
+ for (const f of findings) console.error(` - ${f.file}:${f.line}: ${f.message}`);
203
+ process.exit(1);
204
+ }
205
+ }
@@ -0,0 +1,51 @@
1
+ ---
2
+ /**
3
+ * The install section every product's Start here page carries: the one-line
4
+ * install, one sentence saying what it leaves you with, and one link into
5
+ * norsk-ctl's manual for everything about the machine. The line is a per-product
6
+ * FACT (norsk-ctl's deployment/products.json builds the script it fetches), so it
7
+ * is repeated on every site on purpose; the wording is shared so it cannot drift.
8
+ * Flags never appear here — the plan's rule for Start here pages — they live
9
+ * behind `moreHref`.
10
+ */
11
+ interface Props {
12
+ /** The install slug: `curl .../install/<product>`. `ctl` for norsk-ctl itself. */
13
+ product: string;
14
+ /** What the reader has when the line finishes, one sentence, in the product's nouns. */
15
+ leaves: string;
16
+ /** Where the detail lives. Defaults to norsk-ctl's public manual. */
17
+ moreHref?: string;
18
+ /** Link text for `moreHref`. */
19
+ moreLabel?: string;
20
+ }
21
+ const {
22
+ product,
23
+ leaves,
24
+ moreHref = "https://ctl.norsk.video/how-to/install-options/",
25
+ moreLabel = "ports, TLS, cloud hosts, a box behind another proxy",
26
+ } = Astro.props;
27
+ const line = `curl -fsSL https://norsk.video/install/${product} | bash`;
28
+ ---
29
+
30
+ <div class="install-product">
31
+ <pre class="install-product__line"><code>{line}</code></pre>
32
+ <p class="install-product__leaves">{leaves} It asks for what it needs and waits for a yes.</p>
33
+ <p class="install-product__more">Everything about the machine itself is in norsk-ctl's manual: <a href={moreHref}>{moreLabel}</a>.</p>
34
+ </div>
35
+
36
+ <style>
37
+ .install-product {
38
+ margin: 1.5rem 0;
39
+ }
40
+ .install-product__line {
41
+ margin: 0 0 0.75rem;
42
+ }
43
+ .install-product__leaves,
44
+ .install-product__more {
45
+ margin: 0 0 0.5rem;
46
+ }
47
+ .install-product__more {
48
+ color: var(--sl-color-gray-2);
49
+ font-size: var(--sl-text-sm);
50
+ }
51
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.54",
3
+ "version": "0.2.55",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",
@@ -14,6 +14,7 @@
14
14
  "./doc-guide/live-browser": "./doc-guide/live-browser.js",
15
15
  "./doc-guide/prose-bundle": "./doc-guide/prose-bundle.js",
16
16
  "./docs-theme/sync": "./docs-theme/sync.ts",
17
+ "./docs/guide-stubs": "./docs/guide-stubs.ts",
17
18
  "./docs/path-existence": "./docs/path-existence.ts",
18
19
  "./docs/splice-generated": "./docs/splice-generated.ts",
19
20
  "./licence/check-entitlement": "./licence/check-entitlement.ts",