@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.
- 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
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// gated file uses the loosest mechanism that still pins what is shared:
|
|
4
4
|
// - byte-verbatim: CLAUDE.md fenced core, nix/ctl-shell.nix, biome.json,
|
|
5
5
|
// tsconfig.base.json, the .gitignore core block, scripts/demo (only a
|
|
6
|
-
// product with tests/demo.spec.ts is asked for it)
|
|
6
|
+
// product with tests/demo.spec.ts is asked for it), publish-docs-site.yml
|
|
7
|
+
// (only a repo with a docs site may carry it, and never a turnkey)
|
|
7
8
|
// - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
|
|
8
9
|
// - base + sanctioned extension block: dprint.json (repo-specific excludes)
|
|
9
10
|
// - must import the shared module: flake.nix (otherwise the product's own)
|
|
@@ -272,6 +273,10 @@ export interface CanonicalBytes {
|
|
|
272
273
|
publishDocs: string;
|
|
273
274
|
/** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
|
|
274
275
|
buildDocs: string;
|
|
276
|
+
/** conventions/publish-docs-site.yml — the docs SITE's publish (website/ ->
|
|
277
|
+
* Cloudflare Pages on every push to main). Verbatim; allowed only where
|
|
278
|
+
* there is a site to publish and never in a turnkey. */
|
|
279
|
+
publishDocsSite: string;
|
|
275
280
|
checks: string;
|
|
276
281
|
biome: string;
|
|
277
282
|
tsconfigBase: string;
|
|
@@ -506,6 +511,10 @@ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = proce
|
|
|
506
511
|
}
|
|
507
512
|
|
|
508
513
|
const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/;
|
|
514
|
+
const TURNKEY_REPO = /^norsk-ctl-turnkey-/;
|
|
515
|
+
|
|
516
|
+
/** The docs site's publish workflow, where a repo carries one. */
|
|
517
|
+
export const DOCS_SITE_WORKFLOW = ".github/workflows/publish-docs-site.yml";
|
|
509
518
|
|
|
510
519
|
/** ADR-0012: exposure is declared by which docs workflow a repo carries, so the
|
|
511
520
|
* gate is what makes that declaration safe — exactly one variant; the public
|
|
@@ -530,6 +539,37 @@ export function docsExposureProblems(hasPublish: boolean, hasBuild: boolean, rep
|
|
|
530
539
|
return problems;
|
|
531
540
|
}
|
|
532
541
|
|
|
542
|
+
/** Where a PUBLIC docs site may exist. The rule is negative on the repo name
|
|
543
|
+
* where publish-docs.yml's is positive, because sites live outside
|
|
544
|
+
* norsk-ctl-product-* (norsk-relay has one, and so do norsk-ctl and the
|
|
545
|
+
* Manager); what is certain is that a turnkey has none -- its documentation
|
|
546
|
+
* reaches one customer, inside the image they run (ADR-0012). And a site has
|
|
547
|
+
* to exist to be published: a repo without website/astro.config.mjs carrying
|
|
548
|
+
* the workflow would deploy nothing, or whatever a stale dist held. */
|
|
549
|
+
export function docsSiteExposureProblems(
|
|
550
|
+
hasWorkflow: boolean,
|
|
551
|
+
hasSite: boolean,
|
|
552
|
+
repoName: string | undefined,
|
|
553
|
+
): string[] {
|
|
554
|
+
if (!hasWorkflow) return [];
|
|
555
|
+
const problems: string[] = [];
|
|
556
|
+
if (!hasSite) {
|
|
557
|
+
problems.push(
|
|
558
|
+
`${DOCS_SITE_WORKFLOW} publishes a docs site, but this repo has none (no ${DOCS_SITE_DIR}/astro.config.mjs). Add the site (conventions/docs-site.md) or delete the workflow.`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (repoName === undefined) {
|
|
562
|
+
problems.push(
|
|
563
|
+
`${DOCS_SITE_WORKFLOW} publishes this repo's docs site publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). A turnkey (norsk-ctl-turnkey-*) may not carry it.`,
|
|
564
|
+
);
|
|
565
|
+
} else if (TURNKEY_REPO.test(repoName)) {
|
|
566
|
+
problems.push(
|
|
567
|
+
`${DOCS_SITE_WORKFLOW} publishes a public docs site, but this repo is ${repoName}, a turnkey. A turnkey has no public site: its documentation reaches one customer, inside the image they run (ADR-0012). Delete the workflow.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
return problems;
|
|
571
|
+
}
|
|
572
|
+
|
|
533
573
|
export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: CheckDriftOptions = {}): DriftReport {
|
|
534
574
|
const problems: string[] = [];
|
|
535
575
|
const push = (problem: string | undefined) => {
|
|
@@ -808,6 +848,20 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
|
|
|
808
848
|
// checked, and one clause covers both.
|
|
809
849
|
for (const p of docsThemeProblems(repoRoot, opts.themeCanonical)) push(p);
|
|
810
850
|
|
|
851
|
+
// publish-docs-site.yml: the site's publish, verbatim (no per-repo line: the
|
|
852
|
+
// project name is a repo variable, the credentials are secrets), and only
|
|
853
|
+
// where docsSiteExposureProblems allows a public site at all.
|
|
854
|
+
const siteWorkflowPath = join(repoRoot, DOCS_SITE_WORKFLOW);
|
|
855
|
+
if (existsSync(siteWorkflowPath)) {
|
|
856
|
+
const actual = readFileSync(siteWorkflowPath, "utf8");
|
|
857
|
+
if (actual !== canonical.publishDocsSite) {
|
|
858
|
+
push(
|
|
859
|
+
`${DOCS_SITE_WORKFLOW} has drifted from @norskvideo/ctl-dev-kit conventions/publish-docs-site.yml (${firstDiffLine(actual, canonical.publishDocsSite)}). ${RESYNC}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
for (const p of docsSiteExposureProblems(existsSync(siteWorkflowPath), hasDocsSite(repoRoot), opts.repoName)) push(p);
|
|
864
|
+
|
|
811
865
|
for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
|
|
812
866
|
for (const p of productLogsProblems(repoRoot)) push(p);
|
|
813
867
|
for (const p of ctlPinProblems(repoRoot)) push(p);
|
|
@@ -827,6 +881,7 @@ if (import.meta.main) {
|
|
|
827
881
|
botPrs: readFileSync(join(import.meta.dir, "bot-prs.yml"), "utf8"),
|
|
828
882
|
publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
|
|
829
883
|
buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
|
|
884
|
+
publishDocsSite: readFileSync(join(import.meta.dir, "publish-docs-site.yml"), "utf8"),
|
|
830
885
|
withDisplay: readFileSync(join(import.meta.dir, "..", "doc-guide", "with-display.sh"), "utf8"),
|
|
831
886
|
checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
|
|
832
887
|
biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
|
package/conventions/docs-site.md
CHANGED
|
@@ -8,7 +8,7 @@ Like [`docs.md`](./docs.md) and [`personas.md`](./personas.md) this is a **shipp
|
|
|
8
8
|
reference**, not a drift-gated copy: read it from
|
|
9
9
|
`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md`. What IS gated is
|
|
10
10
|
the theme itself — see "The theme is a copy, and the gate is what makes that
|
|
11
|
-
safe" below.
|
|
11
|
+
safe" below — and the publish workflow, see "Publishing".
|
|
12
12
|
|
|
13
13
|
**`create-product` renders all of it.** A repo generated by
|
|
14
14
|
`bunx @norskvideo/ctl-dev-kit create-product` comes out with a `website/` that
|
|
@@ -78,10 +78,24 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
|
|
|
78
78
|
|
|
79
79
|
- **Start here** — the Evaluator. What this does, and where to go next.
|
|
80
80
|
- **Guides** — the Builder. One task, start to finish, in the order it is done.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
**A Guides page is the recorded chapter where one exists**: a symlink from
|
|
82
|
+
`website/src/content/docs/guides/<slug>` into the `docs/generated/<slug>`
|
|
83
|
+
tree a doc-guide writes, with at most a prose introduction above it. That
|
|
84
|
+
is what the Manager's site does for every guide and what makes the page
|
|
85
|
+
unable to describe a screen that does not exist; see [`docs.md`](./docs.md)
|
|
86
|
+
for the toolchain, the `capturedAgainst` front matter the theme renders at
|
|
87
|
+
the foot of such a page, and the placeholder the dev-kit writes so the site
|
|
88
|
+
builds on a checkout with no captures. **When a product has more than one
|
|
89
|
+
surface** (a console and an API, or a CLI as well), show the same task on
|
|
90
|
+
each: `<task>`, `<task>-ui`, `<task>-api`, grouped under the task in the
|
|
91
|
+
sidebar, as the Manager does for launching a show format, draining a node
|
|
92
|
+
and API tokens. Not every task needs every surface; the first task on the
|
|
93
|
+
Builder's route should have them all.
|
|
84
94
|
- **How-to** — the Administrator. Looked up, repeatedly, while running it.
|
|
95
|
+
Its first page, **"What this product adds to the host"** (images, ports,
|
|
96
|
+
volumes, GPU, state), is generated from the manifest and the product
|
|
97
|
+
template, where those facts already live, and never hand-written; the
|
|
98
|
+
rest of the section ends with a link into norsk-ctl's manual.
|
|
85
99
|
- **Concepts** — the vocabulary the rest of the manual uses. Not architecture.
|
|
86
100
|
- **Reference** — the Integrator. Exhaustive and generated from the source of
|
|
87
101
|
truth where possible.
|
|
@@ -89,6 +103,16 @@ each section serves, in the vocabulary of [`personas.md`](./personas.md):
|
|
|
89
103
|
Add sections below these if a product genuinely needs them. Do not rename or
|
|
90
104
|
reorder these five: the shape is the shared part.
|
|
91
105
|
|
|
106
|
+
**Start here and Guides are the light half**, written for readers who do not
|
|
107
|
+
know our engineering vocabulary: one command per step, no flags, the result
|
|
108
|
+
before the prerequisites, a single "Next" link at the foot of each page, and a
|
|
109
|
+
word budget. `docs/light-pages.ts` lints those pages (see `docs.md`, "Voice").
|
|
110
|
+
How-to and Reference may be as exhaustive as the Administrator and the
|
|
111
|
+
Integrator want. The install section of Start here is the theme's
|
|
112
|
+
`components/InstallProduct.astro`: the product's one-line install, one sentence
|
|
113
|
+
on what it leaves behind, and one link into norsk-ctl's manual. It is repeated
|
|
114
|
+
on every product site on purpose and generated so the wording cannot drift.
|
|
115
|
+
|
|
92
116
|
## The theme is a copy, and the gate is what makes that safe
|
|
93
117
|
|
|
94
118
|
The theme reaches a site as **files copied into it**, not as an import, and
|
|
@@ -138,6 +162,27 @@ Astro derives a component's scoped-style class from its path, so moving
|
|
|
138
162
|
Then `bun install --cwd website` (the repo-root install does NOT cover a
|
|
139
163
|
standalone package) and `bun run docs:site`.
|
|
140
164
|
|
|
165
|
+
### Adding a recorded guide
|
|
166
|
+
|
|
167
|
+
Two lines. A symlink from the site's Guides into the tree the doc-guide
|
|
168
|
+
writes, and a sidebar entry:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
ln -s ../../../../../docs/generated/<slug> website/src/content/docs/guides/<slug>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
{ label: "<Title>", slug: "guides/<slug>" }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`docs:site` runs the dev-kit's `docs/guide-stubs.ts stub` first, so the site
|
|
179
|
+
builds on a checkout that has never captured; `publish-docs-site.yml` runs
|
|
180
|
+
`guide-stubs.ts check` after the build and refuses to deploy while any guide
|
|
181
|
+
is still a placeholder. That refusal is deliberate: a product that declares
|
|
182
|
+
recorded guides must also restore (or regenerate) its captures in that
|
|
183
|
+
workflow before the build, and until it does, the publish stops rather than
|
|
184
|
+
shipping an empty page.
|
|
185
|
+
|
|
141
186
|
## The two builds
|
|
142
187
|
|
|
143
188
|
One content collection, two outputs, selected by `ASTRO_BUILD`:
|
|
@@ -159,22 +204,56 @@ of compressed docs on a ~68 MB binary.
|
|
|
159
204
|
|
|
160
205
|
## Publishing
|
|
161
206
|
|
|
162
|
-
|
|
207
|
+
The site publishes from **`.github/workflows/publish-docs-site.yml`**,
|
|
208
|
+
single-sourced in this package (`conventions/publish-docs-site.yml`) and
|
|
209
|
+
**drift-gated** the way the bundle's `publish-docs.yml` is: `bun run check:drift`
|
|
210
|
+
holds the copy byte-identical, and `bun run sync:drift` writes it — creating it
|
|
211
|
+
where the repo has a site and is not a turnkey. On every push to `main` (and on
|
|
212
|
+
demand) it installs `website/`, builds the public variant and deploys
|
|
213
|
+
`website/dist-public` to Cloudflare Pages with `wrangler`. It never cancels a
|
|
214
|
+
run in flight, because a cancelled deploy is a half-published site. It restores
|
|
215
|
+
no capture cache: a product site builds from the committed prose, so there is
|
|
216
|
+
nothing a nightly run would have that a push does not.
|
|
217
|
+
|
|
218
|
+
Where it may exist is the gate's business (ADR-0012, and the documentation
|
|
219
|
+
plan): a repo with `website/astro.config.mjs` may carry it; a repo without a
|
|
220
|
+
site may not, since there is nothing to publish; a turnkey
|
|
221
|
+
(`norsk-ctl-turnkey-*`) may not, since a turnkey has no public site. The rule
|
|
222
|
+
is negative on the repo name where `publish-docs.yml`'s is positive, because
|
|
223
|
+
sites exist outside `norsk-ctl-product-*` — Relay has one.
|
|
224
|
+
|
|
225
|
+
What the repo needs, per site, and none of it to build or review one locally:
|
|
163
226
|
|
|
164
227
|
1. **A Cloudflare Pages project**, e.g. `norsk-acme-docs`.
|
|
165
228
|
2. **A custom domain** on it — `<product>.norsk.video` — and the DNS record.
|
|
166
|
-
3. **
|
|
167
|
-
|
|
168
|
-
the account, or issue a second token
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
229
|
+
3. **Two repo secrets.** `CLOUDFLARE_API_TOKEN`, a Pages:Edit token — the
|
|
230
|
+
existing one is deliberately narrow and must cover the new project: widen it
|
|
231
|
+
to all Pages projects in the account, or issue a second token — and
|
|
232
|
+
`CLOUDFLARE_ACCOUNT_ID`. The workflow verifies the token at the API (both the
|
|
233
|
+
user-owned and the account-owned endpoint), never with `wrangler whoami`,
|
|
234
|
+
which a Pages:Edit token cannot answer.
|
|
235
|
+
4. **One repo variable.** `NORSK_DOCS_SITE_PROJECT`, the project name from
|
|
236
|
+
step 1. It has **no default**: unset, the workflow still builds the site,
|
|
237
|
+
prints exactly these steps and exits 0 — so `main` is green before
|
|
238
|
+
Cloudflare exists and publishes the day the variable is set, with no change
|
|
239
|
+
to the workflow.
|
|
240
|
+
|
|
241
|
+
A `workflow_dispatch` from a branch other than `main` deploys a **preview** of
|
|
242
|
+
that branch (`--branch` is the ref that ran), never over production.
|
|
243
|
+
|
|
244
|
+
### Wiring it into a repo that has a site
|
|
245
|
+
|
|
246
|
+
Two steps, once a dev-kit carrying the workflow is published:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
bun update @norskvideo/ctl-dev-kit # bump the pin (or let sync-dev-kit.yml do it)
|
|
250
|
+
bun run sync:drift # writes .github/workflows/publish-docs-site.yml
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Commit the result; `bun run check:drift` passes. The nightly `sync-dev-kit.yml`
|
|
254
|
+
does both hands-free, so a product with a site acquires the workflow on its
|
|
255
|
+
next sync without anyone touching the repo. Then create the Pages project, set
|
|
256
|
+
the two secrets and the variable, and the next push to `main` publishes.
|
|
178
257
|
|
|
179
258
|
## Search does not cross products
|
|
180
259
|
|
package/conventions/docs.md
CHANGED
|
@@ -52,6 +52,14 @@ product's manual in one voice:
|
|
|
52
52
|
- **No internal/test vocabulary.** Never `engine`, `fixture`, `Playwright`,
|
|
53
53
|
`assertion`, `harness`, `instance`, `SSR`, a slug, or a DOM id in reader-facing
|
|
54
54
|
copy. The reader does not know or care how the shot was produced.
|
|
55
|
+
**Scoped and checked (October 2026):** the rule applies to the light half of
|
|
56
|
+
a manual, which is a site's Start here pages (`index`, `quickstart`) and its
|
|
57
|
+
`guides/`, plus a repo's `docs/quickstart.md`. `docs/light-pages.ts` in this
|
|
58
|
+
package lints exactly those pages for the deny list and a word budget, skipping
|
|
59
|
+
code, comments and imports, and runs from `bun run docs:check`. A repo whose
|
|
60
|
+
own vocabulary is on the list allows the word (`--allow`); `instance` is not
|
|
61
|
+
on it, because it is the label on norsk-ctl's own screens. How-to and
|
|
62
|
+
Reference may use any of it.
|
|
55
63
|
- **Lead with the capability, not the UI state.** "A commentator joins from a
|
|
56
64
|
browser with a link" — not "the connections page shows a slot in the pending
|
|
57
65
|
state."
|
|
@@ -89,11 +97,44 @@ Per-step treatments (`CaptureTreatment`, from the doc-guide library):
|
|
|
89
97
|
`live` on the first decoded frame, or `error` with the reason in
|
|
90
98
|
`data-moq-error`. The player mounts at the moment the source connects, which
|
|
91
99
|
is also when a guide's "it's running" assertion passes — so a capture taken
|
|
92
|
-
then is of a spinner, and a sleep only hides a preview that is broken.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
then is of a spinner, and a sleep only hides a preview that is broken. The
|
|
101
|
+
phase alone is not enough, though: Commentary's standby slate and its "Enter
|
|
102
|
+
the booth" modal both passed it (2026-10-07), because the player underneath
|
|
103
|
+
was live. So the gate judges the visible composite as well: the element at
|
|
104
|
+
the tile's centre (`document.elementFromPoint`) must be the tile or inside
|
|
105
|
+
it, nothing matching the slate/modal selectors (`[data-slate]`,
|
|
106
|
+
`[role="dialog"]`, `.modal`, or visible text matching
|
|
107
|
+
`/temporarily unavailable|standby/i`) may be visible inside it, and two
|
|
108
|
+
32x18 luma samples of its video or canvas taken 500 ms apart must differ —
|
|
109
|
+
a slate, however bright, is still. Name the tile here, or call
|
|
110
|
+
`awaitLivePicture(page, selector, gate?)` before the capture (the verify run
|
|
111
|
+
skips captures, so an explicit wait is also the assertion); either way a
|
|
112
|
+
tile that never shows a picture fails the guide, naming the check it never
|
|
113
|
+
passed. Add a product's own slate markers with `livePictureGate.
|
|
114
|
+
occluderSelectors`. A tile with no video or canvas in it (an audio-only
|
|
115
|
+
player) cannot pass, so do not name one.
|
|
116
|
+
|
|
117
|
+
**Captures are shot at 2x in a `DOCS_GENERATE` run.** `guidesConfig` defaults
|
|
118
|
+
`deviceScaleFactor` from `captureScaleFactor()`: 2 when `DOCS_GENERATE=1`, 1
|
|
119
|
+
otherwise, and `launchLiveBrowser` (the engine tier) reads the same default.
|
|
120
|
+
The video spike measured the manual's component crops at 383 to 778 px wide;
|
|
121
|
+
blown up to sit in a 1080p frame they were legible but soft, and no renderer
|
|
122
|
+
step recovers detail that was never captured. A plain run of a guide writes no
|
|
123
|
+
captures, so it stays at 1x and nothing else about it changes; pass
|
|
124
|
+
`deviceScaleFactor` explicitly to pin either. Expect the PNGs to grow with the
|
|
125
|
+
pixel count.
|
|
126
|
+
|
|
127
|
+
### When a guide runs
|
|
128
|
+
|
|
129
|
+
A fixture-tier guide costs seconds and needs no licence, so it runs in the
|
|
130
|
+
**PR check** (`checks.yml`), where a broken capture is found by its author;
|
|
131
|
+
the engine tier runs **nightly** in the docs workflow, where a licence and a
|
|
132
|
+
real launch are affordable. A site is never hostage to a capture: on a
|
|
133
|
+
checkout with no `docs/generated`, the dev-kit's `docs/guide-stubs.ts stub`
|
|
134
|
+
writes a placeholder page for every guide the site's symlinks name, so
|
|
135
|
+
`docs:site` builds, and `docs/guide-stubs.ts check` **refuses to publish** a
|
|
136
|
+
placeholder, in every publish workflow the dev-kit ships. The placeholder
|
|
137
|
+
sentence is the marker the gate matches on; do not write it by hand.
|
|
97
138
|
|
|
98
139
|
## The toolchain
|
|
99
140
|
|
|
@@ -103,6 +144,12 @@ they do not copy it:
|
|
|
103
144
|
- `@norskvideo/ctl-dev-kit/doc-guide` — `DocGuide` (a guide narrates itself into
|
|
104
145
|
`docs/generated/<slug>/`), `screenshotFor`, `CaptureTreatment`. Writes under
|
|
105
146
|
`NORSK_DOCS_ROOT` (the repo-level `docs/generated`); the config factory sets it.
|
|
147
|
+
Step text is also the video narration, so in a `DOCS_GENERATE` run `step()`
|
|
148
|
+
(heading plus description) and `text()` fail the guide over 50 spoken words,
|
|
149
|
+
on a raw URL (say "the address"), or on a slash between words (write "or" or
|
|
150
|
+
"and"); the failure names the guide, the step and the count. `captionWords`
|
|
151
|
+
on `guidesConfig` or on the `DocGuide` options tightens the budget. A plain
|
|
152
|
+
run is unchanged.
|
|
106
153
|
- `@norskvideo/ctl-dev-kit/doc-guide/guides-config` — `guidesConfig({ port,
|
|
107
154
|
docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
|
|
108
155
|
the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
|
package/conventions/personas.md
CHANGED
|
@@ -131,3 +131,28 @@ Reconcile them like this:
|
|
|
131
131
|
Map each product's named audiences (from its `PRODUCT.md`) onto the four
|
|
132
132
|
personas above; don't invent a parallel scheme. Keep the per-product mapping in
|
|
133
133
|
the product's `docs/personas.md`.
|
|
134
|
+
|
|
135
|
+
## The journey table
|
|
136
|
+
|
|
137
|
+
A persona is who you write for; a journey is where they go. Each product's
|
|
138
|
+
`docs/personas.md` carries one table, in this shape, and `docs/journeys.ts` in
|
|
139
|
+
this package checks it from `bun run docs:check`: every persona has one row,
|
|
140
|
+
every page it names exists, no route is longer than five pages, and every route
|
|
141
|
+
ends in a hand-off.
|
|
142
|
+
|
|
143
|
+
| Persona | Starts at | Then, in order | Hands off to |
|
|
144
|
+
| ------------- | ----------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
|
|
145
|
+
| Evaluator | `/docs/` | `/docs/quickstart/`, `/docs/guides/gallery/` | Guides (build with it) or ctl (run it for real) |
|
|
146
|
+
| Builder | `/docs/guides/first-…/` | the task pages, `/docs/guides/troubleshooting/` | Reference, for the settings they just used |
|
|
147
|
+
| Integrator | `/docs/reference/` | config, CLI subtree, API, recipes | norsk-ctl's CLI and API reference |
|
|
148
|
+
| Administrator | `/docs/how-to/` | what this product adds to the host, known limitations | norsk-ctl's manual, for everything about the machine |
|
|
149
|
+
|
|
150
|
+
The Integrator's route may start at a recorded API walkthrough (the `-api`
|
|
151
|
+
variant of the Builder's first task) rather than at Reference; the Manager's
|
|
152
|
+
"API tokens, HTTP API" is the pattern. Reference stays where it ends.
|
|
153
|
+
|
|
154
|
+
Site paths are `/docs/...` (the embedded base; the public build strips it) and
|
|
155
|
+
resolve to `website/src/content/docs`; a bare `name.md` resolves under `docs/`.
|
|
156
|
+
The hand-off is where "see norsk-ctl for details" lives, once per route, so no
|
|
157
|
+
page needs the caveat sprinkled through it. A repo with no site and no
|
|
158
|
+
`docs/quickstart.md` (a backend-only turnkey) is not asked for the table.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Publish this product's DOCS SITE -- the public manual at <product>.norsk.video
|
|
2
|
+
# (website/, an Astro + Starlight site on the shared Norsk theme) -- to
|
|
3
|
+
# Cloudflare Pages on every push to main.
|
|
4
|
+
#
|
|
5
|
+
# This is the SITE channel, not the bundle: publish-docs.yml / build-docs.yml
|
|
6
|
+
# regenerate the illustrated manual the product image serves at /docs, nightly,
|
|
7
|
+
# from a licensed Docker run. The site builds from the committed prose alone,
|
|
8
|
+
# needs no licence and no Docker, and so rides every push (conventions/
|
|
9
|
+
# docs-site.md, "Two documentation channels"). A site that also symlinks
|
|
10
|
+
# RECORDED guides (website/src/content/docs/guides/<slug> -> docs/generated/<slug>)
|
|
11
|
+
# builds against a placeholder when the capture is absent, and the gate below
|
|
12
|
+
# refuses to deploy one.
|
|
13
|
+
#
|
|
14
|
+
# This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
|
|
15
|
+
# (conventions/publish-docs-site.yml) and copied verbatim: there is no per-repo
|
|
16
|
+
# line. Everything per-site lives in the repo -- website/astro.config.mjs, the
|
|
17
|
+
# root `docs:site:public` script -- or in the repo's settings:
|
|
18
|
+
#
|
|
19
|
+
# variable NORSK_DOCS_SITE_PROJECT the Cloudflare Pages project name.
|
|
20
|
+
# NO DEFAULT: unset means the project does
|
|
21
|
+
# not exist yet, and the deploy step says
|
|
22
|
+
# what to create and exits 0. So main is
|
|
23
|
+
# green before Cloudflare exists, and the
|
|
24
|
+
# site publishes the day the variable is
|
|
25
|
+
# set, with no change here.
|
|
26
|
+
# secret CLOUDFLARE_API_TOKEN a Pages:Edit token covering that project
|
|
27
|
+
# secret CLOUDFLARE_ACCOUNT_ID the account the project lives in
|
|
28
|
+
#
|
|
29
|
+
# The site is BUILT whether or not it will be deployed: if the build is only
|
|
30
|
+
# exercised on the day the Pages project appears, that day is when we find out
|
|
31
|
+
# it was broken.
|
|
32
|
+
#
|
|
33
|
+
# The drift gate (check-drift.ts) holds the copy byte-identical, allows it only
|
|
34
|
+
# in a repo that has a site (website/astro.config.mjs), and refuses it in a
|
|
35
|
+
# turnkey (norsk-ctl-turnkey-*): a turnkey has no public site, its documentation
|
|
36
|
+
# reaches one customer inside the image they run (ADR-0012).
|
|
37
|
+
name: publish-docs-site
|
|
38
|
+
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
branches: [main]
|
|
42
|
+
workflow_dispatch:
|
|
43
|
+
|
|
44
|
+
permissions:
|
|
45
|
+
contents: read
|
|
46
|
+
|
|
47
|
+
# Never cancel in flight: a cancelled `wrangler pages deploy` is a half-published
|
|
48
|
+
# site, and the next push rebuilds from the same inputs anyway. Queue instead.
|
|
49
|
+
concurrency:
|
|
50
|
+
group: publish-docs-site
|
|
51
|
+
cancel-in-progress: false
|
|
52
|
+
|
|
53
|
+
jobs:
|
|
54
|
+
publish:
|
|
55
|
+
runs-on: x64
|
|
56
|
+
steps:
|
|
57
|
+
# Runs BEFORE checkout, and does two things: points the harness's temp base
|
|
58
|
+
# out of the workspace for every later step in this job, and clears whatever
|
|
59
|
+
# the old repo-local default left behind on this runner. The body carries the
|
|
60
|
+
# why. It fails the job only when legacy litter survives -- which would take
|
|
61
|
+
# the checkout down a step later anyway, but report nothing useful.
|
|
62
|
+
- name: Route test-temp out of the workspace (pre-checkout)
|
|
63
|
+
run: |
|
|
64
|
+
set -uo pipefail
|
|
65
|
+
# These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
|
|
66
|
+
# HOST daemon has not seen before is created by dockerd, as ROOT, on the
|
|
67
|
+
# host -- even when the launch passes --container-user (reproduced
|
|
68
|
+
# 2026-09-04). The harness's default base is repo-local for an OrbStack
|
|
69
|
+
# inotify constraint that binds macOS dev and nothing here, so every run
|
|
70
|
+
# seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
|
|
71
|
+
# cannot remove. That took commentary's checks AND integration red on
|
|
72
|
+
# 2026-09-03. Use the sanctioned override, per-run and outside the
|
|
73
|
+
# checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
|
|
74
|
+
base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
|
75
|
+
mkdir -p "$base"
|
|
76
|
+
echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
|
|
77
|
+
# Yesterday's bases hold dockerd-created root dirs, so a non-root rm
|
|
78
|
+
# EACCESes. They cannot wedge a checkout, but they do fill the disk.
|
|
79
|
+
for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
|
|
80
|
+
docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
|
|
81
|
+
done
|
|
82
|
+
# LEGACY: a runner that ran the old default still carries a workspace
|
|
83
|
+
# test-temp, and a checkout with clean:true dies on it. Reap the holders
|
|
84
|
+
# first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
|
|
85
|
+
# RESTARTING leaked container re-creates the path as root seconds after
|
|
86
|
+
# any clean. Match on mount SOURCE, not a label: the holders are untracked
|
|
87
|
+
# by construction, because a killed run labels nothing.
|
|
88
|
+
tt="$GITHUB_WORKSPACE/test-temp"
|
|
89
|
+
[ -d "$tt" ] || exit 0
|
|
90
|
+
stuck=""
|
|
91
|
+
for c in $(docker ps -aq 2>/dev/null || true); do
|
|
92
|
+
if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
|
|
93
|
+
stuck="$stuck $c"
|
|
94
|
+
fi
|
|
95
|
+
done
|
|
96
|
+
if [ -n "$stuck" ]; then
|
|
97
|
+
echo "reaping containers holding mounts under $tt:$stuck"
|
|
98
|
+
docker rm -f $stuck || true
|
|
99
|
+
fi
|
|
100
|
+
# The DIRECTORY, not just its contents: an empty but root-owned base
|
|
101
|
+
# still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
|
|
102
|
+
# container unlink the leaf.
|
|
103
|
+
docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
|
|
104
|
+
sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
|
|
105
|
+
# Fail HERE if it survived. The checkout fails on it either way, but
|
|
106
|
+
# reports only an EACCES rmdir with no clue what was holding the path.
|
|
107
|
+
if [ -e "$tt" ]; then
|
|
108
|
+
echo "::error::$tt survived the pre-checkout clean"
|
|
109
|
+
ls -lan "$tt" || true
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
- uses: actions/checkout@v5
|
|
114
|
+
with:
|
|
115
|
+
clean: true
|
|
116
|
+
|
|
117
|
+
# website/ is a STANDALONE package -- its own bun.lock, outside the root
|
|
118
|
+
# workspaces -- so the repo-root install never covers it, and the build
|
|
119
|
+
# needs both: the root for @norskvideo/ctl-dev-kit (docs:site:public stubs
|
|
120
|
+
# the recorded guides through its docs/guide-stubs.ts, and the gate below
|
|
121
|
+
# reads them the same way), website/ for Astro. Frozen, as every CI
|
|
122
|
+
# install is: a lockfile the build rewrites is drift nobody sees. The
|
|
123
|
+
# public variant (ASTRO_BUILD=public) is base /; the embedded one is base
|
|
124
|
+
# /docs, and every link in it is wrong on the public domain -- which is
|
|
125
|
+
# what the grep at the end catches.
|
|
126
|
+
- name: Build the public docs site
|
|
127
|
+
run: |
|
|
128
|
+
nix develop .#build --command bash -c '
|
|
129
|
+
set -euo pipefail
|
|
130
|
+
bun install --frozen-lockfile
|
|
131
|
+
bun install --frozen-lockfile --cwd website
|
|
132
|
+
bun run docs:site:public
|
|
133
|
+
dist="website/dist-public"
|
|
134
|
+
[ -s "$dist/index.html" ] || { echo "::error::$dist/index.html was not built"; exit 1; }
|
|
135
|
+
if grep -q "href=./docs/" "$dist/index.html"; then
|
|
136
|
+
echo "::error::$dist is the EMBEDDED variant (links are /docs-prefixed); docs:site:public must set ASTRO_BUILD=public"
|
|
137
|
+
exit 1
|
|
138
|
+
fi
|
|
139
|
+
echo "built $dist ($(find "$dist" -name "*.html" | wc -l) pages)"
|
|
140
|
+
'
|
|
141
|
+
|
|
142
|
+
# A recorded guide whose capture is not on this runner is a placeholder
|
|
143
|
+
# page after the build above (docs:site:public stubs it so the build
|
|
144
|
+
# works). Right for a local build, wrong for a deploy: the live page would
|
|
145
|
+
# be replaced with "run the doc-guide locally", and nothing else would
|
|
146
|
+
# notice -- the build is green, the links resolve. --content reads the
|
|
147
|
+
# guides the site declares by symlink, so a site that declares none has
|
|
148
|
+
# nothing to refuse; one that does needs its captures on the runner, and
|
|
149
|
+
# until the repo restores them this step says which pages are missing.
|
|
150
|
+
- name: Refuse to publish placeholder guides
|
|
151
|
+
run: |
|
|
152
|
+
nix develop .#build --command bash -c '
|
|
153
|
+
set -euo pipefail
|
|
154
|
+
bun run node_modules/@norskvideo/ctl-dev-kit/docs/guide-stubs.ts check --content website/src/content/docs/guides
|
|
155
|
+
'
|
|
156
|
+
|
|
157
|
+
# Skipped -- loudly, and successfully -- while NORSK_DOCS_SITE_PROJECT is
|
|
158
|
+
# unset: the Pages project and the DNS record are the account owner's to
|
|
159
|
+
# create, and main must not go red waiting for them. Setting the variable
|
|
160
|
+
# is the whole switch-on.
|
|
161
|
+
#
|
|
162
|
+
# The token is verified at the API, not with `wrangler whoami`: whoami
|
|
163
|
+
# needs User Details:Read, which a Pages:Edit token deliberately lacks. Two
|
|
164
|
+
# kinds of token verify at two endpoints: a USER-owned token (My Profile ->
|
|
165
|
+
# API Tokens) answers /user/tokens/verify; an ACCOUNT-owned one (Manage
|
|
166
|
+
# Account -> API Tokens) returns 1000 "Invalid API Token" there however
|
|
167
|
+
# valid it is, and answers /accounts/<id>/tokens/verify. Checking only the
|
|
168
|
+
# first once made a valid team-owned token read like a bad secret. curl is
|
|
169
|
+
# on the host PATH; wrangler is not in the shared dev shell, so bunx fetches
|
|
170
|
+
# it inside `nix develop`, where bun is.
|
|
171
|
+
#
|
|
172
|
+
# --branch is the ref that pushed: main is the production branch of the
|
|
173
|
+
# Pages project, so a push to main publishes; a workflow_dispatch from any
|
|
174
|
+
# other branch publishes a PREVIEW deployment of that branch, never over
|
|
175
|
+
# production.
|
|
176
|
+
- name: Deploy to Cloudflare Pages
|
|
177
|
+
env:
|
|
178
|
+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
179
|
+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
180
|
+
NORSK_DOCS_SITE_PROJECT: ${{ vars.NORSK_DOCS_SITE_PROJECT }}
|
|
181
|
+
PAGES_BRANCH: ${{ github.ref_name }}
|
|
182
|
+
run: |
|
|
183
|
+
set -euo pipefail
|
|
184
|
+
if [ -z "${NORSK_DOCS_SITE_PROJECT:-}" ]; then
|
|
185
|
+
origin="$(sed -nE 's/.*PUBLIC_SITE_ORIGIN = "([^"]+)".*/\1/p' website/src/lib/site.ts | head -1)"
|
|
186
|
+
echo "── Docs site → SKIPPED (no Pages project configured) ──"
|
|
187
|
+
echo " The site built (website/dist-public) but NORSK_DOCS_SITE_PROJECT is unset, so"
|
|
188
|
+
echo " nothing is deployed. To turn publishing on:"
|
|
189
|
+
echo " 1. Create a Cloudflare Pages project for this site (e.g. $(basename "$GITHUB_REPOSITORY")-docs)."
|
|
190
|
+
echo " 2. Add its custom domain${origin:+ (${origin#https://})} and the DNS record."
|
|
191
|
+
echo " 3. Make sure CLOUDFLARE_API_TOKEN (Pages:Edit) covers that project, and that"
|
|
192
|
+
echo " CLOUDFLARE_ACCOUNT_ID is set as a repo secret."
|
|
193
|
+
echo " 4. Set the repo variable NORSK_DOCS_SITE_PROJECT to the project name."
|
|
194
|
+
echo " Nothing in this workflow changes. See @norskvideo/ctl-dev-kit conventions/docs-site.md, Publishing."
|
|
195
|
+
exit 0
|
|
196
|
+
fi
|
|
197
|
+
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
|
|
198
|
+
echo "::error::NORSK_DOCS_SITE_PROJECT=$NORSK_DOCS_SITE_PROJECT is set, but CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must both be repo secrets"
|
|
199
|
+
exit 1
|
|
200
|
+
fi
|
|
201
|
+
cf_verify() {
|
|
202
|
+
curl -fsS --retry 3 --retry-all-errors --retry-delay 2 \
|
|
203
|
+
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
|
|
204
|
+
"https://api.cloudflare.com/client/v4/$1" 2>&1
|
|
205
|
+
}
|
|
206
|
+
if verify_body=$(cf_verify "user/tokens/verify"); then
|
|
207
|
+
token_kind="user-owned"
|
|
208
|
+
elif verify_body=$(cf_verify "accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify"); then
|
|
209
|
+
token_kind="account-owned"
|
|
210
|
+
else
|
|
211
|
+
echo "::error::Cloudflare token verification failed at both /user/tokens/verify and /accounts/<id>/tokens/verify. Last reply: $verify_body"
|
|
212
|
+
exit 1
|
|
213
|
+
fi
|
|
214
|
+
if ! echo "$verify_body" | grep -q '"status":"active"'; then
|
|
215
|
+
echo "::error::Cloudflare token not active ($token_kind): $verify_body"
|
|
216
|
+
exit 1
|
|
217
|
+
fi
|
|
218
|
+
# The token id is an identifier, not a credential: it answers "which of
|
|
219
|
+
# our tokens is CI using?", which a write-only secret otherwise cannot.
|
|
220
|
+
token_id=$(echo "$verify_body" | sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p' | head -1)
|
|
221
|
+
echo "Cloudflare token: active ($token_kind${token_id:+, id ${token_id}})"
|
|
222
|
+
echo "── Docs site → Cloudflare Pages ($NORSK_DOCS_SITE_PROJECT, branch $PAGES_BRANCH) ──"
|
|
223
|
+
nix develop .#build --command bash -c '
|
|
224
|
+
set -euo pipefail
|
|
225
|
+
bunx wrangler@4 pages deploy website/dist-public \
|
|
226
|
+
--project-name "$NORSK_DOCS_SITE_PROJECT" \
|
|
227
|
+
--branch "$PAGES_BRANCH" \
|
|
228
|
+
--commit-dirty=true
|
|
229
|
+
'
|
|
230
|
+
- name: Reap the containers this job launched
|
|
231
|
+
# Whatever the tests did. A teardown cut short by a test timeout leaves
|
|
232
|
+
# its instance running on the shared Docker daemon (see JOB_REAP_STEP in
|
|
233
|
+
# conventions/workflow-shell.ts); everything this job launched mounts its
|
|
234
|
+
# own test-temp, so that is what goes.
|
|
235
|
+
if: always()
|
|
236
|
+
run: |
|
|
237
|
+
base="${NORSK_CTL_TEST_TMP:-}"
|
|
238
|
+
tt="$GITHUB_WORKSPACE/test-temp"
|
|
239
|
+
s=""
|
|
240
|
+
for c in $(docker ps -aq); do
|
|
241
|
+
src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
|
|
242
|
+
if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
|
|
243
|
+
done
|
|
244
|
+
if [ -n "$s" ]; then
|
|
245
|
+
echo "::warning::removing containers this job left running:$s"
|
|
246
|
+
docker rm -f $s
|
|
247
|
+
fi
|