@norskvideo/ctl-dev-kit 0.1.55 → 0.1.57

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.
@@ -50,6 +50,11 @@ COPY dashboards /usr/src/dashboards
50
50
  COPY components/lib /usr/src/components/lib
51
51
  # Optional product-template assets (funke's slate/black PNGs). Empty otherwise.
52
52
  COPY assets /usr/src/assets
53
+ # The docs bundle (docs/generated/bundle/ on the host), served by the product
54
+ # at /docs. Beside backend/ and frontend/ so server.ts reaches it the same way
55
+ # it reaches frontend/dist (../../docs from the bundle dir). Empty when the
56
+ # product built none.
57
+ COPY docs ./docs
53
58
  ENV PORT=${PRODUCT_PORT}
54
59
  EXPOSE ${PRODUCT_PORT}
55
60
  CMD ["/usr/local/bin/bun", "run", "backend/dist/index.js"]
@@ -23,6 +23,7 @@
23
23
  # components/lib the components workspace's build output
24
24
  # native/ native addon(s) (probe's signer) -> beside bundle
25
25
  # assets/ product-template assets (funke's PNGs)
26
+ # docs/generated/bundle/ the docs bundle (doc-guide buildBundle) -> docs/
26
27
  # scripts/runtime-images.ts -> emits the norsk-ctl.runtime-images label
27
28
  #
28
29
  # Only backend/dist is required. A product with no frontend/dashboard/components
@@ -51,7 +52,7 @@ trap 'rm -rf "${stage}"' EXIT
51
52
 
52
53
  echo "==> staging artifacts"
53
54
  mkdir -p "${stage}/backend" "${stage}/frontend/dist" "${stage}/components/lib" \
54
- "${stage}/dashboards" "${stage}/native" "${stage}/assets"
55
+ "${stage}/dashboards" "${stage}/native" "${stage}/assets" "${stage}/docs"
55
56
  # The backend bundle is the one non-negotiable payload — every bundle product has
56
57
  # one, so a missing dist is a broken build, not an absent workspace.
57
58
  cp -R "${PRODUCT_DIR}/backend/dist" "${stage}/backend/dist"
@@ -87,6 +88,12 @@ if [ -d "${PRODUCT_DIR}/assets" ]; then
87
88
  cp -R "${PRODUCT_DIR}/assets/." "${stage}/assets/"
88
89
  fi
89
90
 
91
+ # The docs bundle (optional): ONLY the built bundle, never the hand-written
92
+ # docs/ tree beside it (planning notes, customer folders).
93
+ if [ -d "${PRODUCT_DIR}/docs/generated/bundle" ]; then
94
+ cp -R "${PRODUCT_DIR}/docs/generated/bundle/." "${stage}/docs/"
95
+ fi
96
+
90
97
  # Runtime images the default templates launch, stamped on as a label so a
91
98
  # golden-image bake can warm them with `docker inspect` + `docker pull` alone —
92
99
  # no daemon, no license. Derived from the rendered compose (scripts/runtime-images.ts),
