@norskvideo/ctl-dev-kit 0.1.56 → 0.1.58

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.
@@ -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);
@@ -128,19 +128,28 @@ pulls them in an optional `scripts/doc-guide/prepull-extra.sh`.
128
128
 
129
129
  ## The publish contract
130
130
 
131
- Latest-only, republished by ctl:
132
-
133
- - Each product's nightly (`publish-docs.yml`, single-sourced + drift-gated)
134
- rebuilds the manual to ONE self-contained `manual.html` and publishes it as the
135
- asset of a **moving `docs-latest` GitHub Release** — never versioned, its single
136
- asset clobbered each night. Stable URL:
137
- `https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html`.
138
- - **norsk-ctl's docs build fetches each registered product's `docs-latest` asset
139
- and mounts it at `/products/<slug>/`**, deploying the one Cloudflare Pages
140
- project so ctl republishes every product's manual whenever it builds its docs.
141
- A true per-product subdir on ctl's site only works by ctl aggregating, because a
142
- Pages deploy is whole-site.
143
-
144
- Two files back the contract: `manual.html` (the standalone document publish
145
- THIS, not the Artifact fragment `index.html`) and, on the ctl side, the product
146
- 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"),
@@ -19,7 +19,7 @@ inputs:
19
19
  description: "Product key: commentary | playout | probe | studio | funke | reuters."
20
20
  required: true
21
21
  pipeline:
22
- description: "Pipeline key: build-image | integration | drift | nightly."
22
+ description: "Pipeline key: build-image | integration | checks | nightly | dev-kit | smoke | docs."
23
23
  required: true
24
24
  status:
25
25
  description: "success or failure."
