# The product docs site (shared across all Norsk ctl product repos)

What a product repo needs in order to have a **docs site** — the public manual at
`<product>.norsk.video`, an Astro + Starlight package on the shared Norsk theme.
This is the shared half; the per-product half is the content.

Like [`docs.md`](./docs.md) and [`personas.md`](./personas.md) this is a **shipped
reference**, not a drift-gated copy: read it from
`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md`. What IS gated is
the theme itself — see "The theme is a copy, and the gate is what makes that
safe" below — and the publish workflow, see "Publishing".

**`create-product` renders all of it.** A repo generated by
`bunx @norskvideo/ctl-dev-kit create-product` comes out with a `website/` that
builds, so the rest of this document is for a repo that predates the generator,
or for understanding what you have.

## Two documentation channels, and which is which

A product has two, on purpose, and they are not competing drafts:

|                | **The illustrated manual** ([`docs.md`](./docs.md))       | **The docs site** (this document)                     |
| -------------- | --------------------------------------------------------- | ----------------------------------------------------- |
| What it is     | one hash-routed HTML page + a bundle of markdown/captures | a multi-page Starlight site                           |
| Where it lives | `docs/generated/bundle/`, baked into the product image    | `website/`, published to `<product>.norsk.video`      |
| How it's read  | at `/docs` on the running container, through the daemon   | on the open internet, without the product in hand     |
| Version        | exactly the version running — that is its whole point     | the tip; it says which version it was built against   |
| Cadence        | per image                                                 | per commit to main                                    |
| Reader         | an operator who already has it                            | an evaluator, and anyone without a deployment to hand |

The bundle is the **version-true** channel and the only channel a turnkey has
(ADR-0012). The site is the **discoverable** channel. A product with a public
audience wants both; a turnkey wants only the bundle and should not carry a site.

## What the repo owns, and what the theme owns

A site is deliberately thin. It owns **four** things:

| The site's own                    | Where                         |
| --------------------------------- | ----------------------------- |
| its canonical origin              | `website/src/lib/site.ts`     |
| its two names + sidebar           | `website/astro.config.mjs`    |
| the versions it was built against | `website/src/lib/versions.ts` |
| its content                       | `website/src/content/docs/`   |

Everything else comes from the shared theme (`docs-theme/`): the palette, the
self-hosted Geist faces, the three Starlight component overrides, the content
schema, the favicon, and `norskDocsSite()` — the whole Astro config bar those
four. So `astro.config.mjs` is a sidebar plus five lines:

```js
import { defineConfig } from "astro/config";
import { norskDocsSite } from "./src/starlight.ts";
import { PUBLIC_SITE_ORIGIN } from "./src/lib/site.ts";

export default defineConfig(
  norskDocsSite({
    origin: PUBLIC_SITE_ORIGIN,
    tabTitle: "Norsk",          // browser-tab suffix: the FAMILY
    wordmark: "norsk-acme",     // masthead: the product in hand
    buildVersions: [{ label: "norsk-acme", value: version }],
    sidebar: [...],
  }),
);
```

**The two names are different on purpose.** Several Norsk manuals open in one tab
strip, and tabs truncate, so the tab suffix is the family; the masthead names the
binary or product the reader is holding.

### The five sections

    Start here · Guides · How-to · Concepts · Reference

Every Norsk manual uses this top level — norsk-ctl's, the Manager's, and every
generated product's — so a reader who has learned one can navigate the next. Who
each section serves, in the vocabulary of [`personas.md`](./personas.md):

- **Start here** — the Evaluator. What this does, and where to go next.
- **Guides** — the Builder. One task, start to finish, in the order it is done.
  **A Guides page is the recorded chapter where one exists**: a symlink from
  `website/src/content/docs/guides/<slug>` into the `docs/generated/<slug>`
  tree a doc-guide writes, with at most a prose introduction above it. That
  is what the Manager's site does for every guide and what makes the page
  unable to describe a screen that does not exist; see [`docs.md`](./docs.md)
  for the toolchain, the `capturedAgainst` front matter the theme renders at
  the foot of such a page, and the placeholder the dev-kit writes so the site
  builds on a checkout with no captures. **When a product has more than one
  surface** (a console and an API, or a CLI as well), show the same task on
  each: `<task>`, `<task>-ui`, `<task>-api`, grouped under the task in the
  sidebar, as the Manager does for launching a show format, draining a node
  and API tokens. Not every task needs every surface; the first task on the
  Builder's route should have them all.