@@ -0,0 +1,147 @@
1
+ # Nightly: regenerate this product's docs bundle and bake it into the product
2
+ # image. The EMBEDDED variant of the docs workflow (ADR-0012): the bundle is
3
+ # built, put in the image the way a release build puts it there, and read back
4
+ # out of that image as the proof. NOTHING LEAVES THE REPO -- no release asset,
5
+ # no upload, no write permission on the repo, no token held. A turnkey's documentation
6
+ # reaches exactly one customer, inside the image they already run.
7
+ #
8
+ # THE TWO VARIANTS (a repo carries exactly one; the drift gate enforces it):
9
+ # - build-docs.yml (this file) embedded: bundle -> image, full stop.
10
+ # Turnkeys (norsk-ctl-turnkey-*) carry this.
11
+ # - publish-docs.yml public: the same build, then the bundle is
12
+ # published for the norsk-ctl docs site to
13
+ # ingest. ONLY a norsk-ctl-product-* repo may
14
+ # carry it -- the gate refuses it elsewhere.
15
+ #
16
+ # This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
17
+ # (conventions/build-docs.yml) and copied verbatim, same as checks.yml. It is
18
+ # deliberately product-agnostic: everything per-repo lives in repo-local scripts
19
+ # it calls --
20
+ # - scripts/doc-guide/regen-manual.sh : the guide tiers + slug list + assemble
21
+ # - scripts/doc-guide/prepull-extra.sh : OPTIONAL, any images beyond media+studio
22
+ # - deployment/build-image.sh : the product image wrapper (dev-kit driver)
23
+ # so the drift gate can keep every copy byte-identical. A turnkey with prose-only
24
+ # pages (no doc-guides yet) has a regen-manual.sh that just assembles.
25
+ #
26
+ # Nightly-only by design, mirroring publish-docs.yml: a guide's engine tier
27
+ # stands up real instances (slow, licensed), so it does not ride every push.
28
+ name: build-docs
29
+
30
+ on:
31
+ schedule:
32
+ - cron: "30 1 * * *" # 01:30 UTC — ahead of ctl's nightly docs build
33
+ workflow_dispatch:
34
+
35
+ permissions:
36
+ contents: read # builds and bakes; nothing leaves the repo
37
+
38
+ concurrency:
39
+ group: build-docs-${{ github.ref }}
40
+ cancel-in-progress: true
41
+
42
+ jobs:
43
+ build:
44
+ runs-on: x64
45
+ steps:
46
+ # A guide's engine tier launches the same docker instances the integration
47
+ # suite does, leaving root-owned bind-mount target dirs under test-temp/
48
+ # that the non-root runner can't remove — which fails actions/checkout's
49
+ # own cleanup before anything runs. Nuke them from a throwaway root
50
+ # container first. Best-effort. (Mirrors integration.yml.)
51
+ - name: Clear stale root-owned test-temp (pre-checkout)
52
+ run: |
53
+ set -uo pipefail
54
+ tt="$GITHUB_WORKSPACE/test-temp"
55
+ [ -d "$tt" ] || exit 0
56
+ docker run --rm --user 0:0 -v "$tt":/t alpine sh \
57
+ -c 'rm -rf /t/* /t/.[!.]* 2>/dev/null || true' || rm -rf "$tt"/* 2>/dev/null || true
58
+
59
+ - uses: actions/checkout@v5
60
+ with:
61
+ clean: false
62
+
63
+ - name: Write the Norsk license (from the org secret)
64
+ env:
65
+ NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
66
+ run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"
67
+
68
+ - name: Download the released norsk-ctl binary (latest channel)
69
+ run: |
70
+ set -euo pipefail
71
+ S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
72
+ ver="$(curl -fsSL "$S3/latest")"
73
+ echo "norsk-ctl latest channel -> $ver"
74
+ curl -fsSL "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
75
+ chmod +x "$RUNNER_TEMP/norsk-ctl"
76
+ "$RUNNER_TEMP/norsk-ctl" --version || true
77
+
78
+ # Cold-runner first pulls overrun the harness's per-test launch timeouts;
79
+ # pre-pull the universal media + studio images (every product pins them in
80
+ # manifest.seed.json) so compose/run hit local images. A product needing
81
+ # more (e.g. a WHIP driver) pulls them in scripts/doc-guide/prepull-extra.sh.
82
+ - name: Pre-pull the media + studio images
83
+ run: |
84
+ set -euo pipefail
85
+ for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
86
+ echo "pre-pulling $img"
87
+ docker pull "$img"
88
+ done
89
+ if [ -x scripts/doc-guide/prepull-extra.sh ]; then
90
+ echo "running scripts/doc-guide/prepull-extra.sh"
91
+ ./scripts/doc-guide/prepull-extra.sh
92
+ fi
93
+
94
+ # Rebuild every workspace the bundle depends on (a guide's engine tier packs
95
+ # the freshly-built dashboard + frontend dist into the launched template),
96
+ # then run the product's doc-guide tiers and assemble the bundle.
97
+ # regen-manual.sh owns the slug list and which tiers run; with-display.sh
98
+ # wraps the whole run in an X display for headless chromium (the engine tier
99
+ # drives a raw chromium that needs one).
100
+ - name: Regenerate the docs bundle (all tiers)
101
+ env:
102
+ NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
103
+ NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
104
+ # This runner launches Studio as a HOST sibling (DooD), so the harness
105
+ # reaches host-published ports via the host-gateway alias, not
106
+ # localhost — same as the integration suite.
107
+ NORSK_TEST_HOST: host.docker.internal
108
+ # Reach launched instances over norsk-net by service DNS (as the
109
+ # integration tier already does) AND, on a ctl that supports it, launch
110
+ # them binding no host ports — so concurrent doc-guide instances can't
111
+ # collide on the product's deterministic host-port bands.
112
+ NORSK_TEST_NET: direct
113
+ run: |
114
+ nix develop .#build --command bash -c '
115
+ set -euo pipefail
116
+ # clean:false persists node_modules between runs for speed, but a
117
+ # workspace dep whose version moved leaves stale copies that
118
+ # --frozen-lockfile does not reliably relink, so a new export is "not
119
+ # found". Nuke EVERY node_modules — including nested per-workspace ones
120
+ # — for a deterministic install. A nightly can afford it.
121
+ find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
122
+ bun install --frozen-lockfile
123
+ bun run build:no-lint
124
+ bash scripts/with-display.sh scripts/doc-guide/regen-manual.sh
125
+ '
126
+
127
+ # The proof: the bundle the tiers just built goes into the product image
128
+ # the way a release build puts it there (deployment/build-image.sh stages
129
+ # docs/generated/bundle/ as the image's docs/), and the image is asked for
130
+ # its manifest. A bundle that does not build, or does not reach the image,
131
+ # fails here — on the commit that broke it. The image is local to the
132
+ # runner: nothing is pushed, nothing is uploaded, no token is held.
133
+ - name: Bake the bundle into the product image and read it back
134
+ env:
135
+ IMAGE_TAG: build-docs-bake:${{ github.sha }}
136
+ run: |
137
+ nix develop .#build --command bash -c '
138
+ set -euo pipefail
139
+ man="docs/generated/bundle/bundle.json"
140
+ [ -s "$man" ] || { echo "::error::bundle was not generated at $man"; exit 1; }
141
+ bash deployment/build-image.sh
142
+ docker run --rm --entrypoint /usr/local/bin/bun "$IMAGE_TAG" -e "
143
+ const m = require(\"/usr/src/app/docs/bundle.json\");
144
+ console.log(\"image carries docs bundle schema\", m.schemaVersion, \"with\", m.pages.length, \"pages, built\", m.provenance.builtAt);
145
+ "
146
+ docker image rm -f "$IMAGE_TAG" >/dev/null
147
+ '
@@ -17,6 +17,7 @@
17
17
  // dev-kit forces a re-sync. Canonical bytes ship alongside this script
18
18
  // (conventions/* + build/*), resolved package-relative so they work both
19
19
  // workspace-symlinked and installed from the published tarball.
20
+ import { execFileSync } from "node:child_process";
20
21
  import { existsSync, readFileSync, statSync } from "node:fs";
21
22
  import { join } from "node:path";
22
23
  import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
@@ -238,6 +239,8 @@ export interface CanonicalBytes {
238
239
  upgradeLatest: string;
239
240
  syncDevKit: string;
240
241
  publishDocs: string;
242
+ /** conventions/build-docs.yml — the embedded docs variant (bundle -> image, no upload). */
243
+ buildDocs: string;
241
244
  checks: string;
