@norskvideo/ctl-dev-kit 0.1.23 → 0.1.25

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.
@@ -218,6 +218,7 @@ export interface CanonicalBytes {
218
218
  flake: string;
219
219
  upgradeLatest: string;
220
220
  syncDevKit: string;
221
+ publishDocs: string;
221
222
  checks: string;
222
223
  biome: string;
223
224
  tsconfigBase: string;
@@ -359,6 +360,21 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftRe
359
360
  );
360
361
  }
361
362
 
363
+ // publish-docs.yml is OPTIONAL — a product carries it once it has an
364
+ // illustrated manual to publish nightly. Unlike the workflows above it has NO
365
+ // per-repo line: it is deliberately product-agnostic (per-repo bits live in
366
+ // scripts/doc-guide/regen-manual.sh + an optional prepull-extra.sh), so it is a
367
+ // verbatim byte-for-byte copy of the canonical.
368
+ const publishDocsPath = join(repoRoot, ".github", "workflows", "publish-docs.yml");
369
+ if (existsSync(publishDocsPath)) {
370
+ const actual = readFileSync(publishDocsPath, "utf8");
371
+ if (actual !== canonical.publishDocs) {
372
+ push(
373
+ `.github/workflows/publish-docs.yml has drifted from @norskvideo/ctl-dev-kit conventions/publish-docs.yml (${firstDiffLine(actual, canonical.publishDocs)}). ${RESYNC}`,
374
+ );
375
+ }
376
+ }
377
+
362
378
  return { ok: problems.length === 0, problems };
363
379
  }
364
380
 