@@ -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;
@@ -41,6 +42,15 @@ export interface ManualLink {
41
42
  label: string;
42
43
  href: string;
43
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
+ }
44
54
  export interface ManualSpec {
45
55
  /** Sidebar wordmark + document brand. */
46
56
  brand: string;
@@ -49,6 +59,8 @@ export interface ManualSpec {
49
59
  overview: ManualOverview;
50
60
  functionality: ManualPage[];
51
61
  examples: ManualPage[];
62
+ /** Prose pages, listed after the walkthroughs. */
63
+ documents?: ManualDocument[];
52
64
  /** Outbound links for the footer. Optional, but every published manual should
53
65
  * carry at least one. */
54
66
  links?: ManualLink[];
@@ -26,6 +26,7 @@
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";
29
30
  /** Provenance as a GitHub Actions run sees it (GITHUB_SHA / GITHUB_RUN_ID), or
30
31
  * as much of it as a local build has. */
31
32
  export function provenanceFromEnv(env = process.env) {
@@ -111,6 +112,7 @@ export function buildManual(spec, opts) {
111
112
  };
112
113
  const FN = spec.functionality;
113
114
  const EX = spec.examples;
115
+ const DOCS = spec.documents ?? [];
114
116
  const ALL = [...FN, ...EX];
115
117
  const byId = new Map(ALL.map((p) => [p.id, p]));
116
118
  const render = (uri) => {
@@ -137,22 +139,36 @@ export function buildManual(spec, opts) {
137
139
  <p class="lead">${esc(p.intro)}</p>
138
140
  <ol class="steps">${steps}</ol>
139
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>
140
148
  </article>`;
141
149
  }
142
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);
143
162
  const overview = `<article class="page" id="overview">
144
163
  <div class="phead"><span class="kind ov">Overview</span><h1>${esc(ov.headline)}</h1></div>
145
- <p class="lead">${esc(ov.lead)}</p>
146
- <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>
147
- <div class="twocol">
148
- <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>
149
- <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>
150
- </div>
164
+ <p class="lead">${esc(ov.lead)}</p>${hero}
165
+ ${cols.length ? `<div class="twocol">\n ${cols.join("\n ")}\n </div>` : ""}
151
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
+ : "";
152
170
  const nav = `
153
- <a href="#overview" data-nav class="ovlink">Overview</a>
154
- <div class="ngroup"><div class="ghead">Functionality</div>${FN.map((p) => `<a href="#${p.id}" data-nav>${esc(p.nav)}</a>`).join("")}</div>
155
- <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>`;
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)}`;
156
172
  const html = `<style>
157
173
  :root{--bg:#0a0d12;--bg2:#0c1118;--surf:#131923;--ink:#eaeff5;--dim:#939dab;--faint:#5f6a79;
158
174
  --line:#212a36;--accent:#54d6cf;--measure:64ch}
@@ -184,7 +200,18 @@ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
184
200
  @media (prefers-reduced-motion:reduce){.page{animation:none}}
185
201
  .phead{margin-bottom:20px}
186
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}
187
- .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)}
188
215
  .phead h1{font-size:clamp(26px,4vw,42px);line-height:1.05;letter-spacing:-.028em;margin:0;font-weight:730;text-wrap:balance}
189
216
  .ptag{margin-top:12px;font-size:12px;color:var(--faint)}
190
217
  .lead{font-size:clamp(15px,1.9vw,18px);color:var(--dim);max-width:var(--measure);margin:0 0 8px}
@@ -227,6 +254,7 @@ main{min-width:0;padding:0 clamp(20px,5vw,72px)}
227
254
  <main>
228
255
  ${overview}
229
256
  ${ALL.map(pageHtml).join("\n")}
257
+ ${DOCS.map(documentHtml).join("\n")}
230
258
  ${footerHtml(spec, provenance)}
231
259
  </main>
232
260
  </div>
@@ -4,7 +4,7 @@ export interface BundlePageEntry {
4
4
  id: string;
5
5
  nav: string;
6
6
  title: string;
7
- kind: "overview" | "fn" | "ex";
7
+ kind: "overview" | "fn" | "ex" | "doc";
8
8
  /** Relative to the bundle directory. */
9
9
  file: string;
10
10
  }
@@ -28,6 +28,7 @@ export function buildBundle(spec, opts) {
28
28
  // Pages live one level down, so a capture href is reached via `../`.
29
29
  const captureRef = (slug, file) => `../${hrefs[`${slug}/${file}`]}`;
30
30
  const all = [...spec.functionality, ...spec.examples];
31
+ const docs = spec.documents ?? [];
31
32
  const byId = new Map(all.map((p) => [p.id, p]));
32
33
  const pageLink = (id) => {
33
34
  const t = byId.get(id);
@@ -57,28 +58,35 @@ export function buildBundle(spec, opts) {
57
58
  "",
58
59
  ov.lead,
59
60
  "",
60
- `![${ov.hero.alt}](${captureRef(ov.hero.slug, ov.hero.file)})`,
61
- "",
62
- `_${ov.hero.caption}_`,
63
- "",
64
- `## ${ov.byFunctionHeading ?? "By function"}`,
65
- "",
66
- ...spec.functionality.map((p) => `- ${pageLink(p.id)}`),
67
- "",
68
- `## ${ov.byExampleHeading ?? "By example"}`,
69
- "",
70
- ov.byExampleIntro,
71
- "",
72
- ...spec.examples.map((p) => `- ${pageLink(p.id)}`),
73
- "",
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)`), ""] : []),
74
78
  ].join("\n");
79
+ const documentMarkdown = (d) => ["---", `title: ${yamlString(d.title)}`, `nav: ${yamlString(d.nav)}`, "kind: doc", "---", "", d.markdown].join("\n");
75
80
  const pages = [
76
81
  { id: "overview", nav: "Overview", title: ov.headline, kind: "overview", file: "pages/overview.md" },
77
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` })),
78
84
  ];
79
85
  writeFileSync(join(outDir, "pages/overview.md"), overviewMarkdown);
80
86
  for (const p of all)
81
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));
82
90
  const exports = { manual: "manual.html", manualLinked: "manual-linked.html" };
83
91
  writeFileSync(join(outDir, exports.manual), manual.standaloneHtml);
84
92
  writeFileSync(join(outDir, exports.manualLinked), manual.standaloneAssetHtml ?? manual.standaloneHtml);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -19,7 +19,8 @@
19
19
  "ctl-dev-kit": "./create-product/cli.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@norskvideo/ctl-sdk": "^0.1.0"
22
+ "@norskvideo/ctl-sdk": "^0.1.0",
23
+ "marked": "18.0.11"
23
24
  },
24
25
  "peerDependencies": {
25
26
  "@playwright/test": "*"