242
245
  biome: string;
243
246
  tsconfigBase: string;
@@ -320,7 +323,59 @@ function verbatimProblem(
320
323
  return undefined;
321
324
  }
322
325
 
323
- export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftReport {
326
+ export interface CheckDriftOptions {
327
+ /** The GitHub repo name (`norsk-ctl-product-playout`), which decides whether the
328
+ * public docs variant may be carried. The CLI resolves it ({@link resolveRepoName});
329
+ * a fixture passes it. Unknown is allowed only while nothing depends on it. */
330
+ repoName?: string;
331
+ }
332
+
333
+ /** `owner/name` from GITHUB_REPOSITORY (Actions), else the last path segment of
334
+ * the origin remote. Undefined when neither is available. */
335
+ export function resolveRepoName(repoRoot: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
336
+ const fromEnv = env.GITHUB_REPOSITORY?.split("/").pop();
337
+ if (fromEnv) return fromEnv;
338
+ try {
339
+ const url = execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
340
+ encoding: "utf8",
341
+ stdio: ["ignore", "pipe", "ignore"],
342
+ }).trim();
343
+ const name = url
344
+ .split(/[/:]/)
345
+ .pop()
346
+ ?.replace(/\.git$/, "");
347
+ return name || undefined;
348
+ } catch {
349
+ return undefined;
350
+ }
351
+ }
352
+
353
+ const PUBLIC_DOCS_REPO = /^norsk-ctl-product-/;
354
+
355
+ /** ADR-0012: exposure is declared by which docs workflow a repo carries, so the
356
+ * gate is what makes that declaration safe — exactly one variant; the public
357
+ * one only where GitHub calls the repo norsk-ctl-product-*; a name it cannot
358
+ * determine is a finding, never a silent pass. */
359
+ export function docsExposureProblems(hasPublish: boolean, hasBuild: boolean, repoName: string | undefined): string[] {
360
+ const problems: string[] = [];
361
+ if (hasPublish && hasBuild) {
362
+ problems.push(
363
+ "a repo carries exactly one docs workflow: .github/workflows/publish-docs.yml (public: the bundle is published for the norsk-ctl docs site) OR .github/workflows/build-docs.yml (embedded: the bundle goes into the image and nowhere else). Both are present; delete one.",
364
+ );
365
+ }
366
+ if (hasPublish && repoName === undefined) {
367
+ problems.push(
368
+ ".github/workflows/publish-docs.yml publishes this repo's docs publicly, and the gate cannot tell which repo this is (no GITHUB_REPOSITORY, no origin remote). Only a norsk-ctl-product-* repo may carry the public variant.",
369
+ );
370
+ } else if (hasPublish && !PUBLIC_DOCS_REPO.test(repoName as string)) {
371
+ problems.push(
372
+ `.github/workflows/publish-docs.yml publishes this repo's docs publicly, but this repo is ${repoName}, not a norsk-ctl-product-* repo. A turnkey's documentation never leaves the box: carry build-docs.yml (the embedded variant) instead.`,
373
+ );
374
+ }
375
+ return problems;
376
+ }
377
+
378
+ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: CheckDriftOptions = {}): DriftReport {
324
379
  const problems: string[] = [];
325
380
  const push = (problem: string | undefined) => {
326
381
  if (problem) problems.push(problem);
@@ -490,6 +545,18 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftRe
490
545
  }
491
546
  }
492
547
 
548
+ // build-docs.yml is the embedded twin of publish-docs.yml: optional, verbatim.
549
+ const buildDocsPath = join(repoRoot, ".github", "workflows", "build-docs.yml");
550
+ if (existsSync(buildDocsPath)) {
551
+ const actual = readFileSync(buildDocsPath, "utf8");
552
+ if (actual !== canonical.buildDocs) {
553
+ push(
554
+ `.github/workflows/build-docs.yml has drifted from @norskvideo/ctl-dev-kit conventions/build-docs.yml (${firstDiffLine(actual, canonical.buildDocs)}). ${RESYNC}`,
555
+ );
556
+ }
557
+ }
558
+ for (const p of docsExposureProblems(existsSync(publishDocsPath), existsSync(buildDocsPath), opts.repoName)) push(p);
559
+
493
560
  for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
494
561
 
495
562
  return { ok: problems.length === 0, problems };
