@norskvideo/ctl-dev-kit 0.1.22 → 0.1.24
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 +17 -0
- package/conventions/docs.md +133 -0
- package/conventions/publish-docs.yml +141 -0
- package/conventions/sync-dev-kit.yml +16 -4
- package/conventions/sync-drift.ts +8 -0
- package/doc-guide/build-manual.ts +244 -0
- package/doc-guide/doc-guide.ts +174 -0
- package/doc-guide/guides-config.ts +63 -0
- package/doc-guide/instance-proxy.ts +111 -0
- package/doc-guide/live-browser.ts +29 -0
- package/doc-guide/with-display.sh +19 -0
- package/package.json +15 -2
|
@@ -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
|
+
'
|
|
@@ -138,7 +138,7 @@ jobs:
|
|
|
138
138
|
- name: Open a reconciliation PR (gate red)
|
|
139
139
|
if: steps.sync.outputs.changed == '1' && steps.gate.outcome == 'failure'
|
|
140
140
|
env:
|
|
141
|
-
|
|
141
|
+
API_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
|
|
142
142
|
run: |
|
|
143
143
|
set -euo pipefail
|
|
144
144
|
branch="sync-dev-kit/${{ steps.sync.outputs.new }}"
|
|
@@ -162,10 +162,22 @@ jobs:
|
|
|
162
162
|
" bun run lint && bun run typecheck && bun run test:unit # a convention that breaks real code" \
|
|
163
163
|
"" \
|
|
164
164
|
"Resolve whichever is failing, pushing to this branch until green -- then merge.")"
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
# gh is not on the self-hosted runner PATH (it lives only inside
|
|
166
|
+
# `nix develop`, and this step runs outside it), so create/update the PR
|
|
167
|
+
# straight against the REST API with the same PAT that pushed the branch.
|
|
168
|
+
# Using the PAT (not GITHUB_TOKEN) lets the PR's own checks run so a human
|
|
169
|
+
# sees the red/green. curl+jq are on the host PATH (the pin-resolve step
|
|
170
|
+
# above already uses them).
|
|
171
|
+
repo="${{ github.repository }}"; owner="${repo%%/*}"
|
|
172
|
+
api="https://api.github.com/repos/$repo/pulls"
|
|
173
|
+
curl_api=(--fail-with-body -sS -H "Authorization: Bearer $API_TOKEN" -H "Accept: application/vnd.github+json")
|
|
174
|
+
existing="$(curl "${curl_api[@]}" "$api?head=$owner:$branch&state=open" | jq -r '.[0].number // empty')"
|
|
175
|
+
if [ -n "$existing" ]; then
|
|
176
|
+
curl "${curl_api[@]}" -X PATCH "$api/$existing" \
|
|
177
|
+
--data "$(jq -nc --arg t "$title" --arg b "$body" '{title:$t, body:$b}')" >/dev/null
|
|
167
178
|
else
|
|
168
|
-
|
|
179
|
+
curl "${curl_api[@]}" -X POST "$api" \
|
|
180
|
+
--data "$(jq -nc --arg t "$title" --arg b "$body" --arg h "$branch" '{title:$t, body:$b, head:$h, base:"main"}')" >/dev/null
|
|
169
181
|
fi
|
|
170
182
|
|
|
171
183
|
- name: Report "already fresh"
|
|
@@ -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,244 @@
|
|
|
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
|
+
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export type Step = { slug: string; file: string; head: string; desc: string };
|
|
22
|
+
|
|
23
|
+
export type ManualPage = {
|
|
24
|
+
id: string;
|
|
25
|
+
nav: string;
|
|
26
|
+
title: string;
|
|
27
|
+
kind: "fn" | "ex";
|
|
28
|
+
tag?: string;
|
|
29
|
+
intro: string;
|
|
30
|
+
steps: Step[];
|
|
31
|
+
links: string[];
|
|
32
|
+
thin?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface ManualOverview {
|
|
36
|
+
/** The overview <h1>. */
|
|
37
|
+
headline: string;
|
|
38
|
+
/** The lead paragraph under it. */
|
|
39
|
+
lead: string;
|
|
40
|
+
/** The hero capture (a representative shot) and its caption. */
|
|
41
|
+
hero: { slug: string; file: string; alt: string; caption: string };
|
|
42
|
+
/** Left column heading of the two-up index. Default "By function". */
|
|
43
|
+
byFunctionHeading?: string;
|
|
44
|
+
/** Right column heading. Default "By example". */
|
|
45
|
+
byExampleHeading?: string;
|
|
46
|
+
/** The small print under the right column heading. */
|
|
47
|
+
byExampleIntro: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ManualSpec {
|
|
51
|
+
/** Sidebar wordmark + document brand. */
|
|
52
|
+
brand: string;
|
|
53
|
+
/** The full <title> for both the fragment and the standalone document. */
|
|
54
|
+
title: string;
|
|
55
|
+
overview: ManualOverview;
|
|
56
|
+
functionality: ManualPage[];
|
|
57
|
+
examples: ManualPage[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BuildManualResult {
|
|
61
|
+
/** Artifact-ready fragment (no doctype/head/body). */
|
|
62
|
+
indexHtml: string;
|
|
63
|
+
/** Complete standalone document wrapping the fragment. */
|
|
64
|
+
standaloneHtml: string;
|
|
65
|
+
/** `<slug>/<file>` of every referenced capture not found on disk. */
|
|
66
|
+
missing: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
70
|
+
|
|
71
|
+
/** Resize a captured PNG to <=1100px wide JPEG and return a data URI. Missing
|
|
72
|
+
* captures become a labelled placeholder so the manual still builds. */
|
|
73
|
+
function makeUri(docsRoot: string, slug: string, file: string, missing: string[]): string {
|
|
74
|
+
const path = join(docsRoot, slug, file);
|
|
75
|
+
if (!existsSync(path)) {
|
|
76
|
+
missing.push(`${slug}/${file}`);
|
|
77
|
+
const label = esc(`missing: ${slug}/${file}`);
|
|
78
|
+
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>`;
|
|
79
|
+
return `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`;
|
|
80
|
+
}
|
|
81
|
+
const r = spawnSync("magick", [path, "-resize", "1100x>", "-quality", "82", "jpg:-"], {
|
|
82
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
83
|
+
});
|
|
84
|
+
if (r.status !== 0 || !r.stdout?.length) {
|
|
85
|
+
throw new Error(`magick failed for ${path}: ${r.stderr?.toString() ?? "no output"}`);
|
|
86
|
+
}
|
|
87
|
+
return `data:image/jpeg;base64,${r.stdout.toString("base64")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildManual(spec: ManualSpec, opts: { docsRoot: string }): BuildManualResult {
|
|
91
|
+
const { docsRoot } = opts;
|
|
92
|
+
const missing: string[] = [];
|
|
93
|
+
const uri = (slug: string, file: string) => makeUri(docsRoot, slug, file, missing);
|
|
94
|
+
|
|
95
|
+
const FN = spec.functionality;
|
|
96
|
+
const EX = spec.examples;
|
|
97
|
+
const ALL = [...FN, ...EX];
|
|
98
|
+
const byId = new Map(ALL.map((p) => [p.id, p]));
|
|
99
|
+
|
|
100
|
+
function pageHtml(p: ManualPage): string {
|
|
101
|
+
const steps = p.steps
|
|
102
|
+
.map(
|
|
103
|
+
(s, i) => `<li class="step">
|
|
104
|
+
<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>
|
|
105
|
+
<div class="shot"><img loading="lazy" src="${uri(s.slug, s.file)}" alt="${esc(s.head)}"></div>
|
|
106
|
+
</li>`,
|
|
107
|
+
)
|
|
108
|
+
.join("\n");
|
|
109
|
+
const links = p.links.length
|
|
110
|
+
? `<div class="xlinks"><span class="xlab">${p.kind === "fn" ? "Seen in" : "Uses"}</span>${p.links
|
|
111
|
+
.map((id) => {
|
|
112
|
+
const t = byId.get(id);
|
|
113
|
+
return t ? `<a href="#${id}" class="chip">${esc(t.nav)}</a>` : "";
|
|
114
|
+
})
|
|
115
|
+
.join("")}</div>`
|
|
116
|
+
: "";
|
|
117
|
+
const thin = p.thin
|
|
118
|
+
? `<p class="thin">More of this walkthrough is coming — this deployment shape has one screen captured so far.</p>`
|
|
119
|
+
: "";
|
|
120
|
+
return `<article class="page" id="${p.id}">
|
|
121
|
+
<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>
|
|
122
|
+
<p class="lead">${esc(p.intro)}</p>
|
|
123
|
+
<ol class="steps">${steps}</ol>
|
|
124
|
+
${thin}${links}
|
|
125
|
+
</article>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ov = spec.overview;
|
|
129
|
+
const overview = `<article class="page" id="overview">
|
|
130
|
+
<div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
|
|
131
|
+
<p class="lead">${esc(ov.lead)}</p>
|
|
132
|
+
<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>
|
|
133
|
+
<div class="twocol">
|
|
134
|
+
<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>
|
|
135
|
+
<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>
|
|
136
|
+
</div>
|
|
137
|
+
</article>`;
|
|
138
|
+
|
|
139
|
+
const nav = `
|
|
140
|
+
<a href="#overview" data-nav class="ovlink">Overview</a>
|
|
141
|
+
<div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
|
|
142
|
+
<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>`;
|
|
143
|
+
|
|
144
|
+
const html = `<title>${esc(spec.title)}</title>
|
|
145
|
+
<style>
|
|
146
|
+
:root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
|
|
147
|
+
--line:#212a36;--accent:#54d6cf;--measure:64ch}
|
|
148
|
+
@media (prefers-color-scheme:light){:root{--bg:#f5f7f9;--bg2:#eef1f4;--surf:#fff;--ink:#0f1620;
|
|
149
|
+
--dim:#54606e;--faint:#8c96a3;--line:#e2e7ec;--accent:#0c8f86}}
|
|
150
|
+
:root[data-theme="dark"]{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;
|
|
151
|
+
--faint:#5f6a79;--line:#212a36;--accent:#54d6cf}
|
|
152
|
+
:root[data-theme="light"]{--bg:#f5f7f9;--bg2:#eef1f4;--surf:#fff;--ink:#0f1620;--dim:#54606e;
|
|
153
|
+
--faint:#8c96a3;--line:#e2e7ec;--accent:#0c8f86}
|
|
154
|
+
*{box-sizing:border-box}
|
|
155
|
+
body{margin:0;background:var(--bg);color:var(--ink);line-height:1.6;
|
|
156
|
+
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
|
157
|
+
.mono{font-family:ui-monospace,"SF Mono",Menlo,monospace}
|
|
158
|
+
.wrap{display:grid;grid-template-columns:264px minmax(0,1fr);max-width:1400px;margin:0 auto}
|
|
159
|
+
aside{position:sticky;top:0;align-self:start;height:100vh;overflow-y:auto;padding:26px 18px 48px;
|
|
160
|
+
border-right:1px solid var(--line);background:var(--bg2)}
|
|
161
|
+
.brand{font-family:ui-monospace,Menlo,monospace;font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:22px}
|
|
162
|
+
aside a{display:block;text-decoration:none;color:var(--dim);padding:6px 10px;border-radius:7px;font-size:13px;line-height:1.35}
|
|
163
|
+
aside a:hover{background:var(--surf);color:var(--ink)}
|
|
164
|
+
aside a.active{background:var(--surf);color:var(--ink);box-shadow:inset 2px 0 0 var(--accent)}
|
|
165
|
+
.ovlink{margin-bottom:16px;font-weight:600;color:var(--ink)}
|
|
166
|
+
.ngroup{margin-bottom:20px}
|
|
167
|
+
.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}
|
|
168
|
+
.ct{background:var(--surf);border:1px solid var(--line);border-radius:20px;padding:0 6px;font-size:9.5px;color:var(--dim)}
|
|
169
|
+
main{min-width:0;padding:0 clamp(20px,5vw,72px)}
|
|
170
|
+
.page{display:none;max-width:840px;padding:52px 0 88px;animation:fade .18s ease}
|
|
171
|
+
.page.on{display:block}
|
|
172
|
+
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
173
|
+
@media (prefers-reduced-motion:reduce){.page{animation:none}}
|
|
174
|
+
.phead{margin-bottom:20px}
|
|
175
|
+
.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}
|
|
176
|
+
.kind.ex{color:var(--dim)}.kind.ov{color:var(--faint)}
|
|
177
|
+
.phead h1{font-size:clamp(26px,4vw,42px);line-height:1.05;letter-spacing:-.028em;margin:0;font-weight:730;text-wrap:balance}
|
|
178
|
+
.ptag{margin-top:12px;font-size:12px;color:var(--faint)}
|
|
179
|
+
.lead{font-size:clamp(15px,1.9vw,18px);color:var(--dim);max-width:var(--measure);margin:0 0 8px}
|
|
180
|
+
|
|
181
|
+
.steps{list-style:none;margin:34px 0 0;padding:0}
|
|
182
|
+
.step{padding:28px 0;border-top:1px solid var(--line)}
|
|
183
|
+
.stinfo{display:flex;gap:16px;align-items:baseline;margin-bottom:16px;max-width:var(--measure)}
|
|
184
|
+
.stnum{font-family:ui-monospace,Menlo,monospace;font-size:13px;color:var(--accent);flex:none;padding-top:2px}
|
|
185
|
+
.stinfo h3{font-size:17px;margin:0 0 4px;letter-spacing:-.01em}
|
|
186
|
+
.stinfo p{margin:0;color:var(--dim);font-size:14.5px}
|
|
187
|
+
.shot{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#000;box-shadow:0 22px 46px -32px rgba(0,0,0,.8)}
|
|
188
|
+
.shot img{display:block;max-width:100%;height:auto;margin:0 auto}
|
|
189
|
+
.heroshot{margin-bottom:36px}
|
|
190
|
+
.heroshot .shot{border-color:color-mix(in srgb,var(--accent) 24%,var(--line))}
|
|
191
|
+
.hcap{margin:12px 0 0;color:var(--dim);font-size:13px;max-width:56ch}
|
|
192
|
+
|
|
193
|
+
.thin{margin-top:26px;font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--faint);
|
|
194
|
+
background:var(--surf);border:1px dashed var(--line);border-radius:9px;padding:11px 14px;max-width:var(--measure)}
|
|
195
|
+
.xlinks{margin-top:36px;padding-top:22px;border-top:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
|
|
196
|
+
.xlab{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-right:4px}
|
|
197
|
+
.chip{text-decoration:none;font-size:13px;color:var(--ink);background:var(--surf);border:1px solid var(--line);border-radius:20px;padding:6px 13px}
|
|
198
|
+
.chip:hover{border-color:var(--accent);color:var(--accent)}
|
|
199
|
+
.twocol{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:40px;padding-top:30px;border-top:1px solid var(--line)}
|
|
200
|
+
.twocol h2{font-size:15px;margin:0 0 12px}
|
|
201
|
+
.mini{font-size:12.5px;color:var(--faint);margin:0 0 12px;max-width:40ch}
|
|
202
|
+
.linklist{display:flex;flex-direction:column;gap:2px}
|
|
203
|
+
.linklist a{text-decoration:none;color:var(--dim);font-size:14px;padding:5px 0}
|
|
204
|
+
.linklist a:hover{color:var(--accent)}
|
|
205
|
+
@media (max-width:880px){.wrap{grid-template-columns:1fr}
|
|
206
|
+
aside{position:static;height:auto;border-right:none;border-bottom:1px solid var(--line)}
|
|
207
|
+
.twocol{grid-template-columns:1fr}}
|
|
208
|
+
</style>
|
|
209
|
+
|
|
210
|
+
<div class="wrap">
|
|
211
|
+
<aside><div class="brand">${esc(spec.brand)}</div>${nav}</aside>
|
|
212
|
+
<main>
|
|
213
|
+
${overview}
|
|
214
|
+
${ALL.map(pageHtml).join("\n")}
|
|
215
|
+
</main>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<script>
|
|
219
|
+
(function(){
|
|
220
|
+
var pages=[].slice.call(document.querySelectorAll('.page'));
|
|
221
|
+
var links=[].slice.call(document.querySelectorAll('[data-nav]'));
|
|
222
|
+
function show(){var id=(location.hash||'#overview').slice(1);
|
|
223
|
+
if(!document.getElementById(id))id='overview';
|
|
224
|
+
pages.forEach(function(p){p.classList.toggle('on',p.id===id)});
|
|
225
|
+
links.forEach(function(a){a.classList.toggle('active',a.getAttribute('href')==='#'+id)});
|
|
226
|
+
window.scrollTo(0,0);}
|
|
227
|
+
window.addEventListener('hashchange',show);show();
|
|
228
|
+
})();
|
|
229
|
+
</script>`;
|
|
230
|
+
|
|
231
|
+
const standaloneHtml = `<!doctype html>
|
|
232
|
+
<html lang="en">
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="utf-8" />
|
|
235
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
236
|
+
<title>${esc(spec.title)}</title>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
${html}
|
|
240
|
+
</body>
|
|
241
|
+
</html>`;
|
|
242
|
+
|
|
243
|
+
return { indexHtml: html, standaloneHtml, missing };
|
|
244
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import type { Page } from "@playwright/test";
|
|
4
|
+
|
|
5
|
+
// Shared support for a product repo's Playwright/engine doc-guides. A guide drives
|
|
6
|
+
// a real surface (a configure SPA, an operator dashboard, a launched instance) and
|
|
7
|
+
// narrates itself; entries render to an illustrated markdown manual under
|
|
8
|
+
// <repo>/docs/generated/<slug>/. Screenshots can never depict a UI that doesn't
|
|
9
|
+
// exist, because they come from a real, asserted run.
|
|
10
|
+
//
|
|
11
|
+
// Extracted from norsk-commentary (which took it from norsk-ctl) into the dev kit
|
|
12
|
+
// so every ctl product shoots its manual the same way. The one thing that had to
|
|
13
|
+
// change on the way in: the old copy resolved docs/generated RELATIVE TO ITS OWN
|
|
14
|
+
// FILE, which worked only while it lived in the repo. Living in node_modules it
|
|
15
|
+
// cannot, so the docs root is now explicit — NORSK_DOCS_ROOT if set, else
|
|
16
|
+
// <cwd>/docs/generated. A fixture runner whose cwd is a package subdir (e.g.
|
|
17
|
+
// dashboards/<slug>) MUST export NORSK_DOCS_ROOT to the repo-level dir; the
|
|
18
|
+
// guidesConfig() factory does this for you. Resolution is deferred to call time
|
|
19
|
+
// so the env var need only be set before the first capture, not before import.
|
|
20
|
+
|
|
21
|
+
export type UiEntry =
|
|
22
|
+
| { kind: "section"; heading: string }
|
|
23
|
+
| { kind: "step"; heading: string; description?: string }
|
|
24
|
+
| { kind: "text"; prose: string }
|
|
25
|
+
| { kind: "capture"; caption: string; filename: string };
|
|
26
|
+
|
|
27
|
+
export interface UiFixture {
|
|
28
|
+
type: "ui";
|
|
29
|
+
title: string;
|
|
30
|
+
entries: UiEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the repo-level docs/generated root. NORSK_DOCS_ROOT wins (absolute or
|
|
34
|
+
* relative to cwd); otherwise <cwd>/docs/generated, which is correct for runners
|
|
35
|
+
* whose cwd is the repo root (the engine tier). Deferred, not module-level, so a
|
|
36
|
+
* config that sets the env var at eval time is honoured. */
|
|
37
|
+
export function docsRoot(): string {
|
|
38
|
+
const override = process.env.NORSK_DOCS_ROOT;
|
|
39
|
+
if (override) return isAbsolute(override) ? override : resolve(process.cwd(), override);
|
|
40
|
+
return resolve(process.cwd(), "docs/generated");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-step screenshot treatment. `crop` shoots one component at its own bounds;
|
|
44
|
+
* `spotlight` keeps the whole screen for context but dims everything except the
|
|
45
|
+
* named region and outlines it. Omit both for a plain full-page capture. */
|
|
46
|
+
export interface CaptureTreatment {
|
|
47
|
+
crop?: string;
|
|
48
|
+
spotlight?: string;
|
|
49
|
+
/** Slack in px around a spotlight region before the dimming starts. */
|
|
50
|
+
pad?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SPOTLIGHT_ID = "docguide-spotlight";
|
|
54
|
+
|
|
55
|
+
/** Dim the page except the target region, drawn as one fixed box whose oversized
|
|
56
|
+
* box-shadow does the dimming — appended to <body> so no ancestor `overflow`
|
|
57
|
+
* clips it. Scrolls the target into view first so the lit region is on-screen. */
|
|
58
|
+
async function spotlightOn(page: Page, selector: string, pad: number): Promise<void> {
|
|
59
|
+
await page.locator(selector).first().scrollIntoViewIfNeeded();
|
|
60
|
+
await page.evaluate(
|
|
61
|
+
({ selector, pad, id }) => {
|
|
62
|
+
const el = document.querySelector(selector);
|
|
63
|
+
if (!el) throw new Error(`spotlight target not found: ${selector}`);
|
|
64
|
+
const r = el.getBoundingClientRect();
|
|
65
|
+
const box = document.createElement("div");
|
|
66
|
+
box.id = id;
|
|
67
|
+
Object.assign(box.style, {
|
|
68
|
+
position: "fixed",
|
|
69
|
+
left: `${Math.max(0, r.left - pad)}px`,
|
|
70
|
+
top: `${Math.max(0, r.top - pad)}px`,
|
|
71
|
+
width: `${r.width + pad * 2}px`,
|
|
72
|
+
height: `${r.height + pad * 2}px`,
|
|
73
|
+
border: "2px solid #54d6cf",
|
|
74
|
+
borderRadius: "10px",
|
|
75
|
+
boxShadow: "0 0 0 4000px rgba(6,9,13,0.62)",
|
|
76
|
+
zIndex: "2147483647",
|
|
77
|
+
pointerEvents: "none",
|
|
78
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
79
|
+
document.body.appendChild(box);
|
|
80
|
+
},
|
|
81
|
+
{ selector, pad, id: SPOTLIGHT_ID },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function spotlightOff(page: Page): Promise<void> {
|
|
86
|
+
await page.evaluate((id) => document.getElementById(id)?.remove(), SPOTLIGHT_ID);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Produce the screenshot bytes for a capture, applying its treatment. Split out
|
|
90
|
+
* from `DocGuide.capture` (which also gates on DOCS_GENERATE and writes the file)
|
|
91
|
+
* so the treatment geometry is testable on its own. */
|
|
92
|
+
export async function screenshotFor(page: Page, treat?: CaptureTreatment): Promise<Buffer> {
|
|
93
|
+
// Wait for web fonts, else the snapshot catches a fallback font mid-swap.
|
|
94
|
+
await page.evaluate(() => document.fonts.ready);
|
|
95
|
+
if (treat?.crop) return page.locator(treat.crop).first().screenshot();
|
|
96
|
+
if (treat?.spotlight) {
|
|
97
|
+
await spotlightOn(page, treat.spotlight, treat.pad ?? 8);
|
|
98
|
+
try {
|
|
99
|
+
return await page.screenshot();
|
|
100
|
+
} finally {
|
|
101
|
+
await spotlightOff(page);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return page.screenshot({ fullPage: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pure markdown assembly — exposed so a render step can re-emit markdown from a
|
|
108
|
+
* stored entries.json without re-running the browser. */
|
|
109
|
+
export function renderUiMarkdown(title: string, entries: UiEntry[]): string {
|
|
110
|
+
const lines: string[] = ["---", `title: ${title}`, "---", "", `# ${title}`, ""];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
switch (entry.kind) {
|
|
113
|
+
case "section":
|
|
114
|
+
lines.push(`## ${entry.heading}`, "");
|
|
115
|
+
break;
|
|
116
|
+
case "step":
|
|
117
|
+
lines.push(`### ${entry.heading}`, "");
|
|
118
|
+
if (entry.description) lines.push(entry.description, "");
|
|
119
|
+
break;
|
|
120
|
+
case "text":
|
|
121
|
+
lines.push(entry.prose, "");
|
|
122
|
+
break;
|
|
123
|
+
case "capture":
|
|
124
|
+
lines.push(``, "");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class DocGuide {
|
|
132
|
+
private entries: UiEntry[] = [];
|
|
133
|
+
private captureIndex = 0;
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
private slug: string,
|
|
137
|
+
private title: string,
|
|
138
|
+
) {}
|
|
139
|
+
|
|
140
|
+
section(heading: string): void {
|
|
141
|
+
this.entries.push({ kind: "section", heading });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
step(heading: string, description?: string): void {
|
|
145
|
+
this.entries.push({ kind: "step", heading, description });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
text(prose: string): void {
|
|
149
|
+
this.entries.push({ kind: "text", prose });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Screenshot the page and write it immediately under the slug's docs dir. Pass
|
|
153
|
+
* a treatment to crop to one component or spotlight a region in context. */
|
|
154
|
+
async capture(page: Page, caption: string, treat?: CaptureTreatment): Promise<void> {
|
|
155
|
+
if (process.env.DOCS_GENERATE !== "1") return;
|
|
156
|
+
this.captureIndex++;
|
|
157
|
+
const filename = `${this.slug}-${String(this.captureIndex).padStart(3, "0")}.png`;
|
|
158
|
+
const buffer = await screenshotFor(page, treat);
|
|
159
|
+
const outDir = join(docsRoot(), this.slug);
|
|
160
|
+
mkdirSync(outDir, { recursive: true });
|
|
161
|
+
writeFileSync(join(outDir, filename), buffer);
|
|
162
|
+
this.entries.push({ kind: "capture", caption, filename });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Write entries.json + the rendered index.md under docs/generated/<slug>/. */
|
|
166
|
+
async flush(): Promise<void> {
|
|
167
|
+
if (process.env.DOCS_GENERATE !== "1") return;
|
|
168
|
+
const outDir = join(docsRoot(), this.slug);
|
|
169
|
+
mkdirSync(outDir, { recursive: true });
|
|
170
|
+
const fixture: UiFixture = { type: "ui", title: this.title, entries: this.entries };
|
|
171
|
+
writeFileSync(join(outDir, "entries.json"), `${JSON.stringify(fixture, null, 2)}\n`);
|
|
172
|
+
writeFileSync(join(outDir, "index.md"), renderUiMarkdown(this.title, this.entries));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineConfig, type PlaywrightTestConfig } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Playwright config factory for a product's FIXTURE-tier doc-guides: boot the
|
|
4
|
+
// product's Vite app alone (no daemon, no license, no Docker) and drive it to
|
|
5
|
+
// produce illustrated manual captures under <repo>/docs/generated/<slug>/.
|
|
6
|
+
//
|
|
7
|
+
// Carries the hard-won CI defaults so every product's runner behaves the same:
|
|
8
|
+
// - bind + probe 127.0.0.1 explicitly. On CI runners `localhost` can resolve to
|
|
9
|
+
// IPv6 ::1 first while Vite listens on IPv4, so the readiness probe never gets
|
|
10
|
+
// 200 and the webServer "times out" though the server is up.
|
|
11
|
+
// - a 120s webServer timeout (cold CI runners are slow to first-serve) and piped
|
|
12
|
+
// stdout/stderr so a stuck boot shows in the log.
|
|
13
|
+
// - export NORSK_DOCS_ROOT so the dev-kit DocGuide writes to the REPO-level
|
|
14
|
+
// docs/generated even though the runner's cwd is a package subdir. Set here at
|
|
15
|
+
// config-eval time, before any guide imports doc-guide.ts.
|
|
16
|
+
//
|
|
17
|
+
// Chromium comes from the nix flake via BROWSER_FOR_TESTING; the guide script
|
|
18
|
+
// wraps the whole run in with-display.sh to supply an X display.
|
|
19
|
+
|
|
20
|
+
export interface GuidesConfigOptions {
|
|
21
|
+
/** Vite port. Pick one clear of the product's dev servers and sibling sessions. */
|
|
22
|
+
port: number;
|
|
23
|
+
/** Absolute path to the repo-level docs/generated dir. Exported as NORSK_DOCS_ROOT. */
|
|
24
|
+
docsRoot: string;
|
|
25
|
+
/** Capture viewport. Default 1440x960. */
|
|
26
|
+
viewport?: { width: number; height: number };
|
|
27
|
+
/** webServer command. Default `bunx vite --port <port> --strictPort --host 127.0.0.1`. */
|
|
28
|
+
command?: string;
|
|
29
|
+
/** Playwright testDir. Default "./tests/guides". */
|
|
30
|
+
testDir?: string;
|
|
31
|
+
/** Per-test timeout in ms. Default 60_000. */
|
|
32
|
+
timeout?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function guidesConfig(opts: GuidesConfigOptions): PlaywrightTestConfig {
|
|
36
|
+
// Exported before the test files import the dev-kit DocGuide, so its deferred
|
|
37
|
+
// docsRoot() resolves to the repo-level dir rather than <package>/docs/generated.
|
|
38
|
+
process.env.NORSK_DOCS_ROOT = opts.docsRoot;
|
|
39
|
+
|
|
40
|
+
const baseURL = `http://127.0.0.1:${opts.port}`;
|
|
41
|
+
const command = opts.command ?? `bunx vite --port ${opts.port} --strictPort --host 127.0.0.1`;
|
|
42
|
+
|
|
43
|
+
return defineConfig({
|
|
44
|
+
testDir: opts.testDir ?? "./tests/guides",
|
|
45
|
+
workers: 1,
|
|
46
|
+
timeout: opts.timeout ?? 60_000,
|
|
47
|
+
use: {
|
|
48
|
+
baseURL,
|
|
49
|
+
browserName: "chromium",
|
|
50
|
+
headless: true,
|
|
51
|
+
viewport: opts.viewport ?? { width: 1440, height: 960 },
|
|
52
|
+
...(process.env.BROWSER_FOR_TESTING && { launchOptions: { executablePath: process.env.BROWSER_FOR_TESTING } }),
|
|
53
|
+
},
|
|
54
|
+
webServer: {
|
|
55
|
+
command,
|
|
56
|
+
url: baseURL,
|
|
57
|
+
reuseExistingServer: !process.env.CI,
|
|
58
|
+
timeout: 120_000, // cold CI runners are slow to first-serve
|
|
59
|
+
stdout: "pipe",
|
|
60
|
+
stderr: "pipe",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
|
|
16
|
+
import type { ServerWebSocket } from "bun";
|
|
17
|
+
|
|
18
|
+
interface WsBridge {
|
|
19
|
+
upstreamUrl: string;
|
|
20
|
+
upstream: WebSocket | null;
|
|
21
|
+
queue: (string | ArrayBufferLike | Uint8Array)[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InstanceProxy {
|
|
25
|
+
/** Origin of the proxy, e.g. http://localhost:53421 */
|
|
26
|
+
origin: string;
|
|
27
|
+
/** Full dashboard URL for a hash-routed page, under the advertised prefix. */
|
|
28
|
+
dashboardUrl: (page?: string) => string;
|
|
29
|
+
stop: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Response headers that describe the upstream transfer encoding; fetch() has
|
|
33
|
+
// already decoded the body, so forwarding these would misdescribe what we send.
|
|
34
|
+
const STRIP_RESPONSE_HEADERS = ["content-encoding", "content-length", "transfer-encoding"];
|
|
35
|
+
|
|
36
|
+
/** Start a reverse proxy that serves `${prefix}/…` by forwarding `/…` to
|
|
37
|
+
* studioHostPort. `prefix` equals the instance's advertised `studioUrlPrefix`
|
|
38
|
+
* (`/instance/<instanceId>`); `dashboardKey` is the product's baked dashboard
|
|
39
|
+
* path segment (the dashboard is served at `/dashboard/<dashboardKey>/`). */
|
|
40
|
+
export function startInstanceProxy(opts: {
|
|
41
|
+
studioHostPort: number;
|
|
42
|
+
instanceId: string;
|
|
43
|
+
dashboardKey: string;
|
|
44
|
+
}): InstanceProxy {
|
|
45
|
+
const prefix = `/instance/${opts.instanceId}`;
|
|
46
|
+
const httpUpstream = `http://localhost:${opts.studioHostPort}`;
|
|
47
|
+
const strip = (pathname: string): string =>
|
|
48
|
+
pathname === prefix ? "/" : pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname;
|
|
49
|
+
|
|
50
|
+
const server = Bun.serve<WsBridge>({
|
|
51
|
+
port: 0,
|
|
52
|
+
async fetch(req, srv) {
|
|
53
|
+
const u = new URL(req.url);
|
|
54
|
+
const path = strip(u.pathname);
|
|
55
|
+
|
|
56
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
57
|
+
const upstreamUrl = `ws://localhost:${opts.studioHostPort}${path}${u.search}`;
|
|
58
|
+
const ok = srv.upgrade(req, { data: { upstreamUrl, upstream: null, queue: [] } });
|
|
59
|
+
return ok ? undefined : new Response("ws upgrade failed", { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headers = new Headers(req.headers);
|
|
63
|
+
headers.delete("host");
|
|
64
|
+
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
|
65
|
+
const upstream = await fetch(`${httpUpstream}${path}${u.search}`, {
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers,
|
|
68
|
+
body,
|
|
69
|
+
redirect: "manual",
|
|
70
|
+
});
|
|
71
|
+
const outHeaders = new Headers(upstream.headers);
|
|
72
|
+
for (const h of STRIP_RESPONSE_HEADERS) outHeaders.delete(h);
|
|
73
|
+
return new Response(upstream.body, { status: upstream.status, headers: outHeaders });
|
|
74
|
+
},
|
|
75
|
+
websocket: {
|
|
76
|
+
open(ws: ServerWebSocket<WsBridge>) {
|
|
77
|
+
const up = new WebSocket(ws.data.upstreamUrl);
|
|
78
|
+
up.binaryType = "arraybuffer";
|
|
79
|
+
ws.data.upstream = up;
|
|
80
|
+
up.onopen = () => {
|
|
81
|
+
for (const m of ws.data.queue) up.send(m as string | ArrayBufferLike);
|
|
82
|
+
ws.data.queue = [];
|
|
83
|
+
};
|
|
84
|
+
up.onmessage = (e: MessageEvent) => ws.send(e.data);
|
|
85
|
+
up.onclose = (e: CloseEvent) => ws.close(e.code || 1000, e.reason);
|
|
86
|
+
up.onerror = () => {
|
|
87
|
+
try {
|
|
88
|
+
ws.close();
|
|
89
|
+
} catch {}
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
message(ws: ServerWebSocket<WsBridge>, message: string | Buffer) {
|
|
93
|
+
const up = ws.data.upstream;
|
|
94
|
+
if (up && up.readyState === WebSocket.OPEN) up.send(message);
|
|
95
|
+
else ws.data.queue.push(message);
|
|
96
|
+
},
|
|
97
|
+
close(ws: ServerWebSocket<WsBridge>) {
|
|
98
|
+
try {
|
|
99
|
+
ws.data.upstream?.close();
|
|
100
|
+
} catch {}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const origin = `http://localhost:${server.port}`;
|
|
106
|
+
return {
|
|
107
|
+
origin,
|
|
108
|
+
dashboardUrl: (page = "onair") => `${origin}${prefix}/dashboard/${opts.dashboardKey}/#/${page}`,
|
|
109
|
+
stop: () => server.stop(true),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
|
|
8
|
+
import { type Browser, type BrowserContext, chromium, type Page } from "@playwright/test";
|
|
9
|
+
|
|
10
|
+
export interface LiveBrowser {
|
|
11
|
+
browser: Browser;
|
|
12
|
+
context: BrowserContext;
|
|
13
|
+
page: Page;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Launch headless chromium for a live-dashboard capture. Uses the nix-provided
|
|
17
|
+
* chromium (BROWSER_FOR_TESTING) so no Playwright browser download is needed;
|
|
18
|
+
* --no-sandbox because the nix chromium has no setuid sandbox helper. */
|
|
19
|
+
export async function launchLiveBrowser(opts?: { viewport?: { width: number; height: number } }): Promise<LiveBrowser> {
|
|
20
|
+
const executablePath = process.env.BROWSER_FOR_TESTING;
|
|
21
|
+
if (!executablePath) {
|
|
22
|
+
throw new Error("BROWSER_FOR_TESTING is unset — run inside `nix develop` so chromium is on offer");
|
|
23
|
+
}
|
|
24
|
+
const browser = await chromium.launch({ executablePath, args: ["--no-sandbox"] });
|
|
25
|
+
const viewport = opts?.viewport ?? { width: 1440, height: 960 };
|
|
26
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1 });
|
|
27
|
+
const page = await context.newPage();
|
|
28
|
+
return { browser, context, page };
|
|
29
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.24",
|
|
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.ts",
|
|
11
|
+
"./doc-guide/build-manual": "./doc-guide/build-manual.ts",
|
|
12
|
+
"./doc-guide/guides-config": "./doc-guide/guides-config.ts",
|
|
13
|
+
"./doc-guide/instance-proxy": "./doc-guide/instance-proxy.ts",
|
|
14
|
+
"./doc-guide/live-browser": "./doc-guide/live-browser.ts"
|
|
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
|
}
|