@norskvideo/ctl-dev-kit 0.2.53 → 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.
- 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 +6 -5
package/doc-guide/doc-guide.js
CHANGED
|
@@ -10,6 +10,37 @@ export function docsRoot() {
|
|
|
10
10
|
return isAbsolute(override) ? override : resolve(process.cwd(), override);
|
|
11
11
|
return resolve(process.cwd(), "docs/generated");
|
|
12
12
|
}
|
|
13
|
+
/** The spoken ceiling for one step. The video spikes measured a step narrating
|
|
14
|
+
* at about 2.5 words/s: 35 words is 14 s on one still, 50 is 20 s, and the
|
|
15
|
+
* renderer's own lint warns at 35. This is the hard stop. */
|
|
16
|
+
export const DEFAULT_CAPTION_WORDS = 50;
|
|
17
|
+
const SPOKEN_WORD = /[\p{L}\p{N}]/u;
|
|
18
|
+
const RAW_URL = /https?:\/\/\S+/;
|
|
19
|
+
const SLASH_BETWEEN_WORDS = /\S+\/\S+/;
|
|
20
|
+
/** Why a step's text cannot be narrated as written, or null when it can. Pure,
|
|
21
|
+
* so the wording is pinned without a browser. Checks the budget first: an
|
|
22
|
+
* over-long caption gets rewritten anyway, and the rewrite is where the
|
|
23
|
+
* phrasing gets fixed. */
|
|
24
|
+
export function captionFault(text, budget) {
|
|
25
|
+
const count = text.split(/\s+/).filter((token) => SPOKEN_WORD.test(token)).length;
|
|
26
|
+
if (count > budget)
|
|
27
|
+
return `${count} words; the spoken budget is ${budget}`;
|
|
28
|
+
const url = RAW_URL.exec(text);
|
|
29
|
+
if (url)
|
|
30
|
+
return `contains a raw URL ("${url[0]}"); say "the address" and let the page carry the link`;
|
|
31
|
+
const slash = SLASH_BETWEEN_WORDS.exec(text);
|
|
32
|
+
if (slash)
|
|
33
|
+
return `contains a slash between words ("${slash[0]}"); write "or" or "and", or hyphenate`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** Device scale factor for a capture: 2 in a DOCS_GENERATE run, 1 otherwise.
|
|
37
|
+
* The video spike found the manual's component crops 383 to 778 px wide; in
|
|
38
|
+
* a 1080p frame they are soft, and no renderer recovers detail that was never
|
|
39
|
+
* captured. A plain run writes no captures, so it keeps 1x and is unchanged.
|
|
40
|
+
* One place for both tiers: guidesConfig and launchLiveBrowser read it. */
|
|
41
|
+
export function captureScaleFactor() {
|
|
42
|
+
return process.env.DOCS_GENERATE === "1" ? 2 : 1;
|
|
43
|
+
}
|
|
13
44
|
/** The attributes Studio's shared MoQ player (norsk-studio-built-ins
|
|
14
45
|
* `lib/shared/moq-player`) stamps on the container it mounts into. `live`
|
|
15
46
|
* means the first video frame has decoded — not that the transport is up or
|
|
@@ -18,35 +49,161 @@ export function docsRoot() {
|
|
|
18
49
|
export const MOQ_PHASE_ATTR = "data-moq-phase";
|
|
19
50
|
export const MOQ_ERROR_ATTR = "data-moq-error";
|
|
20
51
|
const LIVE_PICTURE_TIMEOUT_MS = 60_000;
|
|
52
|
+
const LIVE_PICTURE_POLL_MS = 250;
|
|
53
|
+
const LIVE_PICTURE_FRAME_GAP_MS = 500;
|
|
54
|
+
/** Slates and modals a tile must not be showing. Products add their own via
|
|
55
|
+
* `occluderSelectors`; the text pattern catches a slate rendered as DOM text. */
|
|
56
|
+
export const DEFAULT_OCCLUDER_SELECTORS = ["[data-slate]", '[role="dialog"]', ".modal"];
|
|
57
|
+
export const DEFAULT_OCCLUDER_TEXT = /temporarily unavailable|standby/i;
|
|
58
|
+
export const FRAME_SAMPLE_W = 32;
|
|
59
|
+
export const FRAME_SAMPLE_H = 18;
|
|
60
|
+
const DEFAULT_PIXEL_DELTA = 6;
|
|
61
|
+
const DEFAULT_MIN_CHANGED_FRACTION = 0.02;
|
|
62
|
+
function changedFraction(a, b, pixelDelta) {
|
|
63
|
+
const n = Math.min(a.length, b.length);
|
|
64
|
+
if (n === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
let changed = 0;
|
|
67
|
+
for (let i = 0; i < n; i++)
|
|
68
|
+
if (Math.abs(a[i] - b[i]) > pixelDelta)
|
|
69
|
+
changed++;
|
|
70
|
+
return changed / n;
|
|
71
|
+
}
|
|
72
|
+
function phaseReason(phase, error) {
|
|
73
|
+
if (phase === null)
|
|
74
|
+
return "not stamped — is a player mounted there?";
|
|
75
|
+
return phase === "error" && error ? `error: ${error}` : phase;
|
|
76
|
+
}
|
|
77
|
+
/** Rule on whether the tile is showing a live picture, naming the first
|
|
78
|
+
* check that says it is not. */
|
|
79
|
+
export function judgeLivePicture(evidence, motion = {}) {
|
|
80
|
+
if (evidence.phase !== "live")
|
|
81
|
+
return { live: false, check: "phase", reason: phaseReason(evidence.phase, evidence.error) };
|
|
82
|
+
if (evidence.coveredBy !== null)
|
|
83
|
+
return { live: false, check: "covered", reason: `the tile's centre is under "${evidence.coveredBy}"` };
|
|
84
|
+
if (evidence.occluders.length > 0)
|
|
85
|
+
return {
|
|
86
|
+
live: false,
|
|
87
|
+
check: "occluded",
|
|
88
|
+
reason: `a slate or modal is visible inside the tile: ${evidence.occluders.join(", ")}`,
|
|
89
|
+
};
|
|
90
|
+
if (evidence.frames === null)
|
|
91
|
+
return { live: false, check: "undrawable", reason: "no video or canvas inside the tile to sample" };
|
|
92
|
+
const need = motion.minChangedFraction ?? DEFAULT_MIN_CHANGED_FRACTION;
|
|
93
|
+
const moved = changedFraction(evidence.frames[0], evidence.frames[1], motion.pixelDelta ?? DEFAULT_PIXEL_DELTA);
|
|
94
|
+
if (moved < need)
|
|
95
|
+
return {
|
|
96
|
+
live: false,
|
|
97
|
+
check: "frozen",
|
|
98
|
+
reason: `the picture did not change across ${evidence.frameGapMs}ms (${(moved * 100).toFixed(1)}% of sampled pixels moved; need ${need * 100}%)`,
|
|
99
|
+
};
|
|
100
|
+
return { live: true };
|
|
101
|
+
}
|
|
21
102
|
/** The failure a guide sees when a tile never shows a picture: the tile, the
|
|
22
103
|
* phase it was stuck in and, for an error, the player's own reason — so a
|
|
23
104
|
* broken preview reads as what it is rather than as a timeout. Pure, so the
|
|
24
105
|
* wording is pinned without a browser. */
|
|
25
106
|
export function livePictureFailure(selector, phase, error, timeoutMs) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
/** Block until the tile at `selector` reports a decoded frame, or fail saying
|
|
34
|
-
* why it did not. This is the wait a guide puts between "the source connected"
|
|
35
|
-
* and the capture: the player mounts at that moment, and a screenshot taken
|
|
36
|
-
* then is of a spinner. A wall-clock sleep would hide a broken preview; this
|
|
37
|
-
* turns it into a named failure. */
|
|
38
|
-
export async function awaitLivePicture(page, selector, opts) {
|
|
39
|
-
const timeoutMs = opts?.timeoutMs ?? LIVE_PICTURE_TIMEOUT_MS;
|
|
40
|
-
try {
|
|
41
|
-
await page.waitForFunction(({ selector, attr }) => document.querySelector(selector)?.getAttribute(attr) === "live", { selector, attr: MOQ_PHASE_ATTR }, { timeout: timeoutMs });
|
|
107
|
+
return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${phaseReason(phase, error)})`;
|
|
108
|
+
}
|
|
109
|
+
/** The failure for whichever check the tile never passed. The phase check
|
|
110
|
+
* keeps its original wording; the composite checks name themselves. */
|
|
111
|
+
export function livePictureGateFailure(selector, verdict, timeoutMs) {
|
|
112
|
+
if (verdict.check === "phase") {
|
|
113
|
+
return `the MoQ preview at "${selector}" never decoded a frame in ${timeoutMs}ms (phase: ${verdict.reason})`;
|
|
42
114
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
115
|
+
return `the MoQ preview at "${selector}" never showed a live picture in ${timeoutMs}ms (${verdict.check}: ${verdict.reason})`;
|
|
116
|
+
}
|
|
117
|
+
/** Gather, in the page, everything `judgeLivePicture` rules on. Stops at the
|
|
118
|
+
* phase when the tile is not live: the frame samples cost `frameGapMs`. */
|
|
119
|
+
async function collectLivePictureEvidence(page, selector, gate) {
|
|
120
|
+
return page.evaluate(async ({ selector, phaseAttr, errorAttr, occluderSelectors, occluderText, frameGapMs, w, h }) => {
|
|
121
|
+
const el = document.querySelector(selector);
|
|
122
|
+
const phase = el?.getAttribute(phaseAttr) ?? null;
|
|
123
|
+
const error = el?.getAttribute(errorAttr) ?? null;
|
|
124
|
+
const evidence = { phase, error, coveredBy: null, occluders: [], frames: null, frameGapMs };
|
|
125
|
+
if (!el || phase !== "live")
|
|
126
|
+
return evidence;
|
|
127
|
+
const describe = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ""}${Array.from(n.classList)
|
|
128
|
+
.map((c) => `.${c}`)
|
|
129
|
+
.join("")}`;
|
|
130
|
+
let r = el.getBoundingClientRect();
|
|
131
|
+
if (r.bottom < 0 || r.right < 0 || r.top > innerHeight || r.left > innerWidth) {
|
|
132
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
133
|
+
r = el.getBoundingClientRect();
|
|
134
|
+
}
|
|
135
|
+
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
136
|
+
if (hit === null)
|
|
137
|
+
evidence.coveredBy = "nothing (is the tile off-screen?)";
|
|
138
|
+
else if (!el.contains(hit))
|
|
139
|
+
evidence.coveredBy = describe(hit);
|
|
140
|
+
const visible = (n) => n.getClientRects().length > 0 && getComputedStyle(n).visibility !== "hidden";
|
|
141
|
+
for (const sel of occluderSelectors) {
|
|
142
|
+
if (Array.from(el.querySelectorAll(sel)).some(visible))
|
|
143
|
+
evidence.occluders.push(sel);
|
|
144
|
+
}
|
|
145
|
+
if (occluderText) {
|
|
146
|
+
const match = new RegExp(occluderText.source, occluderText.flags).exec(el.innerText ?? "");
|
|
147
|
+
if (match)
|
|
148
|
+
evidence.occluders.push(`text "${match[0]}"`);
|
|
149
|
+
}
|
|
150
|
+
const drawable = el.matches("video, canvas") ? el : el.querySelector("video, canvas");
|
|
151
|
+
if (!drawable)
|
|
152
|
+
return evidence;
|
|
153
|
+
const scratch = document.createElement("canvas");
|
|
154
|
+
scratch.width = w;
|
|
155
|
+
scratch.height = h;
|
|
156
|
+
const ctx = scratch.getContext("2d");
|
|
157
|
+
if (!ctx)
|
|
158
|
+
return evidence;
|
|
159
|
+
const sample = () => {
|
|
160
|
+
ctx.drawImage(drawable, 0, 0, w, h);
|
|
161
|
+
const d = ctx.getImageData(0, 0, w, h).data;
|
|
162
|
+
const luma = [];
|
|
163
|
+
for (let i = 0; i < d.length; i += 4)
|
|
164
|
+
luma.push(Math.round(0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]));
|
|
165
|
+
return luma;
|
|
166
|
+
};
|
|
167
|
+
const first = sample();
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, frameGapMs));
|
|
169
|
+
evidence.frames = [first, sample()];
|
|
170
|
+
return evidence;
|
|
171
|
+
}, {
|
|
172
|
+
selector,
|
|
173
|
+
phaseAttr: MOQ_PHASE_ATTR,
|
|
174
|
+
errorAttr: MOQ_ERROR_ATTR,
|
|
175
|
+
occluderSelectors: [...gate.occluderSelectors],
|
|
176
|
+
occluderText: gate.occluderText ? { source: gate.occluderText.source, flags: gate.occluderText.flags } : null,
|
|
177
|
+
frameGapMs: gate.frameGapMs,
|
|
178
|
+
w: FRAME_SAMPLE_W,
|
|
179
|
+
h: FRAME_SAMPLE_H,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Block until the tile at `selector` shows a live picture, or fail saying
|
|
183
|
+
* which check it never passed. This is the wait a guide puts between "the
|
|
184
|
+
* source connected" and the capture: the player mounts at that moment, and a
|
|
185
|
+
* screenshot taken then is of a spinner. A wall-clock sleep would hide a
|
|
186
|
+
* broken preview; this turns it into a named failure.
|
|
187
|
+
*
|
|
188
|
+
* Live means: the player stamped `live`, nothing else sits at the tile's
|
|
189
|
+
* centre, no slate or modal is visible inside it, and its picture moved
|
|
190
|
+
* between two samples. The last two are what let a standby slate and a
|
|
191
|
+
* modal over the hero through the phase check alone (2026-10-07). */
|
|
192
|
+
export async function awaitLivePicture(page, selector, opts = {}) {
|
|
193
|
+
const timeoutMs = opts.timeoutMs ?? LIVE_PICTURE_TIMEOUT_MS;
|
|
194
|
+
const gate = {
|
|
195
|
+
occluderSelectors: opts.occluderSelectors ?? DEFAULT_OCCLUDER_SELECTORS,
|
|
196
|
+
occluderText: opts.occluderText === undefined ? DEFAULT_OCCLUDER_TEXT : opts.occluderText,
|
|
197
|
+
frameGapMs: opts.frameGapMs ?? LIVE_PICTURE_FRAME_GAP_MS,
|
|
198
|
+
};
|
|
199
|
+
const deadline = Date.now() + timeoutMs;
|
|
200
|
+
let verdict = judgeLivePicture(await collectLivePictureEvidence(page, selector, gate), opts);
|
|
201
|
+
while (!verdict.live && Date.now() < deadline) {
|
|
202
|
+
await page.waitForTimeout(LIVE_PICTURE_POLL_MS);
|
|
203
|
+
verdict = judgeLivePicture(await collectLivePictureEvidence(page, selector, gate), opts);
|
|
49
204
|
}
|
|
205
|
+
if (!verdict.live)
|
|
206
|
+
throw new Error(livePictureGateFailure(selector, verdict, timeoutMs));
|
|
50
207
|
}
|
|
51
208
|
const SPOTLIGHT_ID = "docguide-spotlight";
|
|
52
209
|
/** Dim the page except the target region, drawn as one fixed box whose oversized
|
|
@@ -113,8 +270,12 @@ export async function awaitFontsReady(page, timeoutMs = FONTS_READY_TIMEOUT_MS)
|
|
|
113
270
|
* from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
|
|
114
271
|
* so the treatment geometry is testable on its own. */
|
|
115
272
|
export async function screenshotFor(page, treat) {
|
|
116
|
-
if (treat?.livePicture)
|
|
117
|
-
await awaitLivePicture(page, treat.livePicture, {
|
|
273
|
+
if (treat?.livePicture) {
|
|
274
|
+
await awaitLivePicture(page, treat.livePicture, {
|
|
275
|
+
timeoutMs: treat.livePictureTimeoutMs,
|
|
276
|
+
...treat.livePictureGate,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
118
279
|
// Wait for web fonts, else the snapshot catches a fallback font mid-swap --
|
|
119
280
|
// but not forever (see awaitFontsReady).
|
|
120
281
|
if (!(await awaitFontsReady(page))) {
|
|
@@ -157,24 +318,48 @@ export function renderUiMarkdown(title, entries) {
|
|
|
157
318
|
}
|
|
158
319
|
return lines.join("\n");
|
|
159
320
|
}
|
|
321
|
+
/** The budget a guide is held to: an explicit option, else the env var the
|
|
322
|
+
* config factory exports, else the default. Read at call time, like docsRoot. */
|
|
323
|
+
export function captionBudget(option) {
|
|
324
|
+
if (option !== undefined)
|
|
325
|
+
return option;
|
|
326
|
+
const fromEnv = Number(process.env.NORSK_DOCS_CAPTION_WORDS);
|
|
327
|
+
return Number.isInteger(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CAPTION_WORDS;
|
|
328
|
+
}
|
|
329
|
+
const TEXT_LABEL_WORDS = 6;
|
|
160
330
|
export class DocGuide {
|
|
161
331
|
slug;
|
|
162
332
|
title;
|
|
333
|
+
options;
|
|
163
334
|
entries = [];
|
|
164
335
|
captureIndex = 0;
|
|
165
|
-
constructor(slug, title) {
|
|
336
|
+
constructor(slug, title, options = {}) {
|
|
166
337
|
this.slug = slug;
|
|
167
338
|
this.title = title;
|
|
339
|
+
this.options = options;
|
|
168
340
|
}
|
|
169
341
|
section(heading) {
|
|
170
342
|
this.entries.push({ kind: "section", heading });
|
|
171
343
|
}
|
|
172
344
|
step(heading, description) {
|
|
345
|
+
this.lintCaption(`step "${heading}"`, description ? `${heading} ${description}` : heading);
|
|
173
346
|
this.entries.push({ kind: "step", heading, description });
|
|
174
347
|
}
|
|
175
348
|
text(prose) {
|
|
349
|
+
const label = prose.split(/\s+/).slice(0, TEXT_LABEL_WORDS).join(" ");
|
|
350
|
+
this.lintCaption(`text "${label} ..."`, prose);
|
|
176
351
|
this.entries.push({ kind: "text", prose });
|
|
177
352
|
}
|
|
353
|
+
/** Only the DOCS_GENERATE run is published and narrated, so only it is held
|
|
354
|
+
* to the spoken budget; a plain run of the guide asserts the UI and says
|
|
355
|
+
* nothing about the prose. */
|
|
356
|
+
lintCaption(what, narrated) {
|
|
357
|
+
if (process.env.DOCS_GENERATE !== "1")
|
|
358
|
+
return;
|
|
359
|
+
const fault = captionFault(narrated, captionBudget(this.options.captionWords));
|
|
360
|
+
if (fault)
|
|
361
|
+
throw new Error(`doc-guide "${this.slug}": ${what} cannot be narrated: ${fault}`);
|
|
362
|
+
}
|
|
178
363
|
/** Screenshot the page and write it immediately under the slug's docs dir. Pass
|
|
179
364
|
* a treatment to crop to one component or spotlight a region in context. */
|
|
180
365
|
async capture(page, caption, treat) {
|
|
@@ -9,11 +9,17 @@ export interface GuidesConfigOptions {
|
|
|
9
9
|
width: number;
|
|
10
10
|
height: number;
|
|
11
11
|
};
|
|
12
|
+
/** Pixel density of the captures. Default 2 in a DOCS_GENERATE run, 1
|
|
13
|
+
* otherwise (see captureScaleFactor): crops shot at 1x are soft in a video frame. */
|
|
14
|
+
deviceScaleFactor?: number;
|
|
12
15
|
/** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
|
|
13
16
|
command?: string;
|
|
14
17
|
/** Playwright testDir. Default "./tests/guides". */
|
|
15
18
|
testDir?: string;
|
|
16
19
|
/** Per-test timeout in ms. Default 60_000. */
|
|
17
20
|
timeout?: number;
|
|
21
|
+
/** Spoken-word budget for one step's text in a DOCS_GENERATE run (see
|
|
22
|
+
* DocGuide). Exported as NORSK_DOCS_CAPTION_WORDS. Default: the dev-kit's 50. */
|
|
23
|
+
captionWords?: number;
|
|
18
24
|
}
|
|
19
25
|
export declare function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { defineConfig } from "@playwright/test";
|
|
2
|
+
import { captureScaleFactor } from "./doc-guide.js";
|
|
2
3
|
export function guidesConfig(opts) {
|
|
3
4
|
// Exported before the test files import the dev-kit DocGuide, so its deferred
|
|
4
5
|
// docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
|
|
5
6
|
process.env.NORSK_DOCS_ROOT = opts.docsRoot;
|
|
7
|
+
if (opts.captionWords !== undefined)
|
|
8
|
+
process.env.NORSK_DOCS_CAPTION_WORDS = String(opts.captionWords);
|
|
6
9
|
const baseURL = `http://127.0.0.1:${opts.port}`;
|
|
7
10
|
const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
|
|
8
11
|
return defineConfig({
|
|
@@ -14,6 +17,7 @@ export function guidesConfig(opts) {
|
|
|
14
17
|
browserName: "chromium",
|
|
15
18
|
headless: true,
|
|
16
19
|
viewport: opts.viewport ?? { width: 1440, height: 960 },
|
|
20
|
+
deviceScaleFactor: opts.deviceScaleFactor ?? captureScaleFactor(),
|
|
17
21
|
...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
|
|
18
22
|
},
|
|
19
23
|
webServer: {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// chromium the fixture tier uses, via BROWSER_FOR_TESTING) rather than go through
|
|
6
6
|
// the test-runner's webServer model.
|
|
7
7
|
import { chromium } from "@playwright/test";
|
|
8
|
+
import { captureScaleFactor } from "./doc-guide.js";
|
|
8
9
|
/** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
|
|
9
10
|
* chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
|
|
10
11
|
* --no-sandbox because the nix chromium has no setuid sandbox helper. */
|
|
@@ -15,7 +16,10 @@ export async function launchLiveBrowser(opts) {
|
|
|
15
16
|
}
|
|
16
17
|
const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
|
|
17
18
|
const viewport = opts?.viewport ?? { width: 1440, height: 960 };
|
|
18
|
-
const context = await browser.newContext({
|
|
19
|
+
const context = await browser.newContext({
|
|
20
|
+
viewport,
|
|
21
|
+
deviceScaleFactor: opts?.deviceScaleFactor ?? captureScaleFactor(),
|
|
22
|
+
});
|
|
19
23
|
const page = await context.newPage();
|
|
20
24
|
return { browser, context, page };
|
|
21
25
|
}
|
|
@@ -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
|
+
}
|