@@ -503,6 +570,7 @@ if (import.meta.main) {
503
570
  upgradeLatest: readFileSync(join(import.meta.dir, "upgrade-latest.yml"), "utf8"),
504
571
  syncDevKit: readFileSync(join(import.meta.dir, "sync-dev-kit.yml"), "utf8"),
505
572
  publishDocs: readFileSync(join(import.meta.dir, "publish-docs.yml"), "utf8"),
573
+ buildDocs: readFileSync(join(import.meta.dir, "build-docs.yml"), "utf8"),
506
574
  checks: readFileSync(join(import.meta.dir, "checks.yml"), "utf8"),
507
575
  biome: readFileSync(join(import.meta.dir, "biome.base.json"), "utf8"),
508
576
  tsconfigBase: readFileSync(join(import.meta.dir, "tsconfig.base.json"), "utf8"),
@@ -513,7 +581,7 @@ if (import.meta.main) {
513
581
  smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"),
514
582
  demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
515
583
  };
516
- const report = checkDrift(repoRoot, canonical);
584
+ const report = checkDrift(repoRoot, canonical, { repoName: resolveRepoName(repoRoot) });
517
585
  if (report.ok) {
518
586
  console.log("drift-check: all shared-convention copies match @norskvideo/ctl-dev-kit.");
519
587
  process.exit(0);
@@ -95,9 +95,22 @@ they do not copy it:
95
95
  docsRoot, … })`, the Playwright config for the fixture/frontend tiers, carrying
96
96
  the CI defaults (bind+probe `127.0.0.1`, 120s webServer timeout, piped output).
97
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.
98
+ docsRoot, provenance })`, the generic assembler. The product supplies ONLY the
99
+ page content (`ManualSpec`: brand, overview, functionality[], examples[], and
100
+ `links` the way out: the product's page on norsk.video, a contact); layout,
101
+ CSS and routing live in the assembler so every product's manual looks the
102
+ same. Every export ends in a provenance footer (build time, source sha, CI
103
+ run id — `provenanceFromEnv()` reads them from a GitHub Actions run).
104
+ - `@norskvideo/ctl-dev-kit/doc-guide/bundle` — `buildBundle(spec, { docsRoot,
105
+ outDir, provenance })`, the docs bundle: `bundle.json` + `pages/<id>.md` +
106
+ `captures/` + the derived `manual.html`/`manual-linked.html`, in one
107
+ directory. Write it to `docs/generated/bundle/`: the image build stages that
108
+ directory as the image's `docs/` and the backend serves it at `/docs` with
109
+ the SDK's `serveDocs(app, docsBundleDir(import.meta.dir))` — one line in
110
+ `server.ts`, a no-op when there is no bundle. Declare the nav entry in the
111
+ manifest: `ui.sidebarEntries: [{ label: "Documentation", route: "/docs/" }]`;
112
+ ctl links it under `/products/<name>/`. An older ctl simply does not link it
113
+ (additive: nothing about launch changes).
101
114
  - `@norskvideo/ctl-dev-kit/doc-guide/{instance-proxy,live-browser}` — engine-tier
102
115
  support (serve the baked dashboard under its advertised instance prefix; launch
103
116
  the nix chromium for a live capture).
@@ -115,19 +128,28 @@ pulls them in an optional `scripts/doc-guide/prepull-extra.sh`.
115
128
 
116
129
  ## The publish contract
117
130
 
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.
131
+ The unit is the **docs bundle** (`buildBundle` -> `docs/generated/bundle/`:
132
+ `bundle.json`, `pages/*.md`, `captures/`, and the derived `manual.html`).
133
+ Exposure is declared by which docs workflow the repo carries — exactly one,
134
+ single-sourced here and drift-gated:
135
+
136
+ - **`build-docs.yml` embedded.** Nightly: regenerate the bundle, bake it into
137
+ the product image through `deployment/build-image.sh`, read it back out of the
138
+ image as the proof. No upload, no write permission on the repo, no token. A
139
+ turnkey (`norsk-ctl-turnkey-*`) carries this and nothing else: its
140
+ documentation reaches exactly one customer, inside the image they already run.
141
+ - **`publish-docs.yml` public.** The same build, then `manual.html` is
142
+ published as the asset of the moving `docs-latest` GitHub Release
143
+ (`https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`)
144
+ for norsk-ctl's docs site to fetch and mount at `/products/<slug>/`. Versioned
145
+ bundle assets behind a `latest` pointer, and ingestion of `pages/*.md` into the
146
+ site, come with the public-plane work. **Only a repo GitHub calls
147
+ `norsk-ctl-product-*` may carry it.** `check:drift` refuses it anywhere else,
148
+ refuses a repo carrying both, and refuses the public variant when it cannot
149
+ tell the repo's name (`GITHUB_REPOSITORY` in CI, the origin remote locally).
150
+
151
+ Either way the image serves the bundle at `/docs` (SDK `serveDocs`), reached
152
+ through the daemon at `/products/<name>/docs/` behind its auth guard and linked
153
+ from the product's hub via `ui.sidebarEntries`. That is the sole channel for a
154
+ turnkey and the version-true channel for a product; the public site is the
155
+ faster-cadence copy, for products only.
@@ -232,6 +232,12 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
232
232
  if (existsSync(join(repoRoot, publishDocsRel))) {
233
233
  writeIfChanged(join(repoRoot, publishDocsRel), canonical.publishDocs, publishDocsRel, r.written);
234
234
  }
235
+ // build-docs.yml, the embedded variant, the same way: carrying it is the
236
+ // repo's declaration (ADR-0012); sync only keeps the copy verbatim.
237
+ const buildDocsRel = ".github/workflows/build-docs.yml";
238
+ if (existsSync(join(repoRoot, buildDocsRel))) {
239
+ writeIfChanged(join(repoRoot, buildDocsRel), canonical.buildDocs, buildDocsRel, r.written);
240
+ }
235
241
 
236
242
  syncFlake(repoRoot, canonical.flake, r);
237
243
  syncClaude(repoRoot, canonical.core, r);
@@ -251,6 +257,7 @@ if (import.meta.main) {
251
257
  upgradeLatest: readFileSync(join(dir, "upgrade-latest.yml"), "utf8"),
252
258
  syncDevKit: readFileSync(join(dir, "sync-dev-kit.yml"), "utf8"),
253
259
  publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"),
260
+ buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"),
254
261
  checks: readFileSync(join(dir, "checks.yml"), "utf8"),
255
262
  biome: readFileSync(join(dir, "biome.base.json"), "utf8"),
256
263
  tsconfigBase: readFileSync(join(dir, "tsconfig.base.json"), "utf8"),
@@ -20,8 +20,9 @@ export interface ManualOverview {
20
20
  headline: string;
21
21
  /** The lead paragraph under it. */
22
22
  lead: string;
23
- /** The hero capture (a representative shot) and its caption. */
24
- hero: {
23
+ /** The hero capture (a representative shot) and its caption. A prose-only
24
+ * manual (a turnkey handover) has none. */
25
+ hero?: {
25
26
  slug: string;
26
27
  file: string;
27
28
  alt: string;
@@ -34,6 +35,22 @@ export interface ManualOverview {
34
35
  /** The small print under the right column heading. */
35
36
  byExampleIntro: string;
36
37
  }
38
+ /** A way out of the manual: the product's marketing page, the platform docs,
39
+ * a contact. Rendered in the footer of every export. Hash links are the only
40
+ * navigation otherwise, so a manual without these is a cul-de-sac. */
41
+ export interface ManualLink {
42
+ label: string;
43
+ href: string;
44
+ }
45
+ /** A prose page: hand-written markdown carried as-is (a turnkey's handover
46
+ * document, an operator runbook). Rendered to HTML in the manual, verbatim in
47
+ * the bundle. Captures are optional per page; a document has none. */
48
+ export interface ManualDocument {
49
+ id: string;
50
+ nav: string;
51
+ title: string;
52
+ markdown: string;
53
+ }
37
54
  export interface ManualSpec {
38
55
  /** Sidebar wordmark + document brand. */
39
56
  brand: string;
@@ -42,7 +59,29 @@ export interface ManualSpec {
42
59
  overview: ManualOverview;
43
60
  functionality: ManualPage[];
44
61
  examples: ManualPage[];
62
+ /** Prose pages, listed after the walkthroughs. */
63
+ documents?: ManualDocument[];
64
+ /** Outbound links for the footer. Optional, but every published manual should
65
+ * carry at least one. */
66
+ links?: ManualLink[];
67
+ }
68
+ /** Where a build came from, stamped into the footer so two copies of "the
69
+ * manual" can be told apart without hashing them. `builtAt` defaults to now;
70
+ * the rest is whatever the build knows (CI knows all of it, see
71
+ * {@link provenanceFromEnv}). */
72
+ export interface Provenance {
73
+ /** Source commit the captures and copy were built from. */
74
+ sha?: string;
75
+ /** The CI run that built it, when there was one. */
76
+ runId?: string;
77
+ /** ISO-8601. */
78
+ builtAt?: string;
79
+ /** The product version, when the build knows it. */
80
+ version?: string;
45
81
  }
82
+ /** Provenance as a GitHub Actions run sees it (GITHUB_SHA / GITHUB_RUN_ID), or
83
+ * as much of it as a local build has. */
84
+ export declare function provenanceFromEnv(env?: NodeJS.ProcessEnv): Provenance;
46
85
  export interface BuildManualResult {
47
86
  /** Artifact-ready fragment (no doctype/head/body). */
48
87
  indexHtml: string;
@@ -55,6 +94,9 @@ export interface BuildManualResult {
55
94
  standaloneAssetHtml?: string;
56
95
  /** `<slug>/<file>` of every referenced capture not found on disk. */
57
96
  missing: string[];
97
+ /** `<slug>/<file>` -> the sidecar href it was written to. Only when `assets`
98
+ * is set; what a bundle's markdown pages reference captures by. */
99
+ sidecarHrefs?: Record<string, string>;
58
100
  }
59
101
  /** Where to write de-inlined captures for the standalone manual. `dir` is the
60
102
  * filesystem directory; `href` (default "images") is the relative URL prefix
@@ -63,7 +105,9 @@ export interface ManualAssets {
63
105
  dir: string;
64
106
  href?: string;
65
107
  }
66
- export declare function buildManual(spec: ManualSpec, opts: {
108
+ export interface BuildManualOptions {
67
109
  docsRoot: string;
68
110
  assets?: ManualAssets;
69
- }): BuildManualResult;
111
+ provenance?: Provenance;
112
+ }
113
+ export declare function buildManual(spec: ManualSpec, opts: BuildManualOptions): BuildManualResult;
@@ -26,6 +26,17 @@
26
26
  import { spawnSync } from "node:child_process";
27
27
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
+ import { marked } from "marked";
30
+ /** Provenance as a GitHub Actions run sees it (GITHUB_SHA / GITHUB_RUN_ID), or
31
+ * as much of it as a local build has. */
32
+ export function provenanceFromEnv(env = process.env) {
33
+ const p = { builtAt: new Date().toISOString() };
34
+ if (env.GITHUB_SHA)
35
+ p.sha = env.GITHUB_SHA;
36
+ if (env.GITHUB_RUN_ID)
37
+ p.runId = env.GITHUB_RUN_ID;
38
+ return p;
39
+ }
29
40
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
30
41
  /** Resize a captured PNG to <=1100px wide JPEG. A missing capture becomes a
31
42
  * labelled placeholder so the manual still builds, and is recorded in `missing`. */
@@ -63,8 +74,22 @@ function fileUri(a, file, assets) {
63
74
  writeFileSync(join(assets.dir, name), a.kind === "svg" ? Buffer.from(a.text) : a.bytes);
64
75
  return `${assets.href ?? "images"}/${name}`;
65
76
  }
77
+ /** The footer every export carries: provenance first, then the way out. */
78
+ function footerHtml(spec, provenance) {
79
+ const facts = [];
80
+ if (provenance.version)
81
+ facts.push(`version ${esc(provenance.version)}`);
82
+ facts.push(`built ${esc(provenance.builtAt ?? new Date().toISOString())}`);
83
+ if (provenance.sha)
84
+ facts.push(`source <span class="mono">${esc(provenance.sha)}</span>`);
85
+ if (provenance.runId)
86
+ facts.push(`run <span class="mono">${esc(provenance.runId)}</span>`);
87
+ const links = (spec.links ?? []).map((l) => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join("");
88
+ return `<footer class="prov"><div class="facts">${esc(spec.brand)} &middot; ${facts.join(" &middot; ")}</div>${links ? `<div class="out">${links}</div>` : ""}</footer>`;
89
+ }
66
90
  export function buildManual(spec, opts) {
67
91
  const { docsRoot, assets } = opts;
92
+ const provenance = { builtAt: new Date().toISOString(), ...opts.provenance };
68
93
  const missing = [];
69
94
  // Each shot is loaded (resized) at most once, then rendered inline for the
70
95
  // fragment and — when `assets` is set — as a sidecar file for the standalone.
@@ -79,9 +104,15 @@ export function buildManual(spec, opts) {
79
104
  return a;
80
105
  };
81
106
  const inline = (slug, file) => inlineUri(load(slug, file));
82
- const external = (slug, file) => fileUri(load(slug, file), file, assets);
107
+ const sidecarHrefs = {};
108
+ const external = (slug, file) => {
109
+ const href = fileUri(load(slug, file), file, assets);
110
+ sidecarHrefs[`${slug}/${file}`] = href;
111
+ return href;
112
+ };
83
113
  const FN = spec.functionality;
84
114
  const EX = spec.examples;
115
+ const DOCS = spec.documents ?? [];
85
116
  const ALL = [...FN, ...EX];
86
117
  const byId = new Map(ALL.map((p) => [p.id, p]));
87
118
  const render = (uri) => {
@@ -108,24 +139,37 @@ export function buildManual(spec, opts) {
108
139
  <p class="lead">${esc(p.intro)}</p>
109
140
  <ol class="steps">${steps}</ol>
110
141
  ${thin}${links}
142
+ </article>`;
143
+ }
144
+ function documentHtml(d) {
145
+ return `<article class="page doc" id="${d.id}">
146
+ <div class="phead"><span class="kind doc">Document</span><h1>${esc(d.title)}</h1></div>
147
+ <div class="prose">${marked.parse(d.markdown, { async: false })}</div>
111
148
  </article>`;
112
149
  }
113
150
  const ov = spec.overview;
151
+ const hero = ov.hero
152
+ ? `\n <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>`
153
+ : "";
154
+ const linklist = (pages) => `<div class="linklist">${pages.map((p) => `<a href="#${p.id}">${esc(p.nav)}</a>`).join("")}</div>`;
155
+ const cols = [
156
+ FN.length ? `<div><h2>${esc(ov.byFunctionHeading ?? "By function")}</h2>${linklist(FN)}</div>` : "",
157
+ EX.length
158
+ ? `<div><h2>${esc(ov.byExampleHeading ?? "By example")}</h2><p class="mini">${esc(ov.byExampleIntro)}</p>${linklist(EX)}</div>`
159
+ : "",
160
+ DOCS.length ? `<div><h2>Documents</h2>${linklist(DOCS)}</div>` : "",
161
+ ].filter(Boolean);
114
162
  const overview = `<article class="page" id="overview">
115
163
  <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
116
- <p class="lead">${esc(ov.lead)}</p>
117
- <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>
118
- <div class="twocol">
119
- <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>
120
- <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>
121
- </div>
164
+ <p class="lead">${esc(ov.lead)}</p>${hero}
165
+ ${cols.length ? `<div class="twocol">\n ${cols.join("\n ")}\n </div>` : ""}
122
166
  </article>`;
167
+ const navGroup = (head, pages) => pages.length
168
+ ? `\n <div class="ngroup"><div class="ghead">${head}</div>${pages.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>`
169
+ : "";
123
170
  const nav = `
124
- <a href="#overview" data-nav class="ovlink">Overview</a>
125
- <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
126
- <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>`;
127
- const html = `<title>${esc(spec.title)}</title>
128
- <style>
171
+ <a href="#overview" data-nav class="ovlink">Overview</a>${navGroup("Functionality", FN)}${navGroup(`Examples <span class="ct">${EX.length}</span>`, EX)}${navGroup("Documents", DOCS)}`;
172
+ const html = `<style>
129
173
  :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
130
174
  --line:#212a36;--accent:#54d6cf;--measure:64ch}
131
175
  @media (prefers-color-scheme:light){:root{--bg:#f5f7f9;--bg2:#eef1f4;--surf:#fff;--ink:#0f1620;
@@ -156,7 +200,18 @@ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
156
200
  @media (prefers-reduced-motion:reduce){.page{animation:none}}
157
201
  .phead{margin-bottom:20px}
158
202
  .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}
159
- .kind.ex{color:var(--dim)}.kind.ov{color:var(--faint)}
203
+ .kind.ex{color:var(--dim)}.kind.ov{color:var(--faint)}.kind.doc{color:var(--dim)}
204
+ .prose{max-width:var(--measure)}
205
+ .prose h2{font-size:22px;margin:36px 0 10px;letter-spacing:-.015em}
206
+ .prose h3{font-size:17px;margin:26px 0 8px}
207
+ .prose p,.prose li{color:var(--dim);font-size:15px}
208
+ .prose code{font-family:ui-monospace,Menlo,monospace;font-size:.92em;background:var(--surf);border:1px solid var(--line);border-radius:4px;padding:1px 5px}
209
+ .prose pre{background:var(--surf);border:1px solid var(--line);border-radius:9px;padding:14px 16px;overflow-x:auto;font-size:13px}
210
+ .prose pre code{background:none;border:none;padding:0}
211
+ .prose table{border-collapse:collapse;width:100%;font-size:14px;margin:16px 0}
212
+ .prose th,.prose td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
213
+ .prose th{color:var(--ink);font-weight:600}
214
+ .prose a{color:var(--accent)}
160
215
  .phead h1{font-size:clamp(26px,4vw,42px);line-height:1.05;letter-spacing:-.028em;margin:0;font-weight:730;text-wrap:balance}
161
216
  .ptag{margin-top:12px;font-size:12px;color:var(--faint)}
162
217
  .lead{font-size:clamp(15px,1.9vw,18px);color:var(--dim);max-width:var(--measure);margin:0 0 8px}
@@ -185,6 +240,10 @@ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
185
240
  .linklist{display:flex;flex-direction:column;gap:2px}
186
241
  .linklist a{text-decoration:none;color:var(--dim);font-size:14px;padding:5px 0}
187
242
  .linklist a:hover{color:var(--accent)}
243
+ .prov{margin:0 0 48px;padding-top:18px;border-top:1px solid var(--line);font-size:12px;color:var(--faint);display:flex;flex-wrap:wrap;gap:8px 24px;justify-content:space-between}
244
+ .prov .out{display:flex;flex-wrap:wrap;gap:14px}
245
+ .prov a{color:var(--dim);text-decoration:none}
246
+ .prov a:hover{color:var(--accent)}
188
247
  @media (max-width:880px){.wrap{grid-template-columns:1fr}
189
248
  aside{position:static;height:auto;border-right:none;border-bottom:1px solid var(--line)}
190
249
  .twocol{grid-template-columns:1fr}}
@@ -195,6 +254,8 @@ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
195
254
  <main>
196
255
  ${overview}
197
256
  ${ALL.map(pageHtml).join("\n")}
257
+ ${DOCS.map(documentHtml).join("\n")}
258
+ ${footerHtml(spec, provenance)}
198
259
  </main>
199
260
  </div>
200
261
 
@@ -223,12 +284,17 @@ ${ALL.map(pageHtml).join("\n")}
223
284
  ${body}
224
285
  </body>
225
286
  </html>`;
287
+ // The Artifact tool supplies the head, so the fragment carries its own
288
+ // <title>; the standalone wraps the BODY alone, so the title appears once.
289
+ const fragment = (body) => `<title>${esc(spec.title)}</title>\n${body}`;
226
290
  // Fragment and standalone are both inlined (Artifact CSP; publishable on its
227
291
  // own). Sidecars are an EXTRA output, never a substitution.
228
- const indexHtml = render(inline);
229
- const standaloneHtml = document(indexHtml);
292
+ const body = render(inline);
293
+ const indexHtml = fragment(body);
294
+ const standaloneHtml = document(body);
230
295
  if (!assets)
231
296
  return { indexHtml, standaloneHtml, missing };
232
297
  mkdirSync(assets.dir, { recursive: true });
233
- return { indexHtml, standaloneHtml, standaloneAssetHtml: document(render(external)), missing };
298
+ const standaloneAssetHtml = document(render(external));
299
+ return { indexHtml, standaloneHtml, standaloneAssetHtml, missing, sidecarHrefs };
234
300
  }
@@ -0,0 +1,37 @@
1
+ import { type BuildManualResult, type ManualLink, type ManualSpec, type Provenance } from "./build-manual.js";
2
+ export declare const BUNDLE_SCHEMA_VERSION = 1;
3
+ export interface BundlePageEntry {
4
+ id: string;
5
+ nav: string;
6
+ title: string;
7
+ kind: "overview" | "fn" | "ex" | "doc";
8
+ /** Relative to the bundle directory. */
9
+ file: string;
10
+ }
11
+ export interface BundleManifest {
12
+ schemaVersion: number;
13
+ brand: string;
14
+ title: string;
15
+ provenance: Provenance;
16
+ links: ManualLink[];
17
+ pages: BundlePageEntry[];
18
+ /** Derived single-page exports, relative to the bundle directory. */
19
+ exports: {
20
+ manual: string;
21
+ manualLinked: string;
22
+ };
23
+ /** `<slug>/<file>` of every referenced capture that was not on disk — a
24
+ * bundle with gaps is still a bundle, but a consumer can see them. */
25
+ missing: string[];
26
+ }
27
+ export interface BuildBundleOptions {
28
+ docsRoot: string;
29
+ /** Where the bundle is written; created if absent. */
30
+ outDir: string;
31
+ provenance?: Provenance;
32
+ }
33
+ export interface BuildBundleResult extends BuildManualResult {
34
+ manifest: BundleManifest;
35
+ outDir: string;
36
+ }
37
+ export declare function buildBundle(spec: ManualSpec, opts: BuildBundleOptions): BuildBundleResult;
@@ -0,0 +1,105 @@
1
+ // The docs bundle: what a product's docs build produces, and the unit every
2
+ // downstream consumer reads (ADR-0012). A directory holding
3
+ //
4
+ // bundle.json this manifest — identity, provenance, the page list
5
+ // pages/<id>.md one markdown page per manual page, captures by path
6
+ // captures/<file> every referenced capture, resized (or a labelled gap)
7
+ // manual.html derived: the self-contained single-page export
8
+ // manual-linked.html derived: the same page referencing captures/
9
+ //
10
+ // The product image bakes this directory in and serves it at /docs; the public
11
+ // plane ingests pages/*.md. `ManualSpec` stays the authoring input — the
12
+ // bundle is that spec promoted to a contract, with the captures alongside.
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { buildManual, } from "./build-manual.js";
16
+ export const BUNDLE_SCHEMA_VERSION = 1;
17
+ const yamlString = (s) => JSON.stringify(s);
18
+ export function buildBundle(spec, opts) {
19
+ const { docsRoot, outDir } = opts;
20
+ const provenance = { builtAt: new Date().toISOString(), ...opts.provenance };
21
+ mkdirSync(join(outDir, "pages"), { recursive: true });
22
+ const manual = buildManual(spec, {
23
+ docsRoot,
24
+ provenance,
25
+ assets: { dir: join(outDir, "captures"), href: "captures" },
26
+ });
27
+ const hrefs = manual.sidecarHrefs ?? {};
28
+ // Pages live one level down, so a capture href is reached via `../`.
29
+ const captureRef = (slug, file) => `../${hrefs[`${slug}/${file}`]}`;
30
+ const all = [...spec.functionality, ...spec.examples];
31
+ const docs = spec.documents ?? [];
32
+ const byId = new Map(all.map((p) => [p.id, p]));
33
+ const pageLink = (id) => {
34
+ const t = byId.get(id);
35
+ return t ? `[${t.nav}](${id}.md)` : "";
36
+ };
37
+ const pageMarkdown = (p) => {
38
+ const head = [
39
+ "---",
40
+ `title: ${yamlString(p.title)}`,
41
+ `nav: ${yamlString(p.nav)}`,
42
+ `kind: ${p.kind}`,
43
+ ...(p.tag ? [`tag: ${yamlString(p.tag)}`] : []),
44
+ "---",
45
+ ];
46
+ const steps = p.steps.map((s, i) => `### ${String(i + 1).padStart(2, "0")} ${s.head}\n\n${s.desc}\n\n![${s.head}](${captureRef(s.slug, s.file)})`);
47
+ const links = p.links.map(pageLink).filter(Boolean);
48
+ const related = links.length ? [`${p.kind === "fn" ? "Seen in" : "Uses"}: ${links.join(", ")}`] : [];
49
+ return [...head, "", p.intro, "", ...steps.flatMap((s) => [s, ""]), ...related].join("\n").trimEnd().concat("\n");
50
+ };
51
+ const ov = spec.overview;
52
+ const overviewMarkdown = [
53
+ "---",
54
+ `title: ${yamlString(ov.headline)}`,
55
+ `nav: "Overview"`,
56
+ "kind: overview",
57
+ "---",
58
+ "",
59
+ ov.lead,
60
+ "",
61
+ ...(ov.hero
62
+ ? [`![${ov.hero.alt}](${captureRef(ov.hero.slug, ov.hero.file)})`, "", `_${ov.hero.caption}_`, ""]
63
+ : []),
64
+ ...(spec.functionality.length
65
+ ? [`## ${ov.byFunctionHeading ?? "By function"}`, "", ...spec.functionality.map((p) => `- ${pageLink(p.id)}`), ""]
66
+ : []),
67
+ ...(spec.examples.length
68
+ ? [
69
+ `## ${ov.byExampleHeading ?? "By example"}`,
70
+ "",
71
+ ov.byExampleIntro,
72
+ "",
73
+ ...spec.examples.map((p) => `- ${pageLink(p.id)}`),
74
+ "",
75
+ ]
76
+ : []),
77
+ ...(docs.length ? ["## Documents", "", ...docs.map((d) => `- [${d.nav}](${d.id}.md)`), ""] : []),
78
+ ].join("\n");
79
+ const documentMarkdown = (d) => ["---", `title: ${yamlString(d.title)}`, `nav: ${yamlString(d.nav)}`, "kind: doc", "---", "", d.markdown].join("\n");
80
+ const pages = [
81
+ { id: "overview", nav: "Overview", title: ov.headline, kind: "overview", file: "pages/overview.md" },
82
+ ...all.map((p) => ({ id: p.id, nav: p.nav, title: p.title, kind: p.kind, file: `pages/${p.id}.md` })),
83
+ ...docs.map((d) => ({ id: d.id, nav: d.nav, title: d.title, kind: "doc", file: `pages/${d.id}.md` })),
84
+ ];
85
+ writeFileSync(join(outDir, "pages/overview.md"), overviewMarkdown);
86
+ for (const p of all)
87
+ writeFileSync(join(outDir, `pages/${p.id}.md`), pageMarkdown(p));
88
+ for (const d of docs)
89
+ writeFileSync(join(outDir, `pages/${d.id}.md`), documentMarkdown(d));
90
+ const exports = { manual: "manual.html", manualLinked: "manual-linked.html" };
91
+ writeFileSync(join(outDir, exports.manual), manual.standaloneHtml);
92
+ writeFileSync(join(outDir, exports.manualLinked), manual.standaloneAssetHtml ?? manual.standaloneHtml);
93
+ const manifest = {
94
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
95
+ brand: spec.brand,
96
+ title: spec.title,
97
+ provenance,
98
+ links: spec.links ?? [],
99
+ pages,
100
+ exports,
101
+ missing: manual.missing,
102
+ };
103
+ writeFileSync(join(outDir, "bundle.json"), `${JSON.stringify(manifest, null, 2)}\n`);
104
+ return { ...manual, manifest, outDir };
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -10,6 +10,7 @@
10
10
  "./docs/path-existence": "./docs/path-existence.ts",
11
11
  "./doc-guide": "./doc-guide/doc-guide.js",
12
12
  "./doc-guide/build-manual": "./doc-guide/build-manual.js",
13
+ "./doc-guide/bundle": "./doc-guide/bundle.js",
13
14
  "./doc-guide/guides-config": "./doc-guide/guides-config.js",
14
15
  "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
15
16
  "./doc-guide/live-browser": "./doc-guide/live-browser.js"
@@ -18,7 +19,8 @@
18
19
  "ctl-dev-kit": "./create-product/cli.ts"
19
20
  },
20
21
  "dependencies": {
21
- "@norskvideo/ctl-sdk": "^0.1.0"
22
+ "@norskvideo/ctl-sdk": "^0.1.0",
23
+ "marked": "18.0.11"
22
24
  },
23
25
  "peerDependencies": {
24
26
  "@playwright/test": "*"