@@ -369,6 +385,7 @@ if (import.meta.main) {
369
385
  flake: readFileSync(join(import.meta.dir, "..", "build", "flake.nix"), "utf8"),
370
386
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
371
387
  syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
388
+ publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
372
389
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
373
390
  biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
374
391
  tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"),
@@ -0,0 +1,133 @@
1
+ # The illustrated product manual (shared across all Norsk ctl product repos)
2
+
3
+ How a Norsk ctl product builds, writes, and publishes its **illustrated manual** —
4
+ the self-contained HTML page a prospect or operator reads to understand what the
5
+ product does. This is the shared half; the per-product half is the manual's
6
+ actual page content, which lives in the product repo.
7
+
8
+ Like [`personas.md`](./personas.md) this is a **shipped reference**, not a
9
+ drift-gated copy: consume it from
10
+ `node_modules/@norskvideo/ctl-dev-kit/conventions/docs.md`. The two things that
11
+ ARE single-sourced and gated are the workflow (`publish-docs.yml`) and the
12
+ toolchain library (`@norskvideo/ctl-dev-kit/doc-guide`); this file is the
13
+ convention that ties them together.
14
+
15
+ ## What the manual is (and is not)
16
+
17
+ The manual is one hash-routed HTML page assembled from **two cross-linked
18
+ collections**, plus an overview:
19
+
20
+ - **Functionality pages** — one capability per page (remote talent, live mixing,
21
+ output shapes, …). "What the product does."
22
+ - **Example-deployment pages** — worked shows, each a concrete shape a reader can
23
+ recognise as close to their own. "What a real deployment looks like." Named as
24
+ starting points, never as a fixed menu or a tier ladder.
25
+ - **Overview** — a hero capture and two ways in: by function, or by example.
26
+
27
+ It is NOT the reference (`CommentaryConfig` / CI author material — that stays in
28
+ `docs/reference.md` + `examples/`), NOT the operator runbook (host footprint,
29
+ limitations — mostly ctl's), and NOT a feature checklist. It is the
30
+ capability-first, illustrated front door.
31
+
32
+ ### Who reads it
33
+
34
+ Name the reader before writing a page (see `personas.md` and the product's own
35
+ `docs/personas.md`):
36
+
37
+ - **Evaluator** — the overview, the example gallery, first light. Wants "does
38
+ this do what I need?" in minutes.
39
+ - **Builder** — the illustrated task pages. The domain-fluent operator who stands
40
+ the show up and runs it.
41
+
42
+ Integrator and Operator are served elsewhere (reference / ctl). Runtime actors
43
+ (a commentator, a contributor, a producer) are NOT readers of the manual — the
44
+ operator is the Builder; the others receive join links and onboarding kit.
45
+
46
+ ## Voice
47
+
48
+ Customer- and Builder-facing, plain, capability-led. The rules that keep every
49
+ product's manual in one voice:
50
+
51
+ - **No internal/test vocabulary.** Never `engine`, `fixture`, `Playwright`,
52
+ `assertion`, `harness`, `instance`, `SSR`, a slug, or a DOM id in reader-facing
53
+ copy. The reader does not know or care how the shot was produced.
54
+ - **Lead with the capability, not the UI state.** "A commentator joins from a
55
+ browser with a link" — not "the connections page shows a slot in the pending
56
+ state."
57
+ - **Plain, not markety.** Describe what it does; skip the superlatives.
58
+ - **No floor / ceiling / max / tier framing.** A deployment is described by its
59
+ shape ("compose and two delivery groups"), never as "the maximal build" or "the
60
+ ceiling" — the ceiling is wherever the customer needs it to be. Internal example
61
+ slugs (e.g. `commentary-max`) may keep the word; the copy a reader sees must not.
62
+ - **Examples are starting points.** "Worked deployments — the pieces combine
63
+ freely; any show is described in config," not a menu the reader must pick from.
64
+
65
+ ## Captures
66
+
67
+ Every screenshot is a REAL capture written by a doc-guide driving a real surface
68
+ that the guide also asserts — so a shot can never depict a UI that does not
69
+ exist. A guide runs at one of three tiers:
70
+
71
+ - **Fixture** — the product's Vite app booted alone, fed representative state (no
72
+ daemon, no license). Proves the UI renders correctly. Fast; runs in CI.
73
+ - **Frontend** — the standalone configure form, same model.
74
+ - **Engine** — a REAL launched instance, driven live (On Air actually lighting
75
+ up, a real feed composited). Slow, licensed, nightly.
76
+
77
+ Per-step treatments (`CaptureTreatment`, from the doc-guide library):
78
+
79
+ - **full page** (default) — the whole surface, for orientation shots.
80
+ - **`crop`** — a tight element screenshot of one component, by CSS selector, for
81
+ a close-up. Anchor crops on a stable `data-region` / `data-*` attribute the
82
+ component renders, not on incidental structure.
83
+ - **`spotlight`** — the whole screen with one region lit and everything else
84
+ dimmed, for "look here, in context."
85
+
86
+ ## The toolchain
87
+
88
+ All single-sourced in `@norskvideo/ctl-dev-kit/doc-guide` — products import it,
89
+ they do not copy it:
90
+
91
+ - `@norskvideo/ctl-dev-kit/doc-guide` — `DocGuide` (a guide narrates itself into
92
+ `docs/generated/<slug>/`), `screenshotFor`, `CaptureTreatment`. Writes under
93
+ `NORSK_DOCS_ROOT` (the repo-level `docs/generated`); the config factory sets it.
94
+ - `@norskvideo/ctl-dev-kit/doc-guide/guides-config` — `guidesConfig({ port,
95
+ docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
96
+ the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
97
+ - `@norskvideo/ctl-dev-kit/doc-guide/build-manual` — `buildManual(spec, {
98
+ docsRoot })`, the generic assembler. The product supplies ONLY the page content
99
+ (`ManualSpec`: brand, overview, functionality[], examples[]); layout, CSS and
100
+ routing live in the assembler so every product's manual looks the same.
101
+ - `@norskvideo/ctl-dev-kit/doc-guide/{instance-proxy,live-browser}` — engine-tier
102
+ support (serve the baked dashboard under its advertised instance prefix; launch
103
+ the nix chromium for a live capture).
104
+ - `doc-guide/with-display.sh` — wrap the run in an X display for headless chromium
105
+ on a true-headless Linux box. Copy it to the repo's `scripts/` (it must be a
106
+ repo-local executable the package.json guide scripts invoke).
107
+
108
+ Per-repo, the product provides: its guide suites, a thin `build-manual.ts` that
109
+ supplies the `ManualSpec` and does the file IO, and
110
+ `scripts/doc-guide/regen-manual.sh` — the one command a release runs to drive
111
+ every tier with capture-writing on, then assemble. `regen-manual.sh` owns the
112
+ slug list and which tiers run (set `SKIP_ENGINE=1` to regenerate only the fast
113
+ tiers). An engine tier needing images beyond the universal media+studio pair
114
+ pulls them in an optional `scripts/doc-guide/prepull-extra.sh`.
115
+
116
+ ## The publish contract
117
+
118
+ Latest-only, republished by ctl:
119
+
120
+ - Each product's nightly (`publish-docs.yml`, single-sourced + drift-gated)
121
+ rebuilds the manual to ONE self-contained `manual.html` and publishes it as the
122
+ asset of a **moving `docs-latest` GitHub Release** — never versioned, its single
123
+ asset clobbered each night. Stable URL:
124
+ `https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`.
125
+ - **norsk-ctl's docs build fetches each registered product's `docs-latest` asset
126
+ and mounts it at `/products/<slug>/`**, deploying the one Cloudflare Pages
127
+ project — so ctl republishes every product's manual whenever it builds its docs.
128
+ A true per-product subdir on ctl's site only works by ctl aggregating, because a
129
+ Pages deploy is whole-site.
130
+
131
+ Two files back the contract: `manual.html` (the standalone document — publish
132
+ THIS, not the Artifact fragment `index.html`) and, on the ctl side, the product
133
+ registry the aggregator reads.
@@ -0,0 +1,141 @@
1
+ # Nightly: regenerate this product's illustrated manual and publish it as the
2
+ # product's "latest docs" artifact, for the norsk-ctl docs site to republish.
3
+ #
4
+ # THE CROSS-PRODUCT PATTERN (each product does this; norsk-ctl aggregates):
5
+ # - Every product's nightly rebuilds its manual to ONE self-contained
6
+ # `manual.html` and publishes it as the asset of a MOVING `docs-latest`
7
+ # GitHub Release. Latest-only: the release is never versioned, its single
8
+ # asset is clobbered each night. Stable URL:
9
+ # https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html
10
+ # - norsk-ctl's docs build fetches each registered product's `docs-latest`
11
+ # asset and mounts it under `/products/<slug>/`, deploying the one
12
+ # Cloudflare Pages project — so ctl republishes every product's manual
13
+ # whenever it builds its docs.
14
+ #
15
+ # This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
16
+ # (conventions/publish-docs.yml) and copied verbatim, same as checks.yml. It is
17
+ # deliberately product-agnostic: everything per-repo lives in repo-local scripts
18
+ # it calls —
19
+ # - scripts/doc-guide/regen-manual.sh : the guide tiers + slug list + assemble
20
+ # - scripts/doc-guide/prepull-extra.sh : OPTIONAL, any images beyond media+studio
21
+ # so the drift gate can keep every product's copy byte-identical.
22
+ #
23
+ # Nightly-only by design: a guide's engine tier stands up real instances (slow,
24
+ # and subject to teardown flakes), so it does not ride every push. The
25
+ # licensed-runner setup mirrors integration.yml — ctl comes from the released
26
+ # binary via NORSK_CTL_BINARY, Docker is used directly on the x64 pool.
27
+ name: publish-docs
28
+
29
+ on:
30
+ schedule:
31
+ - cron: "30 1 * * *" # 01:30 UTC — ahead of ctl's nightly docs build
32
+ workflow_dispatch:
33
+
34
+ permissions:
35
+ contents: write # move the docs-latest release + clobber its asset
36
+
37
+ concurrency:
38
+ group: publish-docs-${{ github.ref }}
39
+ cancel-in-progress: true
40
+
41
+ jobs:
42
+ publish:
43
+ runs-on: x64
44
+ steps:
45
+ # A guide's engine tier launches the same docker instances the integration
46
+ # suite does, leaving root-owned bind-mount target dirs under test-temp/
47
+ # that the non-root runner can't remove — which fails actions/checkout's
48
+ # own cleanup before anything runs. Nuke them from a throwaway root
49
+ # container first. Best-effort. (Mirrors integration.yml.)
50
+ - name: Clear stale root-owned test-temp (pre-checkout)
51
+ run: |
52
+ set -uo pipefail
53
+ tt="$GITHUB_WORKSPACE/test-temp"
54
+ [ -d "$tt" ] || exit 0
55
+ docker run --rm --user 0:0 -v "$tt":/t alpine sh \
56
+ -c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
57
+
58
+ - uses: actions/checkout@v5
59
+ with:
60
+ clean: false
61
+
62
+ - name: Write the Norsk license (from the org secret)
63
+ env:
64
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
65
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
66
+
67
+ - name: Download the released norsk-ctl binary (latest channel)
68
+ run: |
69
+ set -euo pipefail
70
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
71
+ ver="$(curl -fsSL "$S3/latest")"
72
+ echo "norsk-ctl latest channel -> $ver"
73
+ curl -fsSL "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
74
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
75
+ "$RUNNER_TEMP/norsk-ctl" --version || true
76
+
77
+ # Cold-runner first pulls overrun the harness's per-test launch timeouts;
78
+ # pre-pull the universal media + studio images (every product pins them in
79
+ # manifest.seed.json) so compose/run hit local images. A product needing
80
+ # more (e.g. a WHIP driver) pulls them in scripts/doc-guide/prepull-extra.sh.
81
+ - name: Pre-pull the media + studio images
82
+ run: |
83
+ set -euo pipefail
84
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
85
+ echo "pre-pulling $img"
86
+ docker pull "$img"
87
+ done
88
+ if [ -x scripts/doc-guide/prepull-extra.sh ]; then
89
+ echo "running scripts/doc-guide/prepull-extra.sh"
90
+ ./scripts/doc-guide/prepull-extra.sh
91
+ fi
92
+
93
+ # Rebuild every workspace the manual depends on (a guide's engine tier packs
94
+ # the freshly-built dashboard + frontend dist into the launched template),
95
+ # then run the product's doc-guide tiers and assemble the manual.
96
+ # regen-manual.sh owns the slug list and which tiers run; with-display.sh
97
+ # wraps the whole run in an X display for headless chromium (the engine tier
98
+ # drives a raw chromium that needs one).
99
+ - name: Regenerate the illustrated manual (all tiers)
100
+ env:
101
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
102
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
103
+ # This runner launches Studio as a HOST sibling (DooD), so the harness
104
+ # reaches host-published ports via the host-gateway alias, not
105
+ # localhost — same as the integration suite.
106
+ NORSK_TEST_HOST: host.docker.internal
107
+ run: |
108
+ nix develop .#build --command bash -c '
109
+ set -euo pipefail
110
+ # clean:false persists node_modules between runs for speed, but a
111
+ # workspace dep whose version moved leaves stale copies that
112
+ # --frozen-lockfile does not reliably relink, so a new export is "not
113
+ # found". Nuke EVERY node_modules — including nested per-workspace ones
114
+ # — for a deterministic install. A nightly can afford it.
115
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
116
+ bun install --frozen-lockfile
117
+ bun run build:no-lint
118
+ bash scripts/with-display.sh scripts/doc-guide/regen-manual.sh
119
+ '
120
+
121
+ # Publish the assembled manual as the moving docs-latest release asset.
122
+ # gh is on PATH inside the nix shell; `upload --clobber` replaces the
123
+ # same-named asset in place, so the release stays latest-only.
124
+ - name: Publish manual.html to the moving docs-latest release
125
+ env:
126
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127
+ run: |
128
+ nix develop .#build --command bash -c '
129
+ set -euo pipefail
130
+ # Publish the STANDALONE document (complete <!doctype>…</html>), not
131
+ # the Artifact fragment index.html — ctl hosts it directly as a page.
132
+ man="docs/generated/manual/manual.html"
133
+ [ -s "$man" ] || { echo "::error::manual was not generated at $man"; exit 1; }
134
+ cp "$man" "$RUNNER_TEMP/manual.html"
135
+ if ! gh release view docs-latest >/dev/null 2>&1; then
136
+ gh release create docs-latest --title "Docs (latest)" --prerelease \
137
+ --notes "Latest product manual, rebuilt nightly and republished by the norsk-ctl docs site. Not a versioned release — the manual.html asset is clobbered each night."
138
+ fi
139
+ gh release upload docs-latest "$RUNNER_TEMP/manual.html" --clobber
140
+ echo "published -> docs-latest/manual.html"
141
+ '
@@ -153,6 +153,13 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
153
153
  syncWorkflow(repoRoot, ".github/workflows/upgrade-latest.yml", canonical.upgradeLatest, r);
154
154
  syncWorkflow(repoRoot, ".github/workflows/sync-dev-kit.yml", canonical.syncDevKit, r);
155
155
 
156
+ // publish-docs.yml is optional and verbatim (no per-repo line) — only re-sync a
157
+ // repo that already carries it, unlike biome/tsconfig which every repo must have.
158
+ const publishDocsRel = ".github/workflows/publish-docs.yml";
159
+ if (existsSync(join(repoRoot, publishDocsRel))) {
160
+ writeIfChanged(join(repoRoot, publishDocsRel), canonical.publishDocs, publishDocsRel, r.written);
161
+ }
162
+
156
163
  syncFlake(repoRoot, canonical.flake, r);
157
164
  syncClaude(repoRoot, canonical.core, r);
158
165
  syncGitignore(repoRoot, canonical.gitignoreCore, r);
@@ -169,6 +176,7 @@ if (import.meta.main) {
169
176
  flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"),
170
177
  upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
171
178
  syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
179
+ publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
172
180
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
173
181
  biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
174
182
  tsconfigBase: readFileSync(join(dir, "tsconfig.base.json"), "utf8"),
@@ -0,0 +1,56 @@
1
+ export type Step = {
2
+ slug: string;
3
+ file: string;
4
+ head: string;
5
+ desc: string;
6
+ };
7
+ export type ManualPage = {
8
+ id: string;
9
+ nav: string;
10
+ title: string;
11
+ kind: "fn" | "ex";
12
+ tag?: string;
13
+ intro: string;
14
+ steps: Step[];
15
+ links: string[];
16
+ thin?: boolean;
17
+ };
18
+ export interface ManualOverview {
19
+ /** The overview <h1>. */
20
+ headline: string;
21
+ /** The lead paragraph under it. */
22
+ lead: string;
23
+ /** The hero capture (a representative shot) and its caption. */
24
+ hero: {
25
+ slug: string;
26
+ file: string;
27
+ alt: string;
28
+ caption: string;
29
+ };
30
+ /** Left column heading of the two-up index. Default "By function". */
31
+ byFunctionHeading?: string;
32
+ /** Right column heading. Default "By example". */
33
+ byExampleHeading?: string;
34
+ /** The small print under the right column heading. */
35
+ byExampleIntro: string;
36
+ }
37
+ export interface ManualSpec {
38
+ /** Sidebar wordmark + document brand. */
39
+ brand: string;
40
+ /** The full <title> for both the fragment and the standalone document. */
41
+ title: string;
42
+ overview: ManualOverview;
43
+ functionality: ManualPage[];
44
+ examples: ManualPage[];
45
+ }
46
+ export interface BuildManualResult {
47
+ /** Artifact-ready fragment (no doctype/head/body). */
48
+ indexHtml: string;
49
+ /** Complete standalone document wrapping the fragment. */
50
+ standaloneHtml: string;
51
+ /** `<slug>/<file>` of every referenced capture not found on disk. */
52
+ missing: string[];
53
+ }
54
+ export declare function buildManual(spec: ManualSpec, opts: {
55
+ docsRoot: string;
56
+ }): BuildManualResult;
@@ -0,0 +1,183 @@
1
+ // Generic assembler for a ctl product's illustrated manual: two cross-linked
2
+ // collections (functionality pages + example-deployment pages), each a step
3
+ // walkthrough, rendered into one self-contained, hash-routed HTML page.
4
+ //
5
+ // Every screenshot is a real capture a doc-guide wrote under
6
+ // <docsRoot>/<slug>/<slug>-00N.png. This assembler reads those PNGs, resizes each
7
+ // with ImageMagick, inlines it as a data URI, and returns both the Artifact
8
+ // FRAGMENT (index.html — the Artifact tool supplies the doctype/head/body) and a
9
+ // complete STANDALONE document (manual.html — for hosting the manual directly as
10
+ // a page). A referenced shot that hasn't been generated yet renders as a labelled
11
+ // gap rather than crashing the build; every gap is reported in `missing`.
12
+ //
13
+ // Product-specific content — the page arrays, the overview copy, the brand — is
14
+ // supplied by the caller in `ManualSpec`; everything structural (layout, CSS,
15
+ // routing) lives here so every product's manual looks and behaves the same.
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
+ /** Resize a captured PNG to <=1100px wide JPEG and return a data URI. Missing
21
+ * captures become a labelled placeholder so the manual still builds. */
22
+ function makeUri(docsRoot, slug, file, missing) {
23
+ const path = join(docsRoot, slug, file);
24
+ if (!existsSync(path)) {
25
+ missing.push(`${slug}/${file}`);
26
+ const label = esc(`missing: ${slug}/${file}`);
27
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="880" height="200"><rect width="100%" height="100%" fill="#1a2230"/><text x="50%" y="50%" fill="#6b7787" font-family="monospace" font-size="15" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
28
+ return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
29
+ }
30
+ const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
31
+ maxBuffer: 64 * 1024 * 1024,
32
+ });
33
+ if (r.status !== 0 || !r.stdout?.length) {
34
+ throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
35
+ }
36
+ return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
37
+ }
38
+ export function buildManual(spec, opts) {
39
+ const { docsRoot } = opts;
40
+ const missing = [];
41
+ const uri = (slug, file) => makeUri(docsRoot, slug, file, missing);
42
+ const FN = spec.functionality;
43
+ const EX = spec.examples;
44
+ const ALL = [...FN, ...EX];
45
+ const byId = new Map(ALL.map((p) => [p.id, p]));
46
+ function pageHtml(p) {
47
+ const steps = p.steps
48
+ .map((s, i) => `<li class="step">
49
+ <div class="stinfo"><span class="stnum">${String(i + 1).padStart(2, "0")}</span><div><h3>${esc(s.head)}</h3><p>${esc(s.desc)}</p></div></div>
50
+ <div class="shot"><img loading="lazy" src="${uri(s.slug, s.file)}" alt="${esc(s.head)}"></div>
51
+ </li>`)
52
+ .join("\n");
53
+ const links = p.links.length
54
+ ? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
55
+ .map((id) => {
56
+ const t = byId.get(id);
57
+ return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
58
+ })
59
+ .join("")}</div>`
60
+ : "";
61
+ const thin = p.thin
62
+ ? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
63
+ : "";
64
+ return `<article class="page" id="${p.id}">
65
+ <div class="phead"><span class="kind ${p.kind}">${p.kind === "fn" ? "Functionality" : "Example deployment"}</span><h1>${esc(p.title)}</h1>${p.tag ? `<div class="ptag mono">${esc(p.tag)}</div>` : ""}</div>
66
+ <p class="lead">${esc(p.intro)}</p>
67
+ <ol class="steps">${steps}</ol>
68
+ ${thin}${links}
69
+ </article>`;
70
+ }
71
+ const ov = spec.overview;
72
+ const overview = `<article class="page" id="overview">
73
+ <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
74
+ <p class="lead">${esc(ov.lead)}</p>
75
+ <div class="heroshot"><div class="shot"><img src="${uri(ov.hero.slug, ov.hero.file)}" alt="${esc(ov.hero.alt)}"></div><p class="hcap">${esc(ov.hero.caption)}</p></div>
76
+ <div class="twocol">
77
+ <div><h2>${esc(ov.byFunctionHeading ?? "By function")}</h2><div class="linklist">${FN.map((p) => `<a href="#${p.id}">${esc(p.nav)}</a>`).join("")}</div></div>
78
+ <div><h2>${esc(ov.byExampleHeading ?? "By example")}</h2><p class="mini">${esc(ov.byExampleIntro)}</p><div class="linklist">${EX.map((p) => `<a href="#${p.id}">${esc(p.nav)}</a>`).join("")}</div></div>
79
+ </div>
80
+ </article>`;
81
+ const nav = `
82
+ <a href="#overview" data-nav class="ovlink">Overview</a>
83
+ <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
84
+ <div class="ngroup"><div class="ghead">Examples <span class="ct">${EX.length}</span></div>${EX.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>`;
85
+ const html = `<title>${esc(spec.title)}</title>
86
+ <style>
87
+ :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
88
+ --line:#212a36;--accent:#54d6cf;--measure:64ch}
89
+ @media (prefers-color-scheme:light){:root{--bg:#f5f7f9;--bg2:#eef1f4;--surf:#fff;--ink:#0f1620;
90
+ --dim:#54606e;--faint:#8c96a3;--line:#e2e7ec;--accent:#0c8f86}}
91
+ :root[data-theme="dark"]{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;
92
+ --faint:#5f6a79;--line:#212a36;--accent:#54d6cf}
93
+ :root[data-theme="light"]{--bg:#f5f7f9;--bg2:#eef1f4;--surf:#fff;--ink:#0f1620;--dim:#54606e;
94
+ --faint:#8c96a3;--line:#e2e7ec;--accent:#0c8f86}
95
+ *{box-sizing:border-box}
96
+ body{margin:0;background:var(--bg);color:var(--ink);line-height:1.6;
97
+ font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
98
+ .mono{font-family:ui-monospace,"SF Mono",Menlo,monospace}
99
+ .wrap{display:grid;grid-template-columns:264px minmax(0,1fr);max-width:1400px;margin:0 auto}
100
+ aside{position:sticky;top:0;align-self:start;height:100vh;overflow-y:auto;padding:26px 18px 48px;
101
+ border-right:1px solid var(--line);background:var(--bg2)}
102
+ .brand{font-family:ui-monospace,Menlo,monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:22px}
103
+ aside a{display:block;text-decoration:none;color:var(--dim);padding:6px 10px;border-radius:7px;font-size:13px;line-height:1.35}
104
+ aside a:hover{background:var(--surf);color:var(--ink)}
105
+ aside a.active{background:var(--surf);color:var(--ink);box-shadow:inset 2px 0 0 var(--accent)}
106
+ .ovlink{margin-bottom:16px;font-weight:600;color:var(--ink)}
107
+ .ngroup{margin-bottom:20px}
108
+ .ghead{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin:0 10px 6px;display:flex;gap:8px;align-items:center}
109
+ .ct{background:var(--surf);border:1px solid var(--line);border-radius:20px;padding:0 6px;font-size:9.5px;color:var(--dim)}
110
+ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
111
+ .page{display:none;max-width:840px;padding:52px 0 88px;animation:fade .18s ease}
112
+ .page.on{display:block}
113
+ @keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
114
+ @media (prefers-reduced-motion:reduce){.page{animation:none}}
115
+ .phead{margin-bottom:20px}
116
+ .kind{display:inline-block;font-family:ui-monospace,Menlo,monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:12px}
117
+ .kind.ex{color:var(--dim)}.kind.ov{color:var(--faint)}
118
+ .phead h1{font-size:clamp(26px,4vw,42px);line-height:1.05;letter-spacing:-.028em;margin:0;font-weight:730;text-wrap:balance}
119
+ .ptag{margin-top:12px;font-size:12px;color:var(--faint)}
120
+ .lead{font-size:clamp(15px,1.9vw,18px);color:var(--dim);max-width:var(--measure);margin:0 0 8px}
121
+
122
+ .steps{list-style:none;margin:34px 0 0;padding:0}
123
+ .step{padding:28px 0;border-top:1px solid var(--line)}
124
+ .stinfo{display:flex;gap:16px;align-items:baseline;margin-bottom:16px;max-width:var(--measure)}
125
+ .stnum{font-family:ui-monospace,Menlo,monospace;font-size:13px;color:var(--accent);flex:none;padding-top:2px}
126
+ .stinfo h3{font-size:17px;margin:0 0 4px;letter-spacing:-.01em}
127
+ .stinfo p{margin:0;color:var(--dim);font-size:14.5px}
128
+ .shot{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#000;box-shadow:0 22px 46px -32px rgba(0,0,0,.8)}
129
+ .shot img{display:block;max-width:100%;height:auto;margin:0 auto}
130
+ .heroshot{margin-bottom:36px}
131
+ .heroshot .shot{border-color:color-mix(in srgb,var(--accent) 24%,var(--line))}
132
+ .hcap{margin:12px 0 0;color:var(--dim);font-size:13px;max-width:56ch}
133
+
134
+ .thin{margin-top:26px;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--faint);
135
+ background:var(--surf);border:1px dashed var(--line);border-radius:9px;padding:11px 14px;max-width:var(--measure)}
136
+ .xlinks{margin-top:36px;padding-top:22px;border-top:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
137
+ .xlab{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-right:4px}
138
+ .chip{text-decoration:none;font-size:13px;color:var(--ink);background:var(--surf);border:1px solid var(--line);border-radius:20px;padding:6px 13px}
139
+ .chip:hover{border-color:var(--accent);color:var(--accent)}
140
+ .twocol{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:40px;padding-top:30px;border-top:1px solid var(--line)}
141
+ .twocol h2{font-size:15px;margin:0 0 12px}
142
+ .mini{font-size:12.5px;color:var(--faint);margin:0 0 12px;max-width:40ch}
143
+ .linklist{display:flex;flex-direction:column;gap:2px}
144
+ .linklist a{text-decoration:none;color:var(--dim);font-size:14px;padding:5px 0}
145
+ .linklist a:hover{color:var(--accent)}
146
+ @media (max-width:880px){.wrap{grid-template-columns:1fr}
147
+ aside{position:static;height:auto;border-right:none;border-bottom:1px solid var(--line)}
148
+ .twocol{grid-template-columns:1fr}}
149
+ </style>
150
+
151
+ <div class="wrap">
152
+ <aside><div class="brand">${esc(spec.brand)}</div>${nav}</aside>
153
+ <main>
154
+ ${overview}
155
+ ${ALL.map(pageHtml).join("\n")}
156
+ </main>
157
+ </div>
158
+
159
+ <script>
160
+ (function(){
161
+ var pages=[].slice.call(document.querySelectorAll('.page'));
162
+ var links=[].slice.call(document.querySelectorAll('[data-nav]'));
163
+ function show(){var id=(location.hash||'#overview').slice(1);
164
+ if(!document.getElementById(id))id='overview';
165
+ pages.forEach(function(p){p.classList.toggle('on',p.id===id)});
166
+ links.forEach(function(a){a.classList.toggle('active',a.getAttribute('href')==='#'+id)});
167
+ window.scrollTo(0,0);}
168
+ window.addEventListener('hashchange',show);show();
169
+ })();
170
+ </script>`;
171
+ const standaloneHtml = `<!doctype html>
172
+ <html lang="en">
173
+ <head>
174
+ <meta charset="utf-8" />
175
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
176
+ <title>${esc(spec.title)}</title>
177
+ </head>
178
+ <body>
179
+ ${html}
180
+ </body>
181
+ </html>`;
182
+ return { indexHtml: html, standaloneHtml, missing };
183
+ }
@@ -0,0 +1,57 @@
1
+ import type { Page } from "@playwright/test";
2
+ export type UiEntry = {
3
+ kind: "section";
4
+ heading: string;
5
+ } | {
6
+ kind: "step";
7
+ heading: string;
8
+ description?: string;
9
+ } | {
10
+ kind: "text";
11
+ prose: string;
12
+ } | {
13
+ kind: "capture";
14
+ caption: string;
15
+ filename: string;
16
+ };
17
+ export interface UiFixture {
18
+ type: "ui";
19
+ title: string;
20
+ entries: UiEntry[];
21
+ }
22
+ /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
23
+ * relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
24
+ * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
25
+ * config that sets the env var at eval time is honoured. */
26
+ export declare function docsRoot(): string;
27
+ /** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
28
+ * `spotlight` keeps the whole screen for context but dims everything except the
29
+ * named region and outlines it. Omit both for a plain full-page capture. */
30
+ export interface CaptureTreatment {
31
+ crop?: string;
32
+ spotlight?: string;
33
+ /** Slack in px around a spotlight region before the dimming starts. */
34
+ pad?: number;
35
+ }
36
+ /** Produce the screenshot bytes for a capture, applying its treatment. Split out
37
+ * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
38
+ * so the treatment geometry is testable on its own. */
39
+ export declare function screenshotFor(page: Page, treat?: CaptureTreatment): Promise<Buffer>;
40
+ /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
41
+ * stored entries.json without re-running the browser. */
42
+ export declare function renderUiMarkdown(title: string, entries: UiEntry[]): string;
43
+ export declare class DocGuide {
44
+ private slug;
45
+ private title;
46
+ private entries;
47
+ private captureIndex;
48
+ constructor(slug: string, title: string);
49
+ section(heading: string): void;
50
+ step(heading: string, description?: string): void;
51
+ text(prose: string): void;
52
+ /** Screenshot the page and write it immediately under the slug's docs dir. Pass
53
+ * a treatment to crop to one component or spotlight a region in context. */
54
+ capture(page: Page, caption: string, treat?: CaptureTreatment): Promise<void>;
55
+ /** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
56
+ flush(): Promise<void>;
57
+ }
@@ -0,0 +1,128 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ /** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
4
+ * relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
5
+ * whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
6
+ * config that sets the env var at eval time is honoured. */
7
+ export function docsRoot() {
8
+ const override = process.env.NORSK_DOCS_ROOT;
9
+ if (override)
10
+ return isAbsolute(override) ? override : resolve(process.cwd(), override);
11
+ return resolve(process.cwd(), "docs/generated");
12
+ }
13
+ const SPOTLIGHT_ID = "docguide-spotlight";
14
+ /** Dim the page except the target region, drawn as one fixed box whose oversized
15
+ * box-shadow does the dimming — appended to <body> so no ancestor `overflow`
16
+ * clips it. Scrolls the target into view first so the lit region is on-screen. */
17
+ async function spotlightOn(page, selector, pad) {
18
+ await page.locator(selector).first().scrollIntoViewIfNeeded();
19
+ await page.evaluate(({ selector, pad, id }) => {
20
+ const el = document.querySelector(selector);
21
+ if (!el)
22
+ throw new Error(`spotlight target not found: ${selector}`);
23
+ const r = el.getBoundingClientRect();
24
+ const box = document.createElement("div");
25
+ box.id = id;
26
+ Object.assign(box.style, {
27
+ position: "fixed",
28
+ left: `${Math.max(0, r.left - pad)}px`,
29
+ top: `${Math.max(0, r.top - pad)}px`,
30
+ width: `${r.width + pad * 2}px`,
31
+ height: `${r.height + pad * 2}px`,
32
+ border: "2px solid #54d6cf",
33
+ borderRadius: "10px",
34
+ boxShadow: "0 0 0 4000px rgba(6,9,13,0.62)",
35
+ zIndex: "2147483647",
36
+ pointerEvents: "none",
37
+ });
38
+ document.body.appendChild(box);
39
+ }, { selector, pad, id: SPOTLIGHT_ID });
40
+ }
41
+ async function spotlightOff(page) {
42
+ await page.evaluate((id) => document.getElementById(id)?.remove(), SPOTLIGHT_ID);
43
+ }
44
+ /** Produce the screenshot bytes for a capture, applying its treatment. Split out
45
+ * from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
46
+ * so the treatment geometry is testable on its own. */
47
+ export async function screenshotFor(page, treat) {
48
+ // Wait for web fonts, else the snapshot catches a fallback font mid-swap.
49
+ await page.evaluate(() => document.fonts.ready);
50
+ if (treat?.crop)
51
+ return page.locator(treat.crop).first().screenshot();
52
+ if (treat?.spotlight) {
53
+ await spotlightOn(page, treat.spotlight, treat.pad ?? 8);
54
+ try {
55
+ return await page.screenshot();
56
+ }
57
+ finally {
58
+ await spotlightOff(page);
59
+ }
60
+ }
61
+ return page.screenshot({ fullPage: true });
62
+ }
63
+ /** Pure markdown assembly — exposed so a render step can re-emit markdown from a
64
+ * stored entries.json without re-running the browser. */
65
+ export function renderUiMarkdown(title, entries) {
66
+ const lines = ["---", `title: ${title}`, "---", "", `# ${title}`, ""];
67
+ for (const entry of entries) {
68
+ switch (entry.kind) {
69
+ case "section":
70
+ lines.push(`## ${entry.heading}`, "");
71
+ break;
72
+ case "step":
73
+ lines.push(`### ${entry.heading}`, "");
74
+ if (entry.description)
75
+ lines.push(entry.description, "");
76
+ break;
77
+ case "text":
78
+ lines.push(entry.prose, "");
79
+ break;
80
+ case "capture":
81
+ lines.push(`![${entry.caption}](./${entry.filename})`, "");
82
+ break;
83
+ }
84
+ }
85
+ return lines.join("\n");
86
+ }
87
+ export class DocGuide {
88
+ slug;
89
+ title;
90
+ entries = [];
91
+ captureIndex = 0;
92
+ constructor(slug, title) {
93
+ this.slug = slug;
94
+ this.title = title;
95
+ }
96
+ section(heading) {
97
+ this.entries.push({ kind: "section", heading });
98
+ }
99
+ step(heading, description) {
100
+ this.entries.push({ kind: "step", heading, description });
101
+ }
102
+ text(prose) {
103
+ this.entries.push({ kind: "text", prose });
104
+ }
105
+ /** Screenshot the page and write it immediately under the slug's docs dir. Pass
106
+ * a treatment to crop to one component or spotlight a region in context. */
107
+ async capture(page, caption, treat) {
108
+ if (process.env.DOCS_GENERATE !== "1")
109
+ return;
110
+ this.captureIndex++;
111
+ const filename = `${this.slug}-${String(this.captureIndex).padStart(3, "0")}.png`;
112
+ const buffer = await screenshotFor(page, treat);
113
+ const outDir = join(docsRoot(), this.slug);
114
+ mkdirSync(outDir, { recursive: true });
115
+ writeFileSync(join(outDir, filename), buffer);
116
+ this.entries.push({ kind: "capture", caption, filename });
117
+ }
118
+ /** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
119
+ async flush() {
120
+ if (process.env.DOCS_GENERATE !== "1")
121
+ return;
122
+ const outDir = join(docsRoot(), this.slug);
123
+ mkdirSync(outDir, { recursive: true });
124
+ const fixture = { type: "ui", title: this.title, entries: this.entries };
125
+ writeFileSync(join(outDir, "entries.json"), `${JSON.stringify(fixture, null, 2)}\n`);
126
+ writeFileSync(join(outDir, "index.md"), renderUiMarkdown(this.title, this.entries));
127
+ }
128
+ }
@@ -0,0 +1,19 @@
1
+ import { type PlaywrightTestConfig } from "@playwright/test";
2
+ export interface GuidesConfigOptions {
3
+ /** Vite port. Pick one clear of the product's dev servers and sibling sessions. */
4
+ port: number;
5
+ /** Absolute path to the repo-level docs/generated dir. Exported as NORSK_DOCS_ROOT. */
6
+ docsRoot: string;
7
+ /** Capture viewport. Default 1440x960. */
8
+ viewport?: {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ /** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
13
+ command?: string;
14
+ /** Playwright testDir. Default "./tests/guides". */
15
+ testDir?: string;
16
+ /** Per-test timeout in ms. Default 60_000. */
17
+ timeout?: number;
18
+ }
19
+ export declare function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig;
@@ -0,0 +1,28 @@
1
+ import { defineConfig } from "@playwright/test";
2
+ export function guidesConfig(opts) {
3
+ // Exported before the test files import the dev-kit DocGuide, so its deferred
4
+ // docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
5
+ process.env.NORSK_DOCS_ROOT = opts.docsRoot;
6
+ const baseURL = `http://127.0.0.1:${opts.port}`;
7
+ const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
8
+ return defineConfig({
9
+ testDir: opts.testDir ?? "./tests/guides",
10
+ workers: 1,
11
+ timeout: opts.timeout ?? 60_000,
12
+ use: {
13
+ baseURL,
14
+ browserName: "chromium",
15
+ headless: true,
16
+ viewport: opts.viewport ?? { width: 1440, height: 960 },
17
+ ...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
18
+ },
19
+ webServer: {
20
+ command,
21
+ url: baseURL,
22
+ reuseExistingServer: !process.env.CI,
23
+ timeout: 120_000, // cold CI runners are slow to first-serve
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,16 @@
1
+ export interface InstanceProxy {
2
+ /** Origin of the proxy, e.g. http://localhost:53421 */
3
+ origin: string;
4
+ /** Full dashboard URL for a hash-routed page, under the advertised prefix. */
5
+ dashboardUrl: (page?: string) => string;
6
+ stop: () => void;
7
+ }
8
+ /** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
9
+ * studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
10
+ * (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
11
+ * path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
12
+ export declare function startInstanceProxy(opts: {
13
+ studioHostPort: number;
14
+ instanceId: string;
15
+ dashboardKey: string;
16
+ }): InstanceProxy;
@@ -0,0 +1,90 @@
1
+ // A tiny per-instance reverse proxy for the engine-tier doc-guides.
2
+ //
3
+ // A product's baked operator dashboard is built to run behind the runner's oauth2
4
+ // proxy: its `env` endpoint advertises instance-scoped paths
5
+ // (`apiBasePath: /instance/<id>/live/api`, `wsBasePath: /instance/<id>/live`).
6
+ // The harness, though, publishes Studio DIRECTLY on studioHostPort with no
7
+ // `/instance/<id>` prefix — so those advertised paths 404 and the console never
8
+ // leaves its "workflow starting up" splash.
9
+ //
10
+ // Rather than drag the whole oauth2 proxy + TLS + port 443 into a doc run, this
11
+ // serves the dashboard under exactly the prefix `env` advertises and strips it
12
+ // before forwarding to studioHostPort — for both HTTP and the live-state
13
+ // WebSocket (`useLiveComponent`), which is what carries programReceiving / audio
14
+ // levels and therefore the ON AIR badge. Plain HTTP, ephemeral port, self-owned.
15
+ // Response headers that describe the upstream transfer encoding; fetch() has
16
+ // already decoded the body, so forwarding these would misdescribe what we send.
17
+ const STRIP_RESPONSE_HEADERS = ["content-encoding", "content-length", "transfer-encoding"];
18
+ /** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
19
+ * studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
20
+ * (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
21
+ * path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
22
+ export function startInstanceProxy(opts) {
23
+ const prefix = `/instance/${opts.instanceId}`;
24
+ const httpUpstream = `http://localhost:${opts.studioHostPort}`;
25
+ const strip = (pathname) => pathname === prefix ? "/" : pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
26
+ const server = Bun.serve({
27
+ port: 0,
28
+ async fetch(req, srv) {
29
+ const u = new URL(req.url);
30
+ const path = strip(u.pathname);
31
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
32
+ const upstreamUrl = `ws://localhost:${opts.studioHostPort}${path}${u.search}`;
33
+ const ok = srv.upgrade(req, { data: { upstreamUrl, upstream: null, queue: [] } });
34
+ return ok ? undefined : new Response("ws upgrade failed", { status: 400 });
35
+ }
36
+ const headers = new Headers(req.headers);
37
+ headers.delete("host");
38
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
39
+ const upstream = await fetch(`${httpUpstream}${path}${u.search}`, {
40
+ method: req.method,
41
+ headers,
42
+ body,
43
+ redirect: "manual",
44
+ });
45
+ const outHeaders = new Headers(upstream.headers);
46
+ for (const h of STRIP_RESPONSE_HEADERS)
47
+ outHeaders.delete(h);
48
+ return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
49
+ },
50
+ websocket: {
51
+ open(ws) {
52
+ const up = new WebSocket(ws.data.upstreamUrl);
53
+ up.binaryType = "arraybuffer";
54
+ ws.data.upstream = up;
55
+ up.onopen = () => {
56
+ for (const m of ws.data.queue)
57
+ up.send(m);
58
+ ws.data.queue = [];
59
+ };
60
+ up.onmessage = (e) => ws.send(e.data);
61
+ up.onclose = (e) => ws.close(e.code || 1000, e.reason);
62
+ up.onerror = () => {
63
+ try {
64
+ ws.close();
65
+ }
66
+ catch { }
67
+ };
68
+ },
69
+ message(ws, message) {
70
+ const up = ws.data.upstream;
71
+ if (up && up.readyState === WebSocket.OPEN)
72
+ up.send(message);
73
+ else
74
+ ws.data.queue.push(message);
75
+ },
76
+ close(ws) {
77
+ try {
78
+ ws.data.upstream?.close();
79
+ }
80
+ catch { }
81
+ },
82
+ },
83
+ });
84
+ const origin = `http://localhost:${server.port}`;
85
+ return {
86
+ origin,
87
+ dashboardUrl: (page = "onair") => `${origin}${prefix}/dashboard/${opts.dashboardKey}/#/${page}`,
88
+ stop: () => server.stop(true),
89
+ };
90
+ }
@@ -0,0 +1,15 @@
1
+ import { type Browser, type BrowserContext, type Page } from "@playwright/test";
2
+ export interface LiveBrowser {
3
+ browser: Browser;
4
+ context: BrowserContext;
5
+ page: Page;
6
+ }
7
+ /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
8
+ * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
9
+ * --no-sandbox because the nix chromium has no setuid sandbox helper. */
10
+ export declare function launchLiveBrowser(opts?: {
11
+ viewport?: {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ }): Promise<LiveBrowser>;
@@ -0,0 +1,21 @@
1
+ // Engine-tier doc-guide browser support. The fixture-tier guides run under the
2
+ // @playwright/test runner against a Vite dev server; the engine tier instead
3
+ // drives a REAL launched instance from inside a bun:test that already owns the
4
+ // product harness lifecycle. So we launch a raw chromium here (the same nixpkgs
5
+ // chromium the fixture tier uses, via BROWSER_FOR_TESTING) rather than go through
6
+ // the test-runner's webServer model.
7
+ import { chromium } from "@playwright/test";
8
+ /** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
9
+ * chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
10
+ * --no-sandbox because the nix chromium has no setuid sandbox helper. */
11
+ export async function launchLiveBrowser(opts) {
12
+ const executablePath = process.env.BROWSER_FOR_TESTING;
13
+ if (!executablePath) {
14
+ throw new Error("BROWSER_FOR_TESTING is unset — run inside `nix develop` so chromium is on offer");
15
+ }
16
+ const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
17
+ const viewport = opts?.viewport ?? { width: 1440, height: 960 };
18
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
19
+ const page = await context.newPage();
20
+ return { browser, context, page };
21
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Run "$@" with an X DISPLAY available — passes through if one is already set,
3
+ # otherwise wraps in xvfb-run. Needed because nix-pinned headless chromium on
4
+ # a true-headless Linux box silently no-ops page.fill on plain HTML inputs and
5
+ # crashes the renderer on some pages; giving it a real X display avoids both.
6
+ # On Mac this is a no-op (xvfb-run isn't on PATH and Apple's chromium doesn't
7
+ # need it).
8
+ #
9
+ # Part of the @norskvideo/ctl-dev-kit doc-guide toolchain. A product copies this
10
+ # to its own scripts/ (it must be a repo-local executable the package.json guide
11
+ # scripts can invoke); the dev-kit is the canonical source.
12
+
13
+ set -euo pipefail
14
+
15
+ if [ -n "${DISPLAY:-}" ] || ! command -v xvfb-run >/dev/null 2>&1; then
16
+ exec "$@"
17
+ fi
18
+
19
+ exec xvfb-run -a --server-args="-screen 0 1280x720x24" "$@"
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
7
7
  "./testing/invariants": "./testing/invariants.ts",
8
8
  "./testing/byte-snapshot": "./testing/byte-snapshot.ts",
9
- "./create-product": "./create-product/create-product.ts"
9
+ "./create-product": "./create-product/create-product.ts",
10
+ "./doc-guide": "./doc-guide/doc-guide.js",
11
+ "./doc-guide/build-manual": "./doc-guide/build-manual.js",
12
+ "./doc-guide/guides-config": "./doc-guide/guides-config.js",
13
+ "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
14
+ "./doc-guide/live-browser": "./doc-guide/live-browser.js"
10
15
  },
11
16
  "bin": {
12
17
  "ctl-dev-kit": "./create-product/cli.ts"
@@ -14,6 +19,14 @@
14
19
  "dependencies": {
15
20
  "@norskvideo/ctl-sdk": "^0.1.0"
16
21
  },
22
+ "peerDependencies": {
23
+ "@playwright/test": "*"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "@playwright/test": {
27
+ "optional": true
28
+ }
29
+ },
17
30
  "publishConfig": {
18
31
  "access": "public"
19
32
  }