@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.
- package/build/Dockerfile.bundle +5 -0
- package/build/build-bundle-image.sh +8 -1
- package/conventions/build-docs.yml +147 -0
- package/conventions/check-drift.ts +70 -2
- package/conventions/docs.md +41 -19
- package/conventions/sync-drift.ts +7 -0
- package/doc-guide/build-manual.d.ts +48 -4
- package/doc-guide/build-manual.js +82 -16
- package/doc-guide/bundle.d.ts +37 -0
- package/doc-guide/bundle.js +105 -0
- package/package.json +4 -2
package/build/Dockerfile.bundle
CHANGED
|
@@ -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
|
|
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);
|
package/conventions/docs.md
CHANGED
|
@@ -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
|
|
99
|
-
(`ManualSpec`: brand, overview, functionality[], examples[]
|
|
100
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
108
|
+
export interface BuildManualOptions {
|
|
67
109
|
docsRoot: string;
|
|
68
110
|
assets?: ManualAssets;
|
|
69
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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)} · ${facts.join(" · ")}</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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
const
|
|
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
|
-
|
|
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})`);
|
|
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.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.
|
|
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": "*"
|