@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,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.
|
|
3
|
+
"version": "0.2.56",
|
|
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",
|