- **How-to** — the Administrator. Looked up, repeatedly, while running it.
  Its first page, **"What this product adds to the host"** (images, ports,
  volumes, GPU, state), is generated from the manifest and the product
  template, where those facts already live, and never hand-written; the
  rest of the section ends with a link into norsk-ctl's manual.
- **Concepts** — the vocabulary the rest of the manual uses. Not architecture.
- **Reference** — the Integrator. Exhaustive and generated from the source of
  truth where possible.

Add sections below these if a product genuinely needs them. Do not rename or
reorder these five: the shape is the shared part.

**Start here and Guides are the light half**, written for readers who do not
know our engineering vocabulary: one command per step, no flags, the result
before the prerequisites, a single "Next" link at the foot of each page, and a
word budget. `docs/light-pages.ts` lints those pages (see `docs.md`, "Voice").
How-to and Reference may be as exhaustive as the Administrator and the
Integrator want. The install section of Start here is the theme's
`components/InstallProduct.astro`: the product's one-line install, one sentence
on what it leaves behind, and one link into norsk-ctl's manual. It is repeated
on every product site on purpose and generated so the wording cannot drift.

## The theme is a copy, and the gate is what makes that safe

The theme reaches a site as **files copied into it**, not as an import, and
`docs-theme/README.md` has the measurement behind that: a docs site is a
standalone package with its own install, so it cannot depend on this one — a
`file:` dep fails on this package's own `workspace:*` deps.

    website/src/starlight.ts          website/src/components/*.astro
    website/src/custom.css            website/src/fonts/*.woff2
    website/src/content.config.ts     website/public/favicon.png

**Never hand-edit one.** A change to any of them belongs in the dev-kit, where it
reaches every product's manual at once.

- `bun run check:drift` fails when a copy has drifted, naming the file and the
  line. It is the same gate, and the same CI job, that holds `biome.json` and the
  shared workflows — the clause is conditional on `website/astro.config.mjs`
  existing, so a repo without a site is not asked for one.
- `bun run sync:drift` rewrites the copies from the installed dev-kit. The
  nightly `bump.yml` runs it after bumping the pin, so a theme change
  rolls out hands-free and only a change that genuinely breaks the site goes red.

**Keeping the copies at exactly those paths is load-bearing**, not tidiness:
Astro derives a component's scoped-style class from its path, so moving
`Footer.astro` rewrites a class attribute on every page of the site.

## Adding a site to a repo that has none

`create-product` does all of this; by hand it is:

1. **`website/package.json`** — a standalone package (`<name>-docs`), NOT a member
   of the root `workspaces` array. Its dependencies are what the theme imports:
   `astro`, `@astrojs/starlight`, `@astrojs/markdown-remark` (an _optional_ peer
   of both, so nothing installs it for you) and `sharp`, plus `playwright` as a
   dev dependency for pages that render a mermaid diagram.
2. **`website/tsconfig.json`** — `{"extends": "astro/tsconfigs/strict"}`.
3. **`website/astro.config.mjs`**, `website/src/lib/{site,versions}.ts` — the four
   things above.
4. **`bun run sync:drift`** to lay the theme copies down.
5. **`website/src/content/docs/`** — at least an `index.mdx`, plus a page per
   section.
6. **Root `package.json` scripts** — `docs:dev`, `docs:site`, `docs:site:public`,
   each `bun run --cwd website …`.
7. **`.gitignore`** — `website/dist-embedded/`, `website/dist-public/`,
   `website/.astro/`. The shared core block only knows about `dist/`.

Then `bun install --cwd website` (the repo-root install does NOT cover a
standalone package) and `bun run docs:site`.

### Adding a recorded guide

Two lines. A symlink from the site's Guides into the tree the doc-guide
writes, and a sidebar entry:

```sh
ln -s ../../../../../docs/generated/<slug> website/src/content/docs/guides/<slug>
```

```js
{ label: "<Title>", slug: "guides/<slug>" }
```

`docs:site` runs the dev-kit's `docs/guide-stubs.ts stub` first, so the site
builds on a checkout that has never captured. The captures come from the
product's nightly `publish-docs.yml`, which keeps `docs/generated` as the
`docs-captures` artifact after a passing engine tier; `publish-docs-site.yml`
downloads the newest one before its build (and runs when `publish-docs` completes, so a
fresh capture publishes without waiting for a push). It then runs
`guide-stubs.ts check` and refuses to deploy while any guide is still a
placeholder: until the guide has been captured once, the publish stops rather
than shipping an empty page.

## The two builds

One content collection, two outputs, selected by `ASTRO_BUILD`:

| Build    | `ASTRO_BUILD` | base    | outDir          | What it is                                    |
| -------- | ------------- | ------- | --------------- | --------------------------------------------- |
| embedded | unset         | `/docs` | `dist-embedded` | a bundle a product binary can embed and serve |
| public   | `public`      | `/`     | `dist-public`   | the site on its own domain                    |

Content is authored with absolute `/docs/...` links — right for the embedded
build; the public build strips the prefix in a post-build pass, because Astro does
not base-prefix absolute links in MDX. So **write `/docs/...` links** and let the
theme handle the other case.

A product only needs the public build. The embedded one costs nothing to keep
(the theme produces it either way) and is there for a binary that wants to serve
its own manual offline, as `norsk-ctl` and `norsk-mgr` both do — for about 1.2 MB
of compressed docs on a ~68 MB binary.

## Publishing

The site publishes from **`.github/workflows/publish-docs-site.yml`**,
single-sourced in this package (`conventions/publish-docs-site.yml`) and
**drift-gated** the way the bundle's `publish-docs.yml` is: `bun run check:drift`
holds the copy byte-identical, and `bun run sync:drift` writes it — creating it
where the repo has a site and is not a turnkey. On every push to `main` (and on
demand) it installs `website/`, builds the public variant and deploys
`website/dist-public` to Cloudflare Pages with `wrangler`. It never cancels a
run in flight, because a cancelled deploy is a half-published site. Before the
build it downloads the newest recorded-guide captures (the `docs-captures`
artifact `publish-docs.yml` keeps for 30 days, not the Actions cache, whose
entries are versioned by a compression that varies across runners); a site
with no recorded guides restores nothing it reads, and builds from its
committed prose.

Where it may exist is the gate's business (ADR-0012, and the documentation
plan): a repo with `website/astro.config.mjs` may carry it; a repo without a
site may not, since there is nothing to publish; a turnkey
(`norsk-ctl-turnkey-*`) may not, since a turnkey has no public site. The rule
is negative on the repo name where `publish-docs.yml`'s is positive, because
sites exist outside `norsk-ctl-product-*` — Relay has one.

What the repo needs, per site, and none of it to build or review one locally:

1. **A Cloudflare Pages project**, e.g. `norsk-acme-docs`.
2. **A custom domain** on it — `<product>.norsk.video` — and the DNS record.
3. **The Cloudflare credentials reaching the repo.** The secret
   `CLOUDFLARE_API_TOKEN`, a Pages:Edit token that covers the new project
   (Pages permissions are account-wide), and the variable
   `CLOUDFLARE_ACCOUNT_ID`. Both are org-level, shared with selected
   repositories: add the repo to each. The workflow verifies the token at the API (both the
   user-owned and the account-owned endpoint), never with `wrangler whoami`,
   which a Pages:Edit token cannot answer.
4. **One repo variable.** `NORSK_DOCS_SITE_PROJECT`, the project name from
   step 1. It has **no default**: unset, the workflow still builds the site,
   prints exactly these steps and exits 0 — so `main` is green before
   Cloudflare exists and publishes the day the variable is set, with no change
   to the workflow.

A `workflow_dispatch` from a branch other than `main` deploys a **preview** of
that branch (`--branch` is the ref that ran), never over production.

### Wiring it into a repo that has a site

Two steps, once a dev-kit carrying the workflow is published:

```
bun update @norskvideo/ctl-dev-kit   # bump the pin (or let bump.yml do it)
bun run sync:drift                   # writes .github/workflows/publish-docs-site.yml
```

Commit the result; `bun run check:drift` passes. The nightly `bump.yml`
does both hands-free, so a product with a site acquires the workflow on its
next sync without anyone touching the repo. Then create the Pages project, set
the two secrets and the variable, and the next push to `main` publishes.

## Search does not cross products

Each site indexes itself (Pagefind), so a reader searching one product's manual
does not find another's. That is accepted rather than solved: the alternative is
one corpus and one navigation carrying six products. A hub page listing the
products is the cheap mitigation; cross-site search is possible later and nothing
here forecloses it.
