@norskvideo/ctl-dev-kit 0.2.3 → 0.2.4

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.
@@ -21,6 +21,7 @@ import { execFileSync } from "node:child_process";
21
21
  import { existsSync, readFileSync, statSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
24
+ import { checkTheme } from "../docs-theme/sync.ts";
24
25
 
25
26
  export interface DriftReport {
26
27
  ok: boolean;
@@ -366,6 +367,34 @@ function verbatimProblem(
366
367
  // absent ⇒ `studio`, so existing repos are unaffected.
367
368
  export type ProductShape = "studio" | "sdk-app";
368
369
 
370
+ /** The docs site, when a repo has one: the directory holding `astro.config.mjs`.
371
+ * One name across the fleet (norsk-ctl's and the Manager's both), so the gate
372
+ * can find a site without being told. */
373
+ export const DOCS_SITE_DIR = "website";
374
+
375
+ /** `<repo>/website/astro.config.mjs` — the site's existence test. A docs site is
376
+ * a NESTED standalone package a repo may or may not have, which is why the
377
+ * theme is gated conditionally while every other convention file is demanded
378
+ * outright. */
379
+ export function hasDocsSite(repoRoot: string): boolean {
380
+ return existsSync(join(repoRoot, DOCS_SITE_DIR, "astro.config.mjs"));
381
+ }
382
+
383
+ /** The shared docs theme's copies inside a repo's docs site, as gate problems.
384
+ * The theme has its own writer/checker pair (docs-theme/sync.ts) because it
385
+ * predates this clause and a site in THIS monorepo runs it directly; routing it
386
+ * through here as well is what makes `bun run check:drift` sufficient for a
387
+ * product repo, which has no second suite to hang it off. */
388
+ export function docsThemeProblems(repoRoot: string, themeCanonical?: string): string[] {
389
+ if (!hasDocsSite(repoRoot)) return [];
390
+ const site = join(repoRoot, DOCS_SITE_DIR);
391
+ const { problems } = themeCanonical === undefined ? checkTheme(site) : checkTheme(site, themeCanonical);
392
+ // Paths are reported site-relative by the theme's own checker, so prefix them
393
+ // — a product repo reads them from its root — and name the repo script that
394
+ // fixes them, which the theme's message cannot know.
395
+ return problems.map((p) => `${DOCS_SITE_DIR}/${p} (\`bun run sync:drift\` rewrites it.)`);
396
+ }
397
+
369
398
  export interface CheckDriftOptions {
370
399
  /** The GitHub repo name (`norsk-ctl-product-playout`), which decides whether the
371
400
  * public docs variant may be carried. The CLI resolves it ({@link resolveRepoName});
@@ -373,6 +402,9 @@ export interface CheckDriftOptions {
373
402
  repoName?: string;
374
403
  /** The product shape ({@link resolveShape}); a fixture passes it. Absent ⇒ `studio`. */
375
404
  shape?: ProductShape;
405
+ /** The canonical docs-theme directory. Defaults to this package's own, which
406
+ * is what a product repo wants; a fixture passes one. */
407
+ themeCanonical?: string;
376
408
  }
377
409
 
378
410
  /** A product's shape from `package.json` `ctlProduct.shape`; `studio` when the
@@ -697,6 +729,13 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes, opts: Ch
697
729
  }
698
730
  }
699
731
 
732
+ // The docs site's theme (docs-theme/): gated only when the repo HAS a site,
733
+ // since that is a nested standalone package rather than a repo-root file. A
734
+ // site in this monorepo also runs the theme gate from its own test suite; a
735
+ // product repo has no such suite, so check:drift is where the copies get
736
+ // checked, and one clause covers both.
737
+ for (const p of docsThemeProblems(repoRoot, opts.themeCanonical)) push(p);
738
+
700
739
  for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
701
740
 
702
741
  return { ok: problems.length === 0, problems };
@@ -0,0 +1,185 @@
1
+ # The product docs site (shared across all Norsk ctl product repos)
2
+
3
+ What a product repo needs in order to have a **docs site** — the public manual at
4
+ `<product>.norsk.video`, an Astro + Starlight package on the shared Norsk theme.
5
+ This is the shared half; the per-product half is the content.
6
+
7
+ Like [`docs.md`](./docs.md) and [`personas.md`](./personas.md) this is a **shipped
8
+ reference**, not a drift-gated copy: read it from
9
+ `node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md`. What IS gated is
10
+ the theme itself — see "The theme is a copy, and the gate is what makes that
11
+ safe" below.
12
+
13
+ **`create-product` renders all of it.** A repo generated by
14
+ `bunx @norskvideo/ctl-dev-kit create-product` comes out with a `website/` that
15
+ builds, so the rest of this document is for a repo that predates the generator,
16
+ or for understanding what you have.
17
+
18
+ ## Two documentation channels, and which is which
19
+
20
+ A product has two, on purpose, and they are not competing drafts:
21
+
22
+ | | **The illustrated manual** ([`docs.md`](./docs.md)) | **The docs site** (this document) |
23
+ | -------------- | --------------------------------------------------------- | ----------------------------------------------------- |
24
+ | What it is | one hash-routed HTML page + a bundle of markdown/captures | a multi-page Starlight site |
25
+ | Where it lives | `docs/generated/bundle/`, baked into the product image | `website/`, published to `<product>.norsk.video` |
26
+ | How it's read | at `/docs` on the running container, through the daemon | on the open internet, without the product in hand |
27
+ | Version | exactly the version running — that is its whole point | the tip; it says which version it was built against |
28
+ | Cadence | per image | per commit to main |
29
+ | Reader | an operator who already has it | an evaluator, and anyone without a deployment to hand |
30
+
31
+ The bundle is the **version-true** channel and the only channel a turnkey has
32
+ (ADR-0012). The site is the **discoverable** channel. A product with a public
33
+ audience wants both; a turnkey wants only the bundle and should not carry a site.
34
+
35
+ ## What the repo owns, and what the theme owns
36
+
37
+ A site is deliberately thin. It owns **four** things:
38
+
39
+ | The site's own | Where |
40
+ | --------------------------------- | ----------------------------- |
41
+ | its canonical origin | `website/src/lib/site.ts` |
42
+ | its two names + sidebar | `website/astro.config.mjs` |
43
+ | the versions it was built against | `website/src/lib/versions.ts` |
44
+ | its content | `website/src/content/docs/` |
45
+
46
+ Everything else comes from the shared theme (`docs-theme/`): the palette, the
47
+ self-hosted Geist faces, the three Starlight component overrides, the content
48
+ schema, the favicon, and `norskDocsSite()` — the whole Astro config bar those
49
+ four. So `astro.config.mjs` is a sidebar plus five lines:
50
+
51
+ ```js
52
+ import { defineConfig } from "astro/config";
53
+ import { norskDocsSite } from "./src/starlight.ts";
54
+ import { PUBLIC_SITE_ORIGIN } from "./src/lib/site.ts";
55
+
56
+ export default defineConfig(
57
+ norskDocsSite({
58
+ origin: PUBLIC_SITE_ORIGIN,
59
+ tabTitle: "Norsk", // browser-tab suffix: the FAMILY
60
+ wordmark: "norsk-acme", // masthead: the product in hand
61
+ buildVersions: [{ label: "norsk-acme", value: version }],
62
+ sidebar: [...],
63
+ }),
64
+ );
65
+ ```
66
+
67
+ **The two names are different on purpose.** Several Norsk manuals open in one tab
68
+ strip, and tabs truncate, so the tab suffix is the family; the masthead names the
69
+ binary or product the reader is holding.
70
+
71
+ ### The five sections
72
+
73
+ Start here · Guides · How-to · Concepts · Reference
74
+
75
+ Every Norsk manual uses this top level — norsk-ctl's, the Manager's, and every
76
+ generated product's — so a reader who has learned one can navigate the next. Who
77
+ each section serves, in the vocabulary of [`personas.md`](./personas.md):
78
+
79
+ - **Start here** — the Evaluator. What this does, and where to go next.
80
+ - **Guides** — the Builder. One task, start to finish, in the order it is done.
81
+ These are the pages worth **capturing** rather than illustrating; see
82
+ [`docs.md`](./docs.md) for the toolchain and the `capturedAgainst` front matter
83
+ the theme renders at the foot of such a page.
84
+ - **How-to** — the Administrator. Looked up, repeatedly, while running it.
85
+ - **Concepts** — the vocabulary the rest of the manual uses. Not architecture.
86
+ - **Reference** — the Integrator. Exhaustive and generated from the source of
87
+ truth where possible.
88
+
89
+ Add sections below these if a product genuinely needs them. Do not rename or
90
+ reorder these five: the shape is the shared part.
91
+
92
+ ## The theme is a copy, and the gate is what makes that safe
93
+
94
+ The theme reaches a site as **files copied into it**, not as an import, and
95
+ `docs-theme/README.md` has the measurement behind that: a docs site is a
96
+ standalone package with its own install, so it cannot depend on this one — a
97
+ `file:` dep fails on this package's own `workspace:*` deps.
98
+
99
+ website/src/starlight.ts website/src/components/*.astro
100
+ website/src/custom.css website/src/fonts/*.woff2
101
+ website/src/content.config.ts website/public/favicon.png
102
+
103
+ **Never hand-edit one.** A change to any of them belongs in the dev-kit, where it
104
+ reaches every product's manual at once.
105
+
106
+ - `bun run check:drift` fails when a copy has drifted, naming the file and the
107
+ line. It is the same gate, and the same CI job, that holds `biome.json` and the
108
+ shared workflows — the clause is conditional on `website/astro.config.mjs`
109
+ existing, so a repo without a site is not asked for one.
110
+ - `bun run sync:drift` rewrites the copies from the installed dev-kit. The
111
+ nightly `sync-dev-kit.yml` runs it after bumping the pin, so a theme change
112
+ rolls out hands-free and only a change that genuinely breaks the site goes red.
113
+
114
+ **Keeping the copies at exactly those paths is load-bearing**, not tidiness:
115
+ Astro derives a component's scoped-style class from its path, so moving
116
+ `Footer.astro` rewrites a class attribute on every page of the site.
117
+
118
+ ## Adding a site to a repo that has none
119
+
120
+ `create-product` does all of this; by hand it is:
121
+
122
+ 1. **`website/package.json`** — a standalone package (`<name>-docs`), NOT a member
123
+ of the root `workspaces` array. Its dependencies are what the theme imports:
124
+ `astro`, `@astrojs/starlight`, `@astrojs/markdown-remark` (an _optional_ peer
125
+ of both, so nothing installs it for you) and `sharp`, plus `playwright` as a
126
+ dev dependency for pages that render a mermaid diagram.
127
+ 2. **`website/tsconfig.json`** — `{"extends": "astro/tsconfigs/strict"}`.
128
+ 3. **`website/astro.config.mjs`**, `website/src/lib/{site,versions}.ts` — the four
129
+ things above.
130
+ 4. **`bun run sync:drift`** to lay the theme copies down.
131
+ 5. **`website/src/content/docs/`** — at least an `index.mdx`, plus a page per
132
+ section.
133
+ 6. **Root `package.json` scripts** — `docs:dev`, `docs:site`, `docs:site:public`,
134
+ each `bun run --cwd website …`.
135
+ 7. **`.gitignore`** — `website/dist-embedded/`, `website/dist-public/`,
136
+ `website/.astro/`. The shared core block only knows about `dist/`.
137
+
138
+ Then `bun install --cwd website` (the repo-root install does NOT cover a
139
+ standalone package) and `bun run docs:site`.
140
+
141
+ ## The two builds
142
+
143
+ One content collection, two outputs, selected by `ASTRO_BUILD`:
144
+
145
+ | Build | `ASTRO_BUILD` | base | outDir | What it is |
146
+ | -------- | ------------- | ------- | --------------- | --------------------------------------------- |
147
+ | embedded | unset | `/docs` | `dist-embedded` | a bundle a product binary can embed and serve |
148
+ | public | `public` | `/` | `dist-public` | the site on its own domain |
149
+
150
+ Content is authored with absolute `/docs/...` links — right for the embedded
151
+ build; the public build strips the prefix in a post-build pass, because Astro does
152
+ not base-prefix absolute links in MDX. So **write `/docs/...` links** and let the
153
+ theme handle the other case.
154
+
155
+ A product only needs the public build. The embedded one costs nothing to keep
156
+ (the theme produces it either way) and is there for a binary that wants to serve
157
+ its own manual offline, as `norsk-ctl` and `norsk-mgr` both do — for about 1.2 MB
158
+ of compressed docs on a ~68 MB binary.
159
+
160
+ ## Publishing
161
+
162
+ Per site, and none of it is needed to build or review one locally:
163
+
164
+ 1. **A Cloudflare Pages project**, e.g. `norsk-acme-docs`.
165
+ 2. **A custom domain** on it — `<product>.norsk.video` — and the DNS record.
166
+ 3. **API token scope.** The existing `CLOUDFLARE_API_TOKEN` is deliberately narrow
167
+ (Pages:Edit). It must cover the new project: widen it to all Pages projects in
168
+ the account, or issue a second token.
169
+ 4. **A publish step** that runs the public build and `wrangler pages deploy`. Take
170
+ the project name from a repo variable rather than a literal, and skip the
171
+ deploy with a message when it is unset — so main is green before Cloudflare
172
+ exists and publishes the day the variable is set.
173
+
174
+ There is no shared `publish-docs-site.yml` in this package yet: the two existing
175
+ sites publish from norsk-ctl's own monorepo workflow. The first product repo to
176
+ publish one should bring the workflow back here and drift-gate it, the way
177
+ `publish-docs.yml` (the bundle's) already is.
178
+
179
+ ## Search does not cross products
180
+
181
+ Each site indexes itself (Pagefind), so a reader searching one product's manual
182
+ does not find another's. That is accepted rather than solved: the alternative is
183
+ one corpus and one navigation carrying six products. A hub page listing the
184
+ products is the cheap mitigation; cross-site search is possible later and nothing
185
+ here forecloses it.
@@ -20,15 +20,18 @@
20
20
  // product surfaces as a red lint/typecheck/test — not as a silent bad copy.
21
21
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
22
22
  import { dirname, join } from "node:path";
23
+ import { checkTheme, syncTheme } from "../docs-theme/sync.ts";
23
24
  import {
24
25
  BEGIN,
25
26
  type CanonicalBytes,
26
27
  CTL_HASH_LINE,
27
28
  CTL_VERSION_LINE,
28
29
  carriesIntegrationMarker,
30
+ DOCS_SITE_DIR,
29
31
  END,
30
32
  GITIGNORE_BEGIN,
31
33
  GITIGNORE_END,
34
+ hasDocsSite,
32
35
  PRODUCT_LINE,
33
36
  PRODUCT_SENTINEL,
34
37
  type ProductShape,
@@ -260,6 +263,25 @@ function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): vo
260
263
  chmodSync(path, 0o755);
261
264
  }
262
265
 
266
+ // The docs site's theme copies — the writer behind check-drift's docsThemeProblems.
267
+ // Only for a repo that HAS a site: sync never creates one (a site is a nested
268
+ // standalone package with its own install, not a file this writer can conjure),
269
+ // it only keeps an existing one's copies in step. That is what makes a theme
270
+ // change roll out through sync-dev-kit.yml like any other convention.
271
+ function syncDocsTheme(repoRoot: string, r: SyncReport): void {
272
+ if (!hasDocsSite(repoRoot)) {
273
+ r.skipped.push(`${DOCS_SITE_DIR}/ docs theme (no docs site)`);
274
+ return;
275
+ }
276
+ const site = join(repoRoot, DOCS_SITE_DIR);
277
+ // The report names what CHANGED, and syncTheme rewrites every copy
278
+ // unconditionally — so ask the gate first which ones are actually adrift. Its
279
+ // problems are `<site-relative path>: <reason>`.
280
+ const adrift = checkTheme(site).problems.map((p) => p.slice(0, p.indexOf(":")));
281
+ syncTheme(site);
282
+ for (const rel of adrift) r.written.push(`${DOCS_SITE_DIR}/${rel}`);
283
+ }
284
+
263
285
  export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: ProductShape = "studio"): SyncReport {
264
286
  const r: SyncReport = { written: [], skipped: [] };
265
287
  // Shape-selected canonicals: an sdk-app's flake + checks are its own variants
@@ -310,6 +332,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes, shape: Pr
310
332
  syncDprint(repoRoot, canonical.dprint, r);
311
333
  syncDemoShim(repoRoot, canonical.demoShim, r);
312
334
  syncWithDisplay(repoRoot, canonical.withDisplay, r);
335
+ syncDocsTheme(repoRoot, r);
313
336
 
314
337
  return r;
315
338
  }
@@ -12,7 +12,9 @@
12
12
  // The convention layer is shared verbatim across every shape.
13
13
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
+ import { syncTheme } from "../docs-theme/sync.ts";
15
16
  import { type Canon, loadAsset, loadCanon, PRODUCT_SENTINEL } from "./canon.ts";
17
+ import { SITE_DIR as DOCS_SITE_DIR } from "./docs-site.ts";
16
18
  import { turnkey } from "./turnkey.ts";
17
19
 
18
20
  // A SHAPE is a preset over FEATURES, not a separate skeleton — the four
@@ -289,5 +291,12 @@ export function createProduct(opts: CreateProductOptions): { files: string[] } {
289
291
  mkdirSync(dirname(path), { recursive: true });
290
292
  writeFileSync(path, file.content, { mode: file.executable ? 0o755 : 0o644 });
291
293
  }
292
- return { files: plan.map((f) => f.path) };
294
+ // The docs site's theme files are written by the THEME'S OWN writer, not
295
+ // rendered here: they include two woff2 faces (a plan entry is a string), and
296
+ // more to the point one copier and one gate is the whole arrangement — the
297
+ // same `syncTheme` the drift gate's writer half calls, against the same
298
+ // canonical bytes `check-drift` compares. A second rendering of those bytes
299
+ // here is exactly the drift this package exists to stop.
300
+ const themeFiles = syncTheme(join(opts.dir, DOCS_SITE_DIR)).map((rel) => `${DOCS_SITE_DIR}/${rel}`);
301
+ return { files: [...plan.map((f) => f.path), ...themeFiles] };
293
302
  }
@@ -0,0 +1,316 @@
1
+ // The product's DOCS SITE — `website/`, an Astro + Starlight package on the
2
+ // shared theme (docs-theme/), with the five-section top level every Norsk
3
+ // manual uses: Start here, Guides, How-to, Concepts, Reference.
4
+ //
5
+ // Emitted for every shape, not as a feature, because it is not optional in the
6
+ // way an operator screen is: every product has readers. What the generator
7
+ // writes is a SKELETON — five near-empty pages saying what belongs in each
8
+ // section — because a site whose sections a product must invent is a site the
9
+ // product will instead paste from somewhere else.
10
+ //
11
+ // Two things make this different from every other workspace here:
12
+ //
13
+ // 1. It is NOT in the root `workspaces` array. A docs site is a STANDALONE
14
+ // package with its own install, deliberately: Astro's dependency tree has
15
+ // no business in the product's own install, and the theme is copied rather
16
+ // than imported for exactly that reason (docs-theme/sync.ts has the bun
17
+ // error that proves it). So `bun install` at the repo root does not install
18
+ // it — `bun install --cwd website` does, and the generated README says so.
19
+ // 2. Its theme files are NOT written from here. `createProduct` lays them
20
+ // down with the theme's own writer (`syncTheme`), so there is one copier
21
+ // and one gate (`bun run check:drift`, via check-drift's docsThemeProblems)
22
+ // rather than a second rendering of the same bytes that could drift.
23
+ //
24
+ // It does NOT replace the illustrated manual (conventions/docs.md): that bundle
25
+ // is still what the image serves at `/docs` and what a public product publishes
26
+ // nightly. The two channels and the choice between them are set out in
27
+ // conventions/docs-site.md.
28
+ import type { GeneratedFile, ShapeContext } from "./create-product.ts";
29
+
30
+ /** The docs site's directory, and the name check-drift's gate looks for. */
31
+ export const SITE_DIR = "website";
32
+
33
+ /**
34
+ * What a site needs installed to build. Dictated by the theme, not by taste:
35
+ * `starlight.ts` imports `@astrojs/starlight`, `@astrojs/markdown-remark` and
36
+ * `rehype-mermaid`, and `content.config.ts` imports `astro:content`. Kept on the
37
+ * same versions as the two sites in the norsk-ctl monorepo so one theme is only
38
+ * ever exercised against one Astro major; docs-site.test.ts pins the set to the
39
+ * theme's own imports so a theme that grows a dependency cannot ship a site that
40
+ * cannot build.
41
+ */
42
+ export const SITE_DEPENDENCIES: Record<string, string> = {
43
+ // An OPTIONAL peer of both astro and starlight, so nothing installs it for
44
+ // you: `starlight.ts` builds the markdown pipeline out of it (`unified()`),
45
+ // and a site that leaves it to hoisting is one lockfile away from a build
46
+ // that cannot resolve it.
47
+ "@astrojs/markdown-remark": "^7.3.0",
48
+ "@astrojs/starlight": "^0.41.3",
49
+ astro: "^7.1.0",
50
+ "rehype-mermaid": "^3.0.0",
51
+ // Astro's image pipeline; an optional dep of astro, declared so a build never
52
+ // depends on whether it was hoisted.
53
+ sharp: "^0.33.5",
54
+ };
55
+
56
+ /** rehype-mermaid renders a diagram by driving a real browser, so a page with a
57
+ * mermaid fence needs one. Nothing else in the site does. */
58
+ export const SITE_DEV_DEPENDENCIES: Record<string, string> = { playwright: "^1.59.0" };
59
+
60
+ function packageJson(ctx: ShapeContext): string {
61
+ return `${JSON.stringify(
62
+ {
63
+ name: `${ctx.name}-docs`,
64
+ version: "0.0.1",
65
+ private: true,
66
+ type: "module",
67
+ scripts: {
68
+ dev: "astro dev",
69
+ // --force clears Astro's content cache: the collection is fed by
70
+ // symlinks into ../docs, and a cached entry outlives the file it came
71
+ // from.
72
+ build: "astro build --force",
73
+ preview: "astro preview",
74
+ },
75
+ dependencies: SITE_DEPENDENCIES,
76
+ devDependencies: SITE_DEV_DEPENDENCIES,
77
+ },
78
+ null,
79
+ 2,
80
+ )}\n`;
81
+ }
82
+
83
+ const TSCONFIG = `{
84
+ "extends": "astro/tsconfigs/strict"
85
+ }
86
+ `;
87
+
88
+ function siteTs(ctx: ShapeContext): string {
89
+ return `/** The canonical public origin of this product's docs.
90
+ *
91
+ * ONE value: Astro's \`site\` (which drives the sitemap and the canonical
92
+ * links) and anything that checks what was published read it from here rather
93
+ * than repeating a literal.
94
+ *
95
+ * The domain is NOT live until somebody creates the Cloudflare Pages project
96
+ * and the DNS record for it — see conventions/docs-site.md, "Publishing". The
97
+ * value belongs here from the start so the sitemap and the canonical links are
98
+ * right the day it is. */
99
+ export const PUBLIC_SITE_ORIGIN = "https://${ctx.dashboardKey}.norsk.video";
100
+ `;
101
+ }
102
+
103
+ const VERSIONS_TS = `/**
104
+ * The version this docs build was built against, rendered in the sidebar
105
+ * footer, so a reader can tell what the page describes.
106
+ *
107
+ * Read with node fs at build time rather than imported: the site is a
108
+ * standalone package outside the repo's workspaces, so it cannot import from
109
+ * them. \`shared/src/version.ts\` carries the same version as the runtime's own
110
+ * copy; the repo's package.json is the one a docs build can read.
111
+ */
112
+ import { readFileSync } from "node:fs";
113
+ import { join } from "node:path";
114
+
115
+ export function readProductVersion(repoRoot: string): string {
116
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { version?: string };
117
+ if (!pkg.version) throw new Error("docs versions: the repo's package.json has no version");
118
+ return pkg.version;
119
+ }
120
+ `;
121
+
122
+ function astroConfig(ctx: ShapeContext): string {
123
+ return `import { fileURLToPath } from "node:url";
124
+ import { defineConfig } from "astro/config";
125
+ // The shared Norsk docs theme. \`src/starlight.ts\`, \`src/custom.css\`,
126
+ // \`src/content.config.ts\`, \`src/components/\`, \`src/fonts/\` and
127
+ // \`public/favicon.png\` are COPIES of @norskvideo/ctl-dev-kit's docs-theme,
128
+ // rewritten by \`bun run sync:drift\` and gated by \`bun run check:drift\` —
129
+ // never hand-edited. The dev-kit's docs-theme/README.md says why a copy and not
130
+ // a dependency (this package is standalone: its install is its own, and a file:
131
+ // dep on the dev-kit cannot resolve the dev-kit's workspace deps).
132
+ import { norskDocsSite } from "./src/starlight.ts";
133
+ import { PUBLIC_SITE_ORIGIN } from "./src/lib/site.ts";
134
+ import { readProductVersion } from "./src/lib/versions.ts";
135
+
136
+ // The repo root, one level up — the docs state the version they were built
137
+ // against, read from the product's own files.
138
+ const repoRoot = fileURLToPath(new URL("..", import.meta.url));
139
+
140
+ export default defineConfig(
141
+ norskDocsSite({
142
+ origin: PUBLIC_SITE_ORIGIN,
143
+ // The BROWSER TAB suffix ("Install | Norsk") — the product FAMILY, as on
144
+ // every other Norsk manual, so several of them read as one family in a tab
145
+ // strip. The masthead below is deliberately different and names the product
146
+ // in hand.
147
+ tabTitle: "Norsk",
148
+ wordmark: "${ctx.name}",
149
+ buildVersions: [{ label: "${ctx.name}", value: readProductVersion(repoRoot) }],
150
+ // FIVE SECTIONS, the same top level as every other Norsk manual, so a
151
+ // reader who knows one can navigate this one. Keep the shape; replace the
152
+ // placeholder pages. What belongs in each is on the page itself, and the
153
+ // reader each serves is in the dev-kit's conventions/personas.md.
154
+ sidebar: [
155
+ { label: "Start here", items: [{ label: "${ctx.name}", slug: "" }] },
156
+ { label: "Guides", items: [{ label: "Overview", slug: "guides" }] },
157
+ { label: "How-to", items: [{ label: "Overview", slug: "how-to" }] },
158
+ { label: "Concepts", items: [{ label: "Overview", slug: "concepts" }] },
159
+ { label: "Reference", items: [{ label: "Overview", slug: "reference" }] },
160
+ ],
161
+ }),
162
+ );
163
+ `;
164
+ }
165
+
166
+ /** A placeholder page: real front matter, and prose that says what the section is
167
+ * for and who reads it rather than lorem ipsum a product might ship by accident. */
168
+ function page(title: string, description: string, body: string): string {
169
+ return `---
170
+ title: "${title}"
171
+ description: "${description}"
172
+ ---
173
+
174
+ ${body}`;
175
+ }
176
+
177
+ function indexMdx(ctx: ShapeContext): string {
178
+ return page(
179
+ ctx.name,
180
+ `What ${ctx.name} does and where to start.`,
181
+ `**${ctx.name}** — one paragraph saying what this product does, for someone who has just arrived and does not yet know. Lead with the capability, not the architecture.
182
+
183
+ This page is the **Evaluator's** page: it answers "does this do what I need?" in a couple of minutes, and hands off to the rest of the manual.
184
+
185
+ ## Where to start
186
+
187
+ - **See it working:** a link into [Guides](/docs/guides/), once there is a walkthrough to link to.
188
+ - **Run it somewhere real:** a link into [How-to](/docs/how-to/).
189
+ - **Understand the model:** [Concepts](/docs/concepts/) is the vocabulary the rest of the manual uses.
190
+
191
+ The four readers this manual serves — Evaluator, Builder, Integrator, Administrator — are defined in the dev-kit's
192
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/personas.md\`, and which of them each section is for is on that section's own page.
193
+ `,
194
+ );
195
+ }
196
+
197
+ const GUIDES_MDX = page(
198
+ "Guides",
199
+ "Walkthroughs: one task, start to finish, in the order a reader does it.",
200
+ `A **guide** walks one task from start to finish, in the order a reader does it, and does not stop to explain alternatives. Its reader is the **Builder** — the person standing the product up for the first time.
201
+
202
+ One page per task. Where the same task can be done more than one way (a CLI, a web UI, an HTTP API), each way is its own page under one navigation entry, because a reader has already chosen before they arrive.
203
+
204
+ Guides are the pages worth **capturing rather than illustrating**: a walkthrough generated by a test that really ran cannot describe a screen that does not exist. The dev-kit's \`conventions/docs.md\` covers the capture toolchain, and the \`capturedAgainst\` front matter the shared theme renders at the foot of such a page.
205
+
206
+ _Replace this page with the first guide, and put it in the sidebar in \`astro.config.mjs\`._
207
+ `,
208
+ );
209
+
210
+ const HOW_TO_MDX = page(
211
+ "How-to",
212
+ "Operator tasks: install, upgrade, back up, and the contracts a host must meet.",
213
+ `A **how-to** answers a question an operator arrives with — install, upgrade, back up, restore, what a host must provide — and it assumes they already know why they are doing it. Its reader is the **Administrator**.
214
+
215
+ The difference from Guides is the reader's state, not the format: a guide is read once, front to back, by someone learning the product; a how-to is looked up, repeatedly, by someone running it.
216
+
217
+ _Replace this page with the operator runbook, and put it in the sidebar in \`astro.config.mjs\`._
218
+ `,
219
+ );
220
+
221
+ const CONCEPTS_MDX = page(
222
+ "Concepts",
223
+ "The vocabulary the rest of this manual uses.",
224
+ `**Concepts** is the vocabulary — the nouns this product deals in, what each one is, and how they relate. It is not architecture, and it is not a tour of the implementation: a reader comes here because a word in a guide meant nothing to them.
225
+
226
+ Keep it to terms that appear elsewhere in the manual. A concept nothing else references is a sign the section is documenting the code rather than the product.
227
+
228
+ _Replace this page with the model, and put its pages in the sidebar in \`astro.config.mjs\`._
229
+ `,
230
+ );
231
+
232
+ const REFERENCE_MDX = page(
233
+ "Reference",
234
+ "Exhaustive, look-up-able: the API, the configuration, the limits.",
235
+ `**Reference** is the exhaustive, look-up-able material — the HTTP API, every configuration field, the limits. Its reader is the **Integrator**, who is writing something against this product and needs the whole surface rather than a path through it.
236
+
237
+ Reference pages are the ones worth generating from the source of truth (an OpenAPI document, a schema) rather than writing by hand, because a hand-written reference is wrong the first time the surface changes.
238
+
239
+ _Replace this page with the real reference, and put it in the sidebar in \`astro.config.mjs\`._
240
+ `,
241
+ );
242
+
243
+ function readmeMd(ctx: ShapeContext): string {
244
+ return `# ${ctx.name} docs site
245
+
246
+ The public manual for ${ctx.name}, an [Astro](https://astro.build) +
247
+ [Starlight](https://starlight.astro.build) site on the **shared Norsk docs
248
+ theme**. \`https://${ctx.dashboardKey}.norsk.video\` once it is published; until
249
+ then it builds and is read locally.
250
+
251
+ ## Working on it
252
+
253
+ This is a **standalone package** — its own install, outside the repo's
254
+ workspaces, because Astro's dependency tree has no business in the product's
255
+ install. So the repo-root \`bun install\` does not cover it:
256
+
257
+ \`\`\`
258
+ bun install --cwd website # once
259
+ bun run docs:dev # dev server, hot reload
260
+ bun run docs:site # build (base /docs, into website/dist-embedded)
261
+ bun run docs:site:public # build for the public domain (into website/dist-public)
262
+ \`\`\`
263
+
264
+ ## What is this site's, and what is the theme's
265
+
266
+ This site owns four things, all in \`website/astro.config.mjs\` and
267
+ \`website/src/lib/\`: its origin, its two names (the browser-tab suffix and the
268
+ masthead wordmark), its sidebar, and the version rows in the sidebar footer.
269
+ Its content is \`website/src/content/docs/\`.
270
+
271
+ Everything else — the palette, the fonts, the component overrides, the content
272
+ schema, the two-build shape — is the **shared theme**, and reaches this repo as
273
+ COPIES: \`src/starlight.ts\`, \`src/custom.css\`, \`src/content.config.ts\`,
274
+ \`src/components/\`, \`src/fonts/\` and \`public/favicon.png\`. Do not hand-edit
275
+ them: \`bun run check:drift\` fails when they drift from the installed
276
+ \`@norskvideo/ctl-dev-kit\`, and \`bun run sync:drift\` rewrites them. A styling
277
+ change belongs in the dev-kit, where it reaches every product's manual at once.
278
+
279
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md\` is the full contract — the five
280
+ sections, the two builds, publishing, and how this site relates to the
281
+ illustrated manual the image serves at \`/docs\`.
282
+ `;
283
+ }
284
+
285
+ export function docsSiteFiles(ctx: ShapeContext): GeneratedFile[] {
286
+ return [
287
+ { path: `${SITE_DIR}/package.json`, content: packageJson(ctx) },
288
+ { path: `${SITE_DIR}/tsconfig.json`, content: TSCONFIG },
289
+ { path: `${SITE_DIR}/astro.config.mjs`, content: astroConfig(ctx) },
290
+ { path: `${SITE_DIR}/README.md`, content: readmeMd(ctx) },
291
+ { path: `${SITE_DIR}/src/lib/site.ts`, content: siteTs(ctx) },
292
+ { path: `${SITE_DIR}/src/lib/versions.ts`, content: VERSIONS_TS },
293
+ { path: `${SITE_DIR}/src/content/docs/index.mdx`, content: indexMdx(ctx) },
294
+ { path: `${SITE_DIR}/src/content/docs/guides/index.mdx`, content: GUIDES_MDX },
295
+ { path: `${SITE_DIR}/src/content/docs/how-to/index.mdx`, content: HOW_TO_MDX },
296
+ { path: `${SITE_DIR}/src/content/docs/concepts/index.mdx`, content: CONCEPTS_MDX },
297
+ { path: `${SITE_DIR}/src/content/docs/reference/index.mdx`, content: REFERENCE_MDX },
298
+ ];
299
+ }
300
+
301
+ /** Astro's build output and cache — gitignored, since the repo-root core block
302
+ * only knows about `dist/`. */
303
+ export const DOCS_SITE_GITIGNORE = `# The docs site (website/) is a standalone Astro package: its own install, and
304
+ # two builds (base /docs for a bundle a binary can embed, / for the public site).
305
+ ${SITE_DIR}/dist-embedded/
306
+ ${SITE_DIR}/dist-public/
307
+ ${SITE_DIR}/.astro/
308
+ `;
309
+
310
+ /** The root package.json scripts that drive it. Named the same in every repo
311
+ * that has a site, including the two in the norsk-ctl monorepo. */
312
+ export const DOCS_SITE_SCRIPTS: Record<string, string> = {
313
+ "docs:dev": `bun run --cwd ${SITE_DIR} dev`,
314
+ "docs:site": `bun run --cwd ${SITE_DIR} build`,
315
+ "docs:site:public": `ASTRO_BUILD=public bun run --cwd ${SITE_DIR} build`,
316
+ };
@@ -14,6 +14,7 @@
14
14
  // validate(), INVARIANTS + parity + byte-snapshot + studio-load wired into
15
15
  // test:unit, and an image smoke tier.
16
16
  import { type GeneratedFile, type ShapeContext, type ShapeModule, studioPackage } from "./create-product.ts";
17
+ import { DOCS_SITE_GITIGNORE, DOCS_SITE_SCRIPTS, docsSiteFiles } from "./docs-site.ts";
17
18
  import {
18
19
  COMPONENTS_GITIGNORE,
19
20
  componentsFiles,
@@ -188,6 +189,32 @@ fresh image and checks the pins before relaunching.
188
189
  6. \`bun run docs:handover\` — once a daemon has this product added; fills the
189
190
  handover's generated block from the launch truth.
190
191
 
192
+ ## The docs site (\`website/\`)
193
+
194
+ A **standalone package**, deliberately outside the root \`workspaces\` array:
195
+ Astro's dependency tree has no business in the product's install. Two
196
+ consequences that bite in that order —
197
+
198
+ - \`bun install\` at the root does NOT install it. \`bun install --cwd website\`
199
+ does, once; then \`bun run docs:dev\` / \`bun run docs:site\`.
200
+ - \`website/src/starlight.ts\`, \`website/src/custom.css\`,
201
+ \`website/src/content.config.ts\`, \`website/src/components/\`,
202
+ \`website/src/fonts/\` and \`website/public/favicon.png\` are **copies of the
203
+ dev-kit's shared theme**, not this repo's files. \`bun run check:drift\`
204
+ fails on a hand-edited one; \`bun run sync:drift\` rewrites them. A styling or
205
+ layout change belongs in the dev-kit, where it reaches every product's manual.
206
+
207
+ This repo owns four things: the origin (\`website/src/lib/site.ts\`), the two
208
+ names and the sidebar (\`website/astro.config.mjs\`), and the content
209
+ (\`website/src/content/docs/\`). Keep the five top-level sections — Start here,
210
+ Guides, How-to, Concepts, Reference — they are what make several Norsk manuals
211
+ navigable by one reader. The placeholder pages say what belongs in each.
212
+
213
+ It is NOT the bundle the image serves at /docs (\`bun run docs:manual\`); both
214
+ channels exist on purpose, and the dev-kit's
215
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md\` says which to
216
+ reach for.
217
+
191
218
  ## Version pins
192
219
 
193
220
  Media + Studio container tags derive from **\`manifest.seed.json\`** (the
@@ -273,6 +300,9 @@ function rootPackageJson(ctx: ShapeContext): string {
273
300
  "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/known-limitations.md`,
274
301
  "docs:handover": "bash scripts/docs-handover.sh",
275
302
  "docs:handover:check": "bash scripts/docs-handover.sh --check",
303
+ // The docs SITE (website/) — a standalone package, so its install is its
304
+ // own: `bun install --cwd website` before the first build.
305
+ ...DOCS_SITE_SCRIPTS,
276
306
  "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts",
277
307
  // codegen first: components/_gen/types.ts is gitignored build output,
278
308
  // so on a clean checkout tsc has nothing to resolve against without it.
@@ -1880,6 +1910,7 @@ ${optional}- \`backend/\` — the control-plane service (manifest, product-templ
1880
1910
  - \`examples/\` — starter configs; \`examples/default/input.json\` is the default template.
1881
1911
  - \`tests/\` — unit, image and demo tiers; \`tests/demo.spec.ts\` is the customer journey.
1882
1912
  - \`docs/\` — reader-facing documents; \`docs/README.md\` is the index.
1913
+ - \`website/\` — the public docs site (Astro + Starlight on the shared Norsk theme).
1883
1914
  - \`deployment/\` — the image build wrapper and the iterate loop.
1884
1915
 
1885
1916
  ## First run
@@ -1903,11 +1934,20 @@ norsk-ctl and the pinned Norsk SDK:
1903
1934
 
1904
1935
  ## Documentation
1905
1936
 
1906
- \`bun run docs:manual\` builds the docs bundle from \`docs/handover.md\` and
1907
- \`docs/known-limitations.md\`; the image build runs it, so every image serves
1908
- them at /docs (linked from the product's page in norsk-ctl). \`bun run
1909
- docs:handover\` regenerates the handover's generated block from a daemon that
1910
- has this product added; CI runs its \`--check\`.
1937
+ Two channels, deliberately:
1938
+
1939
+ - **The bundle the image serves.** \`bun run docs:manual\` builds it from
1940
+ \`docs/handover.md\` and \`docs/known-limitations.md\`; the image build runs
1941
+ it, so every image serves them at /docs at the version that is running
1942
+ (linked from the product's page in norsk-ctl). \`bun run docs:handover\`
1943
+ regenerates the handover's generated block from a daemon that has this
1944
+ product added; CI runs its \`--check\`.
1945
+ - **The docs site**, \`website/\` — the public manual on the shared Norsk theme,
1946
+ five sections deep, faster-moving and readable without the product in hand.
1947
+ \`bun install --cwd website\` once, then \`bun run docs:dev\` /
1948
+ \`bun run docs:site\`. \`website/README.md\` says what is the site's and what
1949
+ is the theme's; the dev-kit's
1950
+ \`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md\` is the contract.
1911
1951
 
1912
1952
  \`CLAUDE.md\` has the conventions and the traps.
1913
1953
  `;
@@ -1924,6 +1964,10 @@ served at /docs at the version that is running.
1924
1964
  | \`docs/handover.md\` | the platform team deploying this product | Deploying from the exported bundle without norsk-ctl: every mount, environment and injected setting |
1925
1965
  | \`docs/known-limitations.md\` | operators | What the product does not do yet, with the consequence and the workaround |
1926
1966
 
1967
+ The **docs site** is a separate channel and lives in \`website/\`: the public
1968
+ manual, on the shared Norsk theme, not built from these files. See
1969
+ \`website/README.md\`.
1970
+
1927
1971
  \`bun run docs:manual\` builds the bundle from these files. The tables in the
1928
1972
  handover are generated by norsk-ctl (\`bun run docs:handover\`) — regenerate
1929
1973
  them, never edit them.
@@ -2012,6 +2056,7 @@ function gitignoreTail(ctx: ShapeContext): string {
2012
2056
  const blocks = ["# Repo-specific entries go below (the block above is drift-gated verbatim).\n"];
2013
2057
  if (ctx.features.has("components")) blocks.push(COMPONENTS_GITIGNORE);
2014
2058
  if (ctx.features.has("dashboard")) blocks.push(DASHBOARD_GITIGNORE);
2059
+ blocks.push(DOCS_SITE_GITIGNORE);
2015
2060
  return blocks.join("\n");
2016
2061
  }
2017
2062
 
@@ -2033,6 +2078,7 @@ export const turnkey: ShapeModule = {
2033
2078
  { path: "deployment/iterate.sh", content: iterateSh(ctx), executable: true },
2034
2079
  { path: "scripts/demo", content: ctx.canon.demoShim, executable: true },
2035
2080
  { path: "README.md", content: readmeMd(ctx) },
2081
+ ...docsSiteFiles(ctx),
2036
2082
  { path: "docs/README.md", content: docsReadmeMd(ctx) },
2037
2083
  { path: "docs/handover.md", content: handoverMd(ctx) },
2038
2084
  { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD },
@@ -7,6 +7,7 @@ genuinely differs — its content, its sidebar, its origin and its two names.
7
7
 
8
8
  custom.css the palette + self-hosted Geist faces
9
9
  fonts/ the woff2 those @font-face rules point at
10
+ public/favicon.png the favicon `starlight.ts` names (copied to the site ROOT, not src/)
10
11
  components/Footer.astro the "captured against" line on a generated guide
11
12
  components/Sidebar.astro the "Built against" versions footer
12
13
  components/SiteTitle.astro the masthead wordmark, decoupled from the tab title
@@ -63,20 +64,43 @@ bun node_modules/@norskvideo/ctl-dev-kit/docs-theme/sync.ts check <site>
63
64
 
64
65
  `<site>` is the directory holding `astro.config.mjs`; the copies land in its
65
66
  `src/`, so this directory mirrors a site's `src/` and the path map is an
66
- identity. A site runs `check` in its own test suite (norsk-ctl:
67
+ identity. The one exception is `public/`, which mirrors the site's `public/` —
68
+ Astro serves it verbatim from the site root, and `starlight.ts` already dictates
69
+ the favicon's path (`favicon: "/favicon.png"`), so its bytes belong here too.
70
+
71
+ A site runs `check` in its own test suite (norsk-ctl:
67
72
  `website/tests/theme-drift.test.ts`, which `bun run check` runs), so a
68
- hand-edited copy fails before it is published.
73
+ hand-edited copy fails before it is published. A PRODUCT repo has no such suite;
74
+ there, `conventions/check-drift.ts` runs the same check as part of
75
+ `bun run check:drift`, and `sync-drift.ts` rewrites the copies as part of
76
+ `bun run sync:drift` — see below.
69
77
 
70
78
  **Keeping the copies at those exact paths is load-bearing.** Astro derives a
71
79
  component's scoped-style class from its path, so moving `Footer.astro` rewrites
72
80
  a class attribute on every page of the site.
73
81
 
74
- ## Not yet
82
+ ## The two gates are one gate
83
+
84
+ `conventions/check-drift.ts` gains a clause when — and only when — the repo has a
85
+ site: `hasDocsSite()` tests for `website/astro.config.mjs`, and
86
+ `docsThemeProblems()` routes this directory's `checkTheme` into the same report as
87
+ `biome.json` and the shared workflows. `conventions/sync-drift.ts` is the writer
88
+ half, so `sync-dev-kit.yml` rolls a theme change out to every product hands-free
89
+ like any other convention. The site directory is `website/` across the fleet
90
+ (`DOCS_SITE_DIR`).
91
+
92
+ A site inside THIS monorepo still runs `sync.ts check` from its own test suite,
93
+ because it has one and because `bun run check` is what a monorepo contributor
94
+ runs; a product repo has no second suite, which is why the clause exists.
95
+
96
+ ## `create-product` renders a site
97
+
98
+ Every generated repo comes out with a `website/` on this theme, five sections
99
+ deep, that builds — `create-product/docs-site.ts`. Two notes on how:
75
100
 
76
- - **`conventions/check-drift.ts` does not know about this directory.** Its gate
77
- runs at a product repo's root against files at fixed repo-root paths; a docs
78
- site is a nested package a repo may or may not have. When the first product
79
- repo outside this monorepo grows a site, the two gates should become one —
80
- `check-drift` gaining a "if `website/astro.config.mjs` exists, check the theme"
81
- clause is the obvious shape.
82
- - **`create-product` does not render a site.** No generated repo has one yet.
101
+ - the theme files are written by **this directory's `syncTheme`**, called from
102
+ `createProduct` after the plan is written, not re-rendered by the generator: one
103
+ copier, one gate, and the woff2 faces are not strings;
104
+ - what a product repo needs in order to have a site at all — the five sections,
105
+ the two builds, publishing, and how the site relates to the illustrated manual
106
+ the image serves — is `conventions/docs-site.md`.
Binary file
@@ -30,10 +30,17 @@
30
30
  *
31
31
  * The copies land at the paths Starlight and Astro already expect
32
32
  * (`src/custom.css`, `src/components/*.astro`, `src/content.config.ts`), which
33
- * is also why the canonical directory mirrors a site's `src/`: the map below is
34
- * an identity. Keeping the component paths identical is load-bearing for more
35
- * than tidiness — Astro derives a component's scoped-style class from its path,
36
- * so moving one rewrites a class attribute on every page.
33
+ * is also why the canonical directory mirrors a site's own tree: the map below
34
+ * is an identity. Keeping the component paths identical is load-bearing for
35
+ * more than tidiness — Astro derives a component's scoped-style class from its
36
+ * path, so moving one rewrites a class attribute on every page.
37
+ *
38
+ * One canonical subdirectory is NOT under `src/`: `public/` mirrors the site's
39
+ * `public/`, because `starlight.ts` already names the favicon
40
+ * (`favicon: "/favicon.png"`) and a path the theme dictates whose bytes each
41
+ * site supplies for itself is half a convention. Both sites' copies were
42
+ * byte-identical when it moved here, so the gate gained a file rather than the
43
+ * sites gaining a difference.
37
44
  *
38
45
  * Canonical bytes are resolved relative to THIS file, so the script works both
39
46
  * workspace-symlinked and installed from the published tarball — the same
@@ -48,11 +55,21 @@ const CANONICAL = dirname(fileURLToPath(import.meta.url));
48
55
  /** The site subdirectory the canonical tree is copied into. */
49
56
  export const SITE_DIR = "src";
50
57
 
58
+ /** The one canonical subdirectory that mirrors the site's root, not its `src/`
59
+ * — Astro serves `public/` verbatim, and `starlight.ts` names `/favicon.png`. */
60
+ export const PUBLIC_DIR = "public";
61
+
62
+ /** Where a canonical file lands in a site, relative to the site root. */
63
+ export function sitePath(rel: string): string {
64
+ return rel === PUBLIC_DIR || rel.startsWith(`${PUBLIC_DIR}/`) ? rel : join(SITE_DIR, rel);
65
+ }
66
+
51
67
  /**
52
68
  * Every canonical file, as a path relative to this directory — which is also
53
- * its path relative to the site's `src/`. Derived from the tree rather than
54
- * listed, so a file added here reaches every site without a second edit; the
55
- * script's own source and its test are the only exclusions.
69
+ * its path relative to the site's `src/` ({@link sitePath}, bar `public/`).
70
+ * Derived from the tree rather than listed, so a file added here reaches every
71
+ * site without a second edit; the script's own source and its test are the only
72
+ * exclusions.
56
73
  */
57
74
  export function themeFiles(canonical: string = CANONICAL): string[] {
58
75
  const out: string[] = [];
@@ -84,17 +101,17 @@ const RESYNC = "Edit the dev-kit source and re-run `sync`, never hand-edit the c
84
101
  export function checkTheme(siteRoot: string, canonical: string = CANONICAL): ThemeDrift {
85
102
  const problems: string[] = [];
86
103
  for (const rel of themeFiles(canonical)) {
87
- const copy = join(siteRoot, SITE_DIR, rel);
104
+ const at = sitePath(rel);
88
105
  const want = readFileSync(join(canonical, rel));
89
106
  let got: Buffer;
90
107
  try {
91
- got = readFileSync(copy);
108
+ got = readFileSync(join(siteRoot, at));
92
109
  } catch {
93
- problems.push(`${SITE_DIR}/${rel}: missing — the docs theme is not synced. ${RESYNC}`);
110
+ problems.push(`${at}: missing — the docs theme is not synced. ${RESYNC}`);
94
111
  continue;
95
112
  }
96
113
  if (got.equals(want)) continue;
97
- problems.push(`${SITE_DIR}/${rel}: ${firstDiff(got, want)}. ${RESYNC}`);
114
+ problems.push(`${at}: ${firstDiff(got, want)}. ${RESYNC}`);
98
115
  }
99
116
  return { ok: problems.length === 0, problems };
100
117
  }
@@ -117,10 +134,11 @@ function firstDiff(got: Buffer, want: Buffer): string {
117
134
  export function syncTheme(siteRoot: string, canonical: string = CANONICAL): string[] {
118
135
  const written: string[] = [];
119
136
  for (const rel of themeFiles(canonical)) {
120
- const to = join(siteRoot, SITE_DIR, rel);
137
+ const at = sitePath(rel);
138
+ const to = join(siteRoot, at);
121
139
  mkdirSync(dirname(to), { recursive: true });
122
140
  cpSync(join(canonical, rel), to);
123
- written.push(`${SITE_DIR}/${rel}`);
141
+ written.push(at);
124
142
  }
125
143
  return written;
126
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./create-product": "./create-product/create-product.ts",