<!--{"order":30,"title":"Doc-Site System","description":"How tosijs-ui's static, pre-rendered, hydrating documentation-site system works — and how to adopt it (tosijs-ui/site) in your own project.","parent":"Documentation System"}-->

# `tosijs-ui/site` — static, pre-rendered, hydrating doc sites

A build system that turns a project's markdown (`.md` files + `/*#` block
comments in source) into a **fast, SEO/AI-friendly documentation site** that
works with no JavaScript and then upgrades itself into the interactive
`<tosi-doc-system>` doc browser when the bundle loads.

The output is a plain folder of static files — drop it on GitHub Pages,
Firebase Hosting, or any static host.

The build half is importable as `tosijs-ui/site`; the runtime component ships in the
bundle. [Where the code lives](#where-the-code-lives) maps each part to its module.

## What you get

- **One pre-rendered `/{slug}/index.html` per doc** (README → site root) with
  real `<head>` metadata: `<title>`, description, canonical, Open Graph,
  Twitter card, and `schema.org` JSON-LD: each page a `TechArticle` (a `Chapter` of a
  `Book` under `kind: 'book'`) with its headline, description, dates and author, a
  `BreadcrumbList` for a page inside a nav section, and the home page as the `WebSite`.
  A book's pages also link the previous and next page (`<link rel="prev/next">`).
- **No-JS readable**: the markdown is already rendered to HTML and every nav
  item is a real `<a>`, so crawlers and AI agents see full content and links.
- **Zero-flash hydration**: the theme is burned into a static stylesheet, so
  pages are styled before any JS runs; then `<tosi-doc-system>` hydrates the
  page into the live doc browser (search, live examples, locale switching).
- **`sitemap.xml` + `robots.txt`** (when `baseUrl` is set), and host files
  (`.nojekyll`, `CNAME`, …) appropriate to the chosen host. Each sitemap entry's
  `lastmod`, and each page's `dateModified`, is the last commit that touched its source
  file, or the file's modification time while it has uncommitted edits. Without git there
  is no date, rather than an invented one: a file's mtime alone changes on every checkout,
  so a CI build would date every page today. A page's `date` (frontmatter) is its
  `datePublished`, and its `author` overrides `epub.author`.

## How it works (pipeline)

```
extractDocs(docPaths)            →  docs.json   (markdown corpus)
generateSite(config, docs)       →  /{slug}/index.html + docs.json + sitemap + robots
generate-css(theme)              →  doc-system.css   (burned-in, no FOUC)
bundle(bundleEntry | iife.js)    →  the JS that hydrates the pages
host preset                      →  .nojekyll / CNAME / firebase.json
```

Static and hydrated output share the same slug + markdown rendering
(`src/doc-system/routing` + `render`) so the page never reflows on hydration.

## Quick start (adopting in your project)

> **First time?** Read **[Adopting the doc system](/adopting-the-doc-system/)** first. This
> section is the configuration; that one is the narrow path plus the four things that fail
> silently while the site still serves 200s — most of which cost a real adopter an afternoon
> each ([#144](https://github.com/tonioloewald/tosijs-ui/issues/144),
> [#145](https://github.com/tonioloewald/tosijs-ui/issues/145),
> [#146](https://github.com/tonioloewald/tosijs-ui/issues/146)).


**1. `site.config.ts`** at your repo root:

```typescript
import { defineSiteConfig } from 'tosijs-ui/site'

export default defineSiteConfig({
  name: 'my-lib',
  description: 'What my library does.',
  baseUrl: 'https://my-lib.example.com',
  host: 'github-pages', // emits .nojekyll + CNAME (domain from baseUrl)
  bundleEntry: 'demo/site.ts', // omit to use tosijs-ui's published iife.js
  navbarLinks: [
    { href: 'https://github.com/me/my-lib', label: 'github', icon: 'github' },
  ],
})
```

**2. `bin/site.ts`** — the only build file you write:

```typescript
import { buildSite, devServer } from 'tosijs-ui/site'
import config from '../site.config'

process.argv.includes('--build') ? buildSite(config) : devServer(config)
```

> **If your build does more than `buildSite`** — e.g. you bundle your own
> hydration `iife.js` separately (needed when the bundle requires a Bun plugin,
> which `bundleEntry` can't take) — wrap the whole pipeline in one function and
> pass it to `devServer` as `{ build }`. `buildSite` begins with
> `rm -rf <outputDir>`, so any artifact your extra steps wrote is deleted on the
> first file-change rebuild; without `build`, the watcher only re-runs
> `buildSite` and never regenerates it, so `/iife.js` 404s into the SPA fallback
> and "loads as html". The server runs `build` once on start too:
>
> ```typescript
> const build = async () => {
>   if (!(await buildSite(config))) throw new Error('site build failed')
>   await buildMyIifeBundle() // re-create what buildSite's rm -rf removed
> }
> if (process.argv.includes('--build')) {
>   await build()
> } else {
>   await devServer(config, { build }) // ← builds on start, then on every change
> }
> ```

`devServer` **builds once on start** when it is watching (since 1.16.39; `DEV_NO_WATCH=1` and `{ test: true }` serve what is there; before that it served whatever the last run
left in the output dir, so a config change showed nothing until a source file changed). If your
script has just built, pass `initialBuild: false` to skip the second build. The config file
itself is not watched: the server holds the config it was started with, so restart it after
editing `site.config.ts`.

**3. scripts** in `package.json`:

```json
{
  "scripts": { "start": "bun bin/site.ts", "build": "bun bin/site.ts --build" }
}
```

**4. build-time dependencies.** The build (not your shipped library) needs a few
tools installed alongside tosijs-ui. `marked` is a peer; the rest are optional peers the
build or the dev server reports when missing (`chokidar` is what the dev server watches with):

```bash
bun add -d happy-dom tjs-lang marked chokidar
```

`happy-dom` powers the theme-stylesheet step (the build runs with no real DOM);
`tjs-lang` transpiles live-examples (vanilla JS via `dialect: 'js'`, plus real
TypeScript); `marked` renders markdown. If one
is absent the build fails mid-run with a `Cannot find package …` from inside
`node_modules/tosijs-ui/dist/…` — that means a build-time peer isn't installed.

**5. dev-server TLS (once).** `devServer` serves over HTTPS and looks for
`tls/key.pem` + `tls/certificate.pem`; if they're missing it tells you to run:

```bash
bunx tosijs-dev-certs
```

This ships with tosijs-ui — it uses [mkcert](https://github.com/FiloSottile/mkcert)
to write a **locally-trusted** cert into `./tls/` (no browser warnings), valid
for `localhost`, `127.0.0.1`, `::1`, and your machine's `.local` name. Run it as
your normal user (it prompts for sudo itself only to install its CA); re-run to
add hostnames. Requires `mkcert` — the command prints install instructions if
it's missing.

## Bundles & live examples (read this)

The static pages are inert HTML until a JS bundle loads and registers the
custom elements (and powers live `js`/`test` examples). You pick one of two
modes:

- **`bundleEntry` — bring your own (recommended for any project with custom
  components).** The build bundles your entrypoint to IIFE and pages load it.
  **Your entrypoint must import everything your pages and live examples
  reference**, and expose any custom modules to live examples by setting each
  `<tosi-doc-system>`'s `context` property (live examples resolve
  `import { x } from 'my-lib'` against `context['my-lib']`):

  ```typescript
  // demo/site.ts
  // REQUIRED — this is what defines <tosi-doc-system>. The package ROOT
  // (`import 'tosijs-ui'`) does NOT: the root barrel deliberately excludes the
  // doc-system cluster (#133), so it registers your buttons and leaves the doc
  // system undefined — a site that serves 200s and shows nothing.
  import 'tosijs-ui/doc-browser'
  import 'tosijs-ui/live-example' // only if your docs have executable fences
  import * as mylib from '../src/index' // your own components/exports

  // Expose your library to live examples. tosijs / tosijs-ui are provided by
  // default (from the IIFE globals); add your own here.
  for (const el of document.querySelectorAll('tosi-doc-system')) {
    ;(el as any).context = { 'my-lib': mylib }
  }
  ```

  **Where it lands.** The bundle and its sourcemap are written into your **site output**
  (`outputDir`, default `docs/`), so the map sits beside the script it describes and a
  browser can load it.

  Set `bundleOutDir` only when the bundle is itself a _published_ artifact — tosijs-ui does,
  because `dist/iife.js` is the CDN `<script>` target consumers reach via unpkg/jsdelivr.
  When set, the `.js` is also copied into the site output, so pages load it either way.

  > Before 1.9.9 the bundle was written into `dist/` unconditionally — the same directory
  > `emitLibrary` / `libraryTsconfig` use for your **library** — and only the `.js` was
  > copied out. The sourcemap stayed behind in a tree you publish and commit but never
  > serve, unreachable by any consumer. One adopter accumulated `iife.js.map` at **65 MiB
  > across 216 packed blobs, about 35% of the repository's entire packed blob store**, for a
  > file nothing could load ([#69](https://github.com/tonioloewald/tosijs-ui/issues/69)). If
  > you have that history, `git rm --cached dist/iife.js dist/iife.js.map` and gitignore
  > them; new builds will not put them back.

  Without the `import` your custom elements won't upgrade; without the
  `context` entry, `import … from 'my-lib'` in a live example won't resolve.

- **`scriptUrl` fallback — use a prebuilt bundle.** Omit `bundleEntry` and
  pages load `scriptUrl` (default `/iife.js`, i.e. tosijs-ui's published
  bundle). Good for a pure docs site with no custom elements of its own.

**Replacing the bundle after `buildSite`: call `restampSite`.** This is for a site with
**no** `bundleEntry`. Some projects have to build the hydration bundle themselves (one that
aliases a package to its own working source, say, which a `bundleEntry` path cannot express)
and overwrite `/iife.js` once `buildSite` has finished. Two things are then out of date, and
neither says so:

- every page's `iife.js?v=<hash>` was computed from the file `buildSite` wrote, so the stamp
  only moves when tosijs-ui does, and a returning visitor runs the cached bundle against new
  pages;
- nothing has checked that _your_ file registers `<tosi-doc-system>`. A green build, every
  page served, and no header, no nav, no live examples and no doc tests.

```typescript:static
import { buildSite, restampSite } from 'tosijs-ui/site'

await buildSite(config)
await buildMyDocsBundle() // writes <outputDir>/iife.js
await restampSite(config) // checks the file that is served, then re-stamps every page
```

`restampSite` hashes the same assets `buildSite` does, applies the registration checks
listed under [`strict`](#strict-builds-strict) to the file that is actually served, and
rewrites the stamp on the script and the stylesheet in every page. It returns
`{ stamp, previous, pages, changed }`. Run it after every build, watch rebuilds included.

It **throws**, and writes nothing, when:

- no page loads `scriptUrl` with a stamp (a restamp that quietly did nothing is the failure
  it exists to prevent);
- the site has a `bundleEntry`: its pages hydrate from a hash-named bundle and never load
  `scriptUrl`, so there is nothing to replace;
- it cannot read the corpus (`docsJson`), since it could not then tell whether the bundle
  needs `<tosi-example>`. Pass the config `buildSite` was given;
- `strict` is set and the bundle fails a registration check. **The pages are left as the
  build wrote them; nothing is restored**, because nothing was changed. Without `strict` it
  warns and re-stamps.

**Heads-up — IIFE bundle limits.** The bundle is a classic `<script>` (IIFE), so:

- **`import.meta` is illegal** in it — if an isomorphic dep references
  `import.meta.url` in a branch the bundler can't drop, the page dies with a
  `SyntaxError`. Mark that dep external (+ an importmap) or use a browser-only entry.
- **`bundleExternals` are a dynamic `require()` shim** that throws at runtime
  (`Dynamic require of … is not supported`). Load externals via `import()`
  (kept async) or an importmap — never a static top-level import.

The build warns about both, but they fail at page-load, not build-time.

## Custom icons

The icon set is extensible at runtime: `defineIcons({ name: '<svg…>' })` adds new
icons or **overrides a default by reusing its name**. Registered icons work with
`icons.name()`, `<tosi-icon icon="name">`, and the composition language; an icon's
`class="filled|stroked|color"` sets its default styling. Do this in your bundle
entry so the icons are available before the page renders:

```typescript
// demo/site.ts
import { defineIcons } from 'tosijs-ui'

defineIcons({
  // a brand glyph, and an override of the default `star`
  acme: '<svg class="stroked" viewBox="0 0 24 24"><path d="…"/></svg>',
  star: '<svg class="filled" viewBox="0 0 24 24"><path d="…"/></svg>',
})
```

For a **folder of SVGs**, generate a ready-to-register module with the bundled
CLI (it scales/rounds coordinates and emits `export default { name: '<svg>' }`):

```bash
bunx tosijs-make-icons --input ./my-icons --output ./src/my-icons.ts
```

```typescript
import { defineIcons } from 'tosijs-ui'
import myIcons from './my-icons'
defineIcons(myIcons)
```

(Each SVG file's `class` attribute — `filled` / `stroked` / `color` — is preserved.)

## Configuration reference

All fields are optional except `name`. See `src/doc-system/site/site-config.ts`
for the authoritative typed definition.

### Identity & SEO

| field         | default        | purpose                                       |
| ------------- | -------------- | --------------------------------------------- |
| `name`        | —              | brand name; `<title>` suffix, `og:site_name`  |
| `description` | —              | site-level meta + structured-data fallback    |
| `baseUrl`     | —              | absolute origin for canonical/OG/sitemap URLs |
| `lang`        | `'en'`         | `<html lang>`                                 |
| `favicon`     | `/favicon.svg` | favicon href                                  |
| `ogImage`     | —              | default share image (per-page overridable; see "Share images" below) |
| `headExtra`   | —              | raw lines injected into every `<head>`        |

### Branding & chrome

| field              | default | purpose                                                               |
| ------------------ | ------- | --------------------------------------------------------------------- |
| `projectLinks`     | —       | view-source links; `tosijs` key also gates the default logo           |
| `logo`             | —       | brand mark left of the title: icon name, image URL, or inline `<svg>` |
| `navbarLinks`      | —       | header-bar icon links                                                 |
| `searchPlaceholder` | `'Search'` | the hint in the header's search field; localized through `localizedStrings` |
| `theme`            | —       | base colors (palette derived from `accent`)                           |
| `localizedStrings` | —       | TSV table for the language picker                                     |

The header **brand mark** (left of the site title) resolves in this order: an
explicit `logo` — the name of an icon (from `tosijs-ui`'s `icons`, e.g.
`'tosiUi'`), an image URL / `data:` URI, or a raw inline `<svg>…</svg>` string —
otherwise the tosijs-ui logo when `projectLinks.tosijs` is set, otherwise no mark.
The same `logo` is accepted by `createDocBrowser({ logo })` and by an embedded
`<tosi-doc-system config='{"logo":"…"}'>`. Its size and spacing are class-driven
(`.logo-mark`), so retune them with one CSS variable each rather than editing the
build: `--tosi-logo-mark-size` (default 32px) and `--tosi-logo-mark-gap` (default
10px).

### Accessibility (opt-in)

| field           | default | purpose                                                         |
| --------------- | ------- | --------------------------------------------------------------- |
| `accessibility` | —       | `'wcag22-aa'` builds to WCAG 2.2 AA as far as a build can       |
| `altText`       | —       | asked for alt text for each image that has none (needs the mode) |

The site's own frame meets WCAG 2.2 AA whether or not you set anything, and the Playwright lane
holds it there with an axe scan. Your content is a different matter: the build cannot know what
an image shows. `accessibility: 'wcag22-aa'` makes the build do what it can decide on its own,
and report what it cannot.

What it changes, on the site, in print and in the ePub alike:

- **Figures.** An image alone in its paragraph, followed by a paragraph that is only italic text
  (on the next line, or after a blank one), becomes a `<figure>` with that text as its
  `<figcaption>`. The caption is then tied to the image, which is what makes an empty alt right
  for an image the caption describes:

  ```markdown:static
  ![](key-light.png)

  *The key light, at 45° to the subject.*
  ```

- **One `<h1>` per page, its title.** A page with no `#` heading has its first heading promoted
  if that heading is where its title came from, and gets its title as an `<h1>` otherwise. A
  later `#` becomes `##`, and the other headings shift so the first level below the title is
  `##`. A page that starts at `###` comes out starting at `##`.

What it reports, by file and line, and fails the build on under `strict`:

- an image with no alt text and no caption, a linked image with no alt text (the link then has
  no name), and a raw `<img>` with no `alt` attribute (`alt=""` is taken as deliberate);
- a heading that skips a level (an `<h4>` straight under an `<h2>`), as rendered;
- a link whose whole text is "here", "click here", "read more" and the like, which says nothing
  in a screen reader's list of links.

It does not judge whether a caption or an alt text is any good; that stays yours.

**The axe audit.** A full build in the mode then opens every page of the built site in Chromium,
waits for it to hydrate, and runs [axe-core](https://github.com/dequelabs/axe-core) with its WCAG
2.0–2.2 A and AA rules. Each failure is listed by page, rule and element, worst first, and fails
the build under `strict`. This covers what the markdown report cannot see: contrast, the names
of controls inside your live examples, ARIA on the components you embed.

It needs `playwright` and `axe-core` installed in your project (`bun add -d playwright
axe-core`, then `bunx playwright install chromium`). Without them the build says the audit did
not run, and makes no claim about what it would have found. It runs on the first build in a
process and never under a dev server. Pages are audited four at a time, each once it has
settled: about half a second a page, measured on this site. To keep the mode on but skip the
audit and the Ace check for one build, set `TOSIJS_A11Y_AUDIT=off`; the ePub then claims no
conformance.

**Slow pages: mark them `aria-busy`.** A page counts as settled when no element has been added or
removed for half a second, and nothing on it is marked `aria-busy="true"`. A page that keeps
changing is audited after five seconds as it stands. A page that is still loading, such as a 3D
scene fetching large models or a chart waiting on data, should say so: while any element carries
`aria-busy="true"`, the audit waits, up to 30 seconds. A page that never loads, or is still busy
at 30 seconds, is listed as not fully checked; the build makes no claim about it, and the rest of
the audit goes on.

This is the standard ARIA signal, not a tosijs-ui convention: a screen reader also holds off
announcing a busy region until it is ready. A component that loads asynchronously sets it on its
own element (the host, not inside its shadow root, where the audit cannot see it) and removes it
when it is ready, failed included:

```js:static
class SceneViewer extends HTMLElement {
  async connectedCallback() {
    this.setAttribute('aria-busy', 'true')
    try {
      await this.loadModels()
    } finally {
      this.removeAttribute('aria-busy')
    }
  }
}
```

Always clear it in a `finally`: a component left busy after an error is waited for the full 30
seconds on every build, and reported as not fully checked.

**The ePub.** In the mode the book's package document declares its accessibility, as EPUB
Accessibility 1.1 asks (and the European Accessibility Act points e-books at): its access modes,
which of them is sufficient alone, its features, its hazards and a summary. Each statement is
derived from the build, so it cannot say more than the build knows: "alternative text" is
claimed only when every image in the volume has alt text or a caption. Override the summary and
hazards, or name who certified the book, with `epub: { accessibility: { summary, hazards,
certifiedBy } }`.

Conformance (`dcterms:conformsTo` "EPUB Accessibility 1.1 - WCAG 2.2 Level AA") is claimed only
when [DAISY Ace](https://daisy.github.io/ace/) has checked the finished book and passed it. Ace
runs on release builds when it is installed. Install it globally (`npm install -g @daisy/ace`):
it brings Electron and Puppeteer, and in your project their advisories fail the build's own
[dependency audit](#dependency-audit-gate). The build looks in the project's `node_modules/.bin`
first, then on `PATH`. Without it the build says no conformance is claimed and why. A claim nothing verified is worse than none: a
distributor takes it at face value.

**`altText`** fills the gap for a corpus with hundreds of undescribed images. It is called for
each image the build would report, and what it returns becomes that image's alt text in the
built site, `docs.json` and the ePub. It never writes a source file, so generated text stays
something you review before it becomes authored text. Return nothing to leave an image reported.

```ts:static
altText: async ({ src, title, doc }) => {
  // a relative src resolves from doc.path, the source file the image appears in
  return cache[src] ??= await describeImage(path.resolve(path.dirname(doc.path!), src))
},
```

It runs on every build, watch rebuilds included, so cache anything slow or paid by `src`.

### Doc sources

| field         | default                | purpose                                                                                                                                       |
| ------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `docPaths`    | `['src', 'README.md']` | dirs scanned for `/*#` + `.md` files (list root `.md` files explicitly)                                                                       |
| `ignoreDocPaths` | `['reviews']`       | directory basenames skipped while scanning. **`reviews/` is excluded by default** — an adopter listing a docPath whose tree contained one published their internal pre-release review reports, BLOCK verdicts and all, to the open web (#153). Name a directory in `docPaths` explicitly to publish it anyway |
| `home`        | README                 | the page served at `/`, by slug or filename (`home: 'host'` for a site that is an app). README then gets its own page at `/readme/`, and the nav, canonical links, sitemap, `llms.txt` and books follow. The build fails if it names no page, if it names a hidden one, or if a second doc claims the root. A doc can claim the root itself with `{"home": true}` |
| `aliases`     | none                   | extra paths a page is served at, `{ alias: slug }` (`''` is the root page). `aliases: { readme: '' }` also writes the root page to `/readme/`. See [Serving a page at a second path](#serving-a-page-at-a-second-path) |
| `sectionsDir` | `'src/docs'`           | where auto-created section docs are written (must be inside a `docPath`, not named `docs`; outside every `docPath` the build warns and writes no stub). Stubs are created on the first build in a process, never on a watch rebuild. A section's `<!-- toc -->` list of its children is filled in the built page only, never in the source file; a section with no `<!-- toc -->` block gets the list at the end |
| `docsJson`    | `'demo/docs.json'`     | path of the intermediate doc corpus the build writes and re-reads; its directory is created automatically, so you don't need a `demo/` folder |

### Bundle

| field             | default     | purpose                                                                    |
| ----------------- | ----------- | -------------------------------------------------------------------------- |
| `bundleEntry`     | —           | your IIFE entrypoint; omit to use the fallback bundle                      |
| `kind`            | `'documentation'` | `'book'` makes each page a `Chapter` of a `Book` in the structured data, numbered in reading order, with `rel="prev/next"` links |
| `liveExamples`    | `'auto'`    | `'auto'` — the six executable fence languages run; `'opt-in'` — nothing runs unless the fence asks with `:inline`/`:iframe`/`:ide`; `'no-code'` — only `html` and `css` run, for a host with a strict CSP. See "Marking a fence display-only" and "No-code examples" |
| `exampleModules`  | `false`     | write each script example to `_examples/<hash>.js` and run it by `import()`, for a strict CSP. See "Script examples under a strict CSP" |
| `exampleTabs`     | all         | the tabs an example's code panel shows: `'js'`, `'html'`, `'css'`, `'test'`, `'console'`, `'chat'`. Under `'no-code'` the default is `['html', 'css', 'chat']`. A fence overrides it with `{"tabs": [...]}` |
| `bundleExternals` | —           | modules left external, e.g. `['jolt-physics']`                             |
| `scriptUrl`       | `/iife.js`  | bundle URL pages load (fallback + output name)                             |
| `bundleOutDir`    | `outputDir` | where the bundle is BUILT; set only when it is itself a published artifact |

### Example checking

| field           | default | purpose                                                                                                   |
| --------------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `checkExamples` | `true`  | transpile every executable example at build time; `{ contextKeys: [...] }` extends the resolvable imports |

Every ` ```js ` / ` ```ts ` / ` ```tjs ` / ` ```test ` block is compiled during the build, on
every page, so a syntax error or a bad import is caught once rather than when a reader opens
that page. Illustrative code mistakenly fenced as executable is the commonest catch.

**An unresolvable import warns; it does not fail the build.** The block is demoted to
display-only and the build stays green — which matters when you read the next paragraph.

Example imports resolve against the doc-system's example context, which is `tosijs` and
`tosijs-ui` by default. A library that documents **itself** imports its own package name, so
extend the context rather than turning the check off:

```typescript
checkExamples: { contextKeys: ['my-lib'] }   // ADDED to the defaults, not substituted
```

`checkExamples: false` disables it entirely. Worth knowing what that costs: with the guard
off, one adopter published a page teaching its own core contract with a hard `SyntaxError` in
the example, plus ten pages importing a symbol their barrel did not export.

### Static assets

| field        | default                           | purpose                     |
| ------------ | --------------------------------- | --------------------------- |
| `staticDirs` | `['demo/static']` or `['static']` | dirs copied to the web root |

### Hosting

| field      | default                | purpose                                                                                                                                                                                                                                |
| ---------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `host`     | `'static'`             | `'github-pages' \| 'firebase' \| 'static'` preset                                                                                                                                                                                      |
| `domain`   | derived from `baseUrl` | custom domain → `CNAME` (github-pages); implies `basePath: '/'`                                                                                                                                                                        |
| `basePath` | `'/'`                  | URL prefix; set `'/<repo>'` for a GitHub project page without a custom domain                                                                                                                                                          |
| `preview`  | —                      | `{ host?, path?, url?, tunnel? }` — deploy the built site, and optionally expose the live dev server; see [`preview`](#preview--deploy-the-built-site-to-a-host-you-control) and [`preview.tunnel`](#previewtunnel--the-live-workspace) |

### Build toggles & dev server

| field                | default  | purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prebuild`           | —        | `() => void \| Promise<void>` run first, for source-tree codegen (version stamp, icon data, …). The output dir is reset after it runs — don't write there. `dist/` is reset only when `libraryBundle`/`emitLibrary`/`libraryTsconfig` own it (#130)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `libraryBundle`      | —        | **the recommended library build (#169).** `{ entries, externals?, tsconfig?, target? }`: `bun build` bundles the entries with splitting into `dist/` (every declared dependency and peer stays an import), then `tsc --emitDeclarationOnly`. Fails the build if `dist/` has a relative import without a file extension, which Node cannot load. Needs `typescript` installed. Supersedes `libraryTsconfig`/`emitLibrary`; `libraryBuild` supersedes it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `emitLibrary`        | `false`  | also build the library: `tsc --declaration --outDir dist`. **Prefer `libraryBundle`** — a bare tsc copies bundler-style specifiers into a `dist/` Node cannot load, and the build warns when it does (#169)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `libraryTsconfig`    | —        | run `tsc -p <path>` for the library build instead (handles root `noEmit`, `removeComments`, custom `outDir`); supersedes `emitLibrary` If a later step of yours replaces tsc's `.js` files with bundles, set `"emitDeclarationOnly": true` in that tsconfig: the build otherwise warns about extensionless imports in files you do not publish. |
| `libraryBuild`       | —        | `(ctx: { dist, root, tsconfig? }) => void \| Promise<void>` — fully override the tsc library build; you emit `dist/*.js` + `*.d.ts` for ALL sources. For non-`.ts` sources tsc can't compile (native tjs-lang `.tjs`): run tsc for `.ts` + `tjs convert`/`generateDTS` for `.tjs`. Supersedes `libraryTsconfig`/`emitLibrary`. See `BUILD-TJS-HOOK.md`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `generateCssPreload` | —        | module to `bun --preload` into the CSS-extraction subprocess (`generate-css` imports your library to burn the theme); needed when that graph reaches non-`.ts` sources (`.tjs`) requiring a Bun loader plugin — point it at a module that registers it. Pairs with `libraryBuild`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `llmsTxt`            | `true`   | emit `llms.txt` and `llms-full.txt` — `true`, `false`, or `(docs) => string` for a custom index (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `markdownPages`      | `true`   | write each page's markdown beside its HTML (`/slug/index.md`) and link it with `<link rel="alternate" type="text/markdown">`; `false` ships HTML only (see below) |
| `strict`              | `false`  | fail the build on misconfigurations it otherwise only warns about (see "Strict builds" below) |
| `epub`               | `false`  | build + ship an ePub of the corpus every build — `true` or `{ author, title, css, cover, coverIcon, coverColor, printCover }` (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `book`               | —        | curate/reorder the book artifact without touching site nav (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `outputDir`          | `'docs'` | served web-root output dir                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `port`               | `8787`   | dev-server port                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `watchPaths`         | —        | extra dev-server watch dirs                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `haltijaDev`         | `false`  | give a coding agent eyes on your running dev page (see below); also `HALTIJA_DEV=1`. `'tunnel'` additionally bridges it over the tunnel so a headset/phone can be driven (session-gated)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `openBrowser`        | `false`  | on `bun start`, open (or bring to front) this project's browser tab once the server is up — reused per project via the dev origin, so restarts don't pile up tabs. `true` = auto-detect; a string names the browser; `BROWSER=<name>`/`BROWSER=none` override. macOS reuse via AppleScript; other platforms open (no reuse). Skipped in CI / non-TTY (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `preview`            | —        | preview-host target for `bun run deploy` — `{ host?, path?, url?, tunnel? }`, every field optional: keep `host` out of the repo and supply it from `PREVIEW_HOST`; `path` defaults to `/srv/preview/<name>`. Deploying rsyncs the built site and self-registers its own route, so no shared server config and no DNS change (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `editableSources`    | `false`  | Enables the dev server's `/__docstore/source` read+write endpoints, so "edit page source" and a live example's "Save to source" write the actual file. **Off** by default (writing files is opt-in): editing still works read-only — the client falls back to the GitHub raw source — but saving hands back a download. Set `true` to author in place. **Authorization depends on how the request arrived.** The dev server binds every interface (so you can view the site from a phone), but reading serves any file in the repo and writing is remote code execution. So: a request that arrived **directly** is authorized only by a **loopback peer** — you, at this keyboard. A request that arrived **through the tunnel** is authorized only by a **valid session**, earned by redeeming an invite link (`tosijs-tunnel --link`), because "looks local" is exactly what a tunnel counterfeits. Note what this means in practice: a phone on `https://<host>.local:8787` can _view_ the site but cannot save, session or not — reach the workspace through the tunnel URL instead, which is the path the session is for, or set `trustLan`. There is no env-var override. (The endpoint always answers `/__docstore/source` with a real status; it never serves the SPA `index.html`, so a disabled/misconfigured server can't leak the rendered page as the "source".) |
| `editableData`      | —        | Folders an editor running on the dev page (a level or scene editor) may list, read and save, through `/__docstore/data`, behind the same gate as source (this machine, an edit session over the tunnel, or the LAN under `trustLan`; share links stay read-only). `['static/levels']`, or `{ dirs, extensions, commit }`. Extensions default to `.json` and may only be data formats (`.json` `.jsonc` `.json5` `.ndjson` `.txt` `.csv` `.tsv` `.yaml` `.yml` `.toml`), so nothing saved there runs or renders. Dot-files and symlinks are never written, nor are common configuration names such as `package.json`, `tsconfig.json`, `firebase.json` or `*.config.*`; that list is a backstop, and the folders you name are the boundary, so name folders that hold only data. A `.json` save must parse, and a save replaces the file whole. File names may use letters, digits, `-`, `_`, `.`, `/` and spaces. Folders must be inside the project, not the project itself, not in or around `.git` or `node_modules`, and not overlapping the build output (which every build deletes); a bad setting stops the server at launch. With `commit: true` each save is its own commit (`data: save <file>`, naming who saved it), leaving other staged work alone; the repository's hooks run, git gets 20 seconds per step, and a repository mid-rebase, mid-merge, cherry-picking, reverting, bisecting or on a detached HEAD is not committed to. A save that wrote but did not commit answers with `commitError`. Each committed save waits for git and the hooks, so it suits an explicit Save, not autosave. `?history=` lists a file's latest 100 versions and `?file=…&at=<commit>` reads one. `tosijs-ui/doc-system/data-client` wraps the endpoint (`listData`, `readData`, `saveData`, `dataHistory`). |
| `trustLan`          | `false`  | With `editableSources` or `editableData` on, trust the local network for reading and editing source and data, so a phone or headset on your LAN can use "Edit page source" directly instead of going through the tunnel (#227), and a live example's "Save to source" is offered there too. Each write admitted this way is logged with the device's address and the file. Trusted: a **direct** peer at a private address (RFC 1918 IPv4, IPv6 unique-local or link-local). Never trusted: tunnel traffic (it still needs a session) or a request with forwarding headers. Writes must still come from the dev server's own origin. **It means anyone on the network can change files here, including code that runs on this machine**: use it on a network you control, never shared wifi, and note that a reverse proxy on the LAN that forwards outside traffic without forwarding headers would look like a LAN peer. Prefer naming your network, which trusts only private addresses inside it and so does nothing on anyone else's: `'192.168.1.0/24'` or a list. A refused device outside the named networks is told so in the server log. Or keep it out of the config: `TOSIJS_TRUST_LAN=1` (or a network, or `0`) sets it for one run and wins over the config. A network that cannot be parsed stops the server at launch. Agent driving, invite links, builds and test reports stay this machine only. |
| `memoryLimitMb`      | `4096`   | RSS ceiling for the dev server; past it, print growth-per-rebuild and exit (see below); also `DEV_MEMORY_LIMIT_MB`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `idleTimeoutHours`   | `8`      | exit after this long with no request and no rebuild; `0` disables (see below); also `DEV_IDLE_TIMEOUT_HOURS`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `audit`              | `true`   | dependency-audit gate — `bun audit` on the initial build — synchronously in `buildSite` (ungated high+ advisories fail the build), and after the port is bound under `bun start` (reported, never blocking) (findings annotated with the nature of the risk). `true`/omitted = `{ mode: 'fail', level: 'high' }`; `false`/`{ mode: 'off' }` disables; also `TOSIJS_AUDIT=off\|warn\|fail`. Time-box exceptions via `{ allow: [{ advisory, reason, expires }] }` (see below)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |

#### Dependency audit gate

`bun audit` knows the registry advisory database; nothing in a normal build ever
asks it, so a high-severity advisory in a transitive dep stays invisible until
someone runs it by hand. The gate asks once, at the point a human is looking:

- **When it runs.** The **initial** build only, and it blocks in one mode but not the other:
  `bun run build` / `--test` audit **synchronously** inside `buildSite`, and a finding fails
  the build. `devServer` binds the port **first** and audits after, reporting when the answer
  arrives — it never refuses to start.

  That asymmetry replaced "synchronous everywhere", which rested on the audit being
  sub-second. It is not: measured against the live registry, `bun audit` took **79.5s**, and
  the guard's own `AUDIT_TIMEOUT_MS` (20s) capped it, gave up, and **failed open** — so the
  twenty-second stall on every `bun start` was the cost of a check that did not happen. A
  release build is the place to wait; the edit loop is not. **Watch rebuilds never audit** —
  that would put a network call in your edit loop and break offline dev.

  A late finding is printed loudly and prefixed, so it cannot be mistaken for part of the
  startup banner it now comes after — non-blocking must not mean easy to miss.
- **It audits once per process**, however many callers there are. That invariant lives
  inside `auditDependencies` (the result is memoized), not in each call site
  remembering to pass `skipAudit`. It has to: the `{ build }` pattern above re-calls
  `buildSite` on every rebuild, so when the flag was the only guard, anyone following
  these docs got a registry round-trip on every keystroke — and offline dev broke.
- **What blocks.** Any advisory at or above `level` (default `high`, so `high` +
  `critical`) that isn't gated. `moderate`/`low` are reported, never fatal.
  Consumer-facing and developer-facing advisories block **alike**: a dev-only
  dependency still runs on your machine, and the time-boxed gate below is the escape
  hatch, so the cost of over-blocking is a two-minute gate entry while the cost of
  under-blocking is a miss.
- **Fails open, not closed, when it can't check.** Offline, registry down, a `bun`
  too old, or the audit exceeding its 20s timeout → it warns and proceeds. It fails
  **closed** only on a real finding.
- **Not downgraded in CI.** Unlike the machine-health preflight (a heuristic about
  someone's local box), an advisory is deterministic and environment-independent, so
  CI is exactly where you want it enforced.

**Each finding is annotated with the _nature_ of the risk** — parsed from the
advisory's CVSS vector (3.x `C/I/A` and 4.0 `VC/VI/VA` both understood) and its CWEs:

| label              | meaning                                                                                                        |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `LEAK/ALTER`       | confidentiality or integrity impact — can leak data or execute code                                            |
| `DoS-only`         | availability impact only — resource exhaustion, hang, crash                                                    |
| `DoS?+ESCALATABLE` | scored availability-only, but an escalatable CWE (e.g. prototype pollution) means the vector may understate it |
| `UNCLASSIFIED`     | no or unparseable vector — **treat as worst case**                                                             |

**The report is grouped, sorted, and complete.** `bun audit` emits one entry per
(package, vulnerable-range) pair, so a single advisory against a package present at
several versions arrives several times — on a real tree, **16 entries were 12
advisories across 6 packages**, and the lone `critical` (a VM-context escape leading
to RCE) printed _sixth_, purely because the raw output is in package order. So the
gate:

- **groups by advisory**, listing the affected ranges together (`affects <3.1.3,
  > =9.0.0 <9.0.6`), and reports the honest count (`12 … in 6 packages, from 16
  > findings`) rather than over-stating the workload;
- **sorts worst-first** — severity descending, then by nature, so `LEAK/ALTER` and
  `UNCLASSIFIED` come before `DoS-only` and the thing you must read is line one;
- **lists sub-threshold advisories compactly** (one line each, severity-sorted).
  These are never fatal, but they used to be invisible — and a `moderate` today is a
  `high` the day someone re-scores it;
- **tallies advisories per package** whenever any package produces more than one.
  A single moderate is noise; a dependency that keeps generating them is a **code
  smell**, and dropping a library with a long tail of quasi-flaky advisories is a
  legitimate call that needs the aggregate the per-finding view hides.

This is **annotation, not policy**: it never changes whether a finding blocks, it
just lets you triage in seconds instead of opening four browser tabs. Classification
deliberately fails **closed**, because it has to — measured against a real
44-advisory sample, **20% carried no CVSS vector at all, and those skewed severe**
(4 high, 2 critical). Anything that auto-softened on classification would have been
blind on exactly the worst ones. Whether a vulnerable path is reachable _in your
usage_ is not encoded anywhere and is not knowable from the data — that judgment is
yours, and the time-boxed gate is where it belongs.

**What to expect the first time you turn it on.** The first real adoption (`tosijs`,
12 blocking advisories → zero, **no allowlist entries**) is a good model for the shape
of the work:

- **The findings will be concentrated, not scattered.** Twelve advisories across four
  packages turned out to be _one_ stale toolchain. Read the per-package tally before
  you start patching — it usually names the real culprit, and fixing that clears a
  cluster.
- **Budget for an upgrade, not a pin.** If `brace-expansion` is flagged,
  `GHSA-mh99-v99m-4gvg` marks _every_ version below **5.0.8** affected, so an override
  is mandatory — and `brace-expansion@5` breaks **eslint 8**'s bundled `minimatch@3`
  with `expand is not a function`. So that one pin forces an **eslint 8 → 10**
  migration (flat config + typescript-eslint 8). It also clears the whole
  `minimatch`/`js-yaml`/`flatted` cluster, which is the tally's point.
- **"Prefer the minimal fix" has a limit.** When the minimal fix is blocked by a
  transitive incompatibility like that one, the larger replacement _is_ the correct
  fix, not a failure of discipline.
- **Expect the worst finding to be in a dev dependency.** `tosijs` ships **zero**
  runtime dependencies, and its critical finding — a happy-dom VM-context escape
  leading to RCE — was dev-only, and carried _no CVSS vector at all_. This is why the
  gate blocks regardless of dependency class and why classification never softens a
  verdict: filtering on either would have downgraded exactly that finding to a warning.

**Gating an accepted risk — with a deadline.** You can't always patch immediately.
Instead of silencing a finding forever, gate it with a reason and an **expiry**:

```typescript
audit: {
  allow: [
    {
      advisory: 'GHSA-25h7-pfq9-p65f', // GHSA id, the numeric id, or a package name
      reason: 'no untrusted parse path reaches it; patch tracked in #123',
      expires: '2026-08-15', // YYYY-MM-DD — after this the gate stops suppressing
    },
  ],
}
```

On/after `expires` the gate stops working and the build fails again — the risk is
forced back onto the table rather than living in an allowlist nobody re-reads. A
gate missing a `reason` or a valid `expires` is **ignored** (fail-closed): "gated"
means _explicitly and specifically_ gated. Stale gates (matching no current
advisory) are reported so you delete them.

**Due diligence when you do adopt a patch** (the gate prints this when it blocks):

- Read the advisory. Confirm the patched version actually fixes it and is published
  by the package's real maintainers — a fresh release is also how a hijacked package
  ships.
- Prefer the **minimal** fix: a targeted `overrides`/`resolutions` pin to the patched
  version beats a broad `bun update --latest` that churns dozens of transitive deps,
  each a new supply-chain surface. (For example, a `flatted` advisory was fixed with a
  one-line `overrides: { "flatted": ">=3.4.2" }`.)
- Treat large churn in a "patch" as itself suspicious; review what moved.

**Continuous drift is GitHub's job, not the gate's.** The gate catches advisories at
build time; advisories published later against an unchanged lockfile are caught by
GitHub Dependabot (alerts are automatic on public repos; add `.github/dependabot.yml`
to also get fix PRs). The two are complementary: Dependabot _notifies_, the gate makes
it _un-ignorable_.

#### Not taking the machine down with you

A dev server is a process that lives for **days**, rebuilding thousands of times.
Three things follow from that, and the build system enforces all three — because a
forgotten dev server is not inert, it is a days-old process **still running the code
it loaded at launch**. Updating the package does nothing for one that is already
running.

This is not hypothetical. Three such servers, left over from before a memory-leak
fix landed, grew to 103GB, 57GB and 49GB of RSS on a 32GB machine: ~210GB of demand
against 32GB of RAM, the compressor at 18GB, 14MB of free memory, and the page-out
scanner reclaiming _zero_ pages. macOS's jetsam never intervened — it let the box
thrash for twenty minutes until it was power-cycled.

- **Never call `Bun.build()` (or any native-heavy step) in the long-lived process.**
  Its native arena is never returned — ~30MB of RSS per call, monotonic, invisible to
  the JS heap and to `Bun.gc()` ([oven-sh/bun#34053](https://github.com/oven-sh/bun/issues/34053)).
  The build shells out to the `bun build` CLI instead, and the ePub step (happy-dom +
  `@resvg/resvg-js`, also native) runs in a child. The OS reclaims a child's memory on
  exit; the same 15 bundles cost **+0.5MB** in-parent instead of **+192MB**.

  **"On exit" is the load-bearing clause, so reap what you spawn.** Shelling out only
  helps if the child actually exits. The failure this omission produced, reported against
  a consumer that followed the advice above (tosijs-ui#93): a `dev.ts` that spawns
  `bun build --watch`, itself run under `bun --watch dev.ts`. Every source edit restarts
  the parent, which spawns a **fresh** watch bundler while the previous one is never
  signalled — one leaked bundler per edit, ~200MB each. Observed: **69 orphans holding
  13.09GB**, ~55 of them from a single 61-minute window, on a 128GB machine left with
  116MB free.

  A long-lived child needs both halves:

  ```typescript
  let child: ReturnType<typeof Bun.spawn> | undefined
  const spawnBundler = () => {
    child?.kill() // kill the PREVIOUS one before starting another
    child = Bun.spawn(['bun', 'build', ...args, '--watch'], { stdout: 'inherit' })
  }
  for (const sig of ['SIGINT', 'SIGTERM'] as const)
    process.on(sig, () => {
      child?.kill()
      process.exit(0)
    })
  process.on('beforeExit', () => child?.kill())
  ```

  A one-shot `bun build` needs none of this — it exits on its own, which is why this
  project's own builds are unaffected. It is `--watch` that turns a child into something
  you own for as long as you live, and `bun --watch` on the parent that makes "as long as
  you live" mean "until the next keystroke".

- **`memoryLimitMb`** — the dev server samples its own RSS after every rebuild and, past
  the ceiling, prints the growth-per-rebuild and exits. Growth per rebuild should be ~0;
  sustained growth is a leak, not a ceiling that is too low.
- **`idleTimeoutHours`** — the dev server exits after 8 idle hours (no request, no
  rebuild). The ceiling bounds how bad _one_ server gets; this bounds _how many there
  are_. An idle server has no value to weigh against being tomorrow's runaway.
- **Preflight** — every build and every dev-server launch samples the process table
  first and **refuses to start** if the machine is already in trouble: any process over
  half of physical RAM, or a `bun` dev process over the RSS ceiling that has been alive
  for more than an hour. It names the PIDs, their sizes, their ages, their project dirs,
  and the `kill` command. Nothing noticed the runaways because nothing ever looked.
  Override with `DEV_SKIP_PREFLIGHT=1`.
- **A health tick, not just events.** Every other check here is edge-triggered — the RSS
  sample fires after a rebuild, the preflight at launch — and all of them are blind to the
  state that actually kills machines: a server nobody is touching, sitting on gigabytes,
  on a box quietly filling up around it. _Nothing rebuilds, so nothing looks._ So the dev
  server also checks on a **timer**: the RSS ceiling every minute, and a full machine
  preflight every five. If the box is dying — even because of someone else's runaway — it
  exits and prints the PIDs on the way out.
- **A rebuild-storm detector.** The other way to eat a machine is not a leak but a **loop**:
  if the build writes a file the watcher watches, every rebuild triggers the next, forever —
  spawning a bundler each time. A loop is a leak with the throttle removed. The known
  self-writes (`version.ts`, `icon-data.ts`) are ignored by the watcher, but **`prebuild` is
  arbitrary consumer code**, and anything it writes into a watched path loops. Nobody types
  20 times in a minute: past that, the server names the files that keep firing — which _is_
  the diagnosis — and stops.

#### Nothing the dev server serves is cacheable

Every response carries `Cache-Control: no-store`. That is not paranoia — sending no
`Cache-Control` is not neutral, because a browser is free to invent a freshness lifetime when
you decline to state one, and Safari is the most willing to. The failure mode is a dev server
that rebuilds correctly while the browser keeps showing the previous build, which presents as
"my fix did not work" and is only escaped by emptying the cache by hand.

It applies to the **dev server only**. The built site in `docs/` carries no such header — that
is a static site for a real host, and telling a CDN never to store it would be actively wrong.

> If a page looks stale anyway, the other suspect is the **server**, not the browser: a running
> dev server keeps executing the code it loaded at launch, so a change to the server itself
> needs a restart, not a reload.

#### `debugSink` — telemetry back from a page that is not on this machine

`haltijaDev` is localhost-gated for good reason: an injected drive-my-browser channel reachable
from the network is RCE-adjacent. But that leaves a page served to a headset or a phone with no
way to say anything at all — and in a WebXR session there is no console and
`requestAnimationFrame` is suspended, so every usual readback path is gone at once. The
remaining channel is a human reading an in-scene panel aloud.

Set `debugSink: true` and the dev server accepts `POST /__debug-sink`, appending one line per
event to a JSONL file whose path it prints at startup:

```javascript
// page side — fire-and-forget, and survives rAF suspension
navigator.sendBeacon('/__debug-sink', JSON.stringify({ t: Date.now(), tag, ...event }))
```

```
Debug sink: POST /__debug-sink  ->  tail -f /tmp/tosijs-debug-6fe6a9b0.jsonl
```

`debugSink: 'logs/xr.jsonl'` chooses the path instead.

**Why this is reachable off-loopback when `/__docstore/source` is not.** It appends opaque bytes
to a scratch file, outside the repo, that the build never reads and nothing serves back. No code
runs and nothing is reconfigured. The source endpoint is remote code execution by design and
stays gated; this is a log. It is still **off by default**, because an unauthenticated write
endpoint on a LAN-reachable server should be a decision you made rather than one you inherited.

Bounded so a looping page cannot fill a disk: bodies over 64 KB are refused (413) and the file
stops growing at 32 MB (507). Newlines in a payload are escaped, so one event is always one line
— otherwise `tail -f` would show a split event and the reader could not tell.

#### `POST /__build` — let a build delegate to a running dev server

`bun run build` used to refuse while a dev server held the build lock, and the workflow that
produced was "kill the server, build, forget to restart it". It now asks the server to build and
reports what it says.

The endpoint is **loopback-only, not via the tunnel, and same-origin** (see the security note in
the CHANGELOG for why the last one matters). It answers JSON identifying itself — `pid`, `root`
and `outputDir` — and the caller refuses the result unless those match the lock it read and the
tree it asked for. A `?root=` that names a different project gets `409`. A dev server predating
this endpoint answers with the SPA shell, which the caller detects and reports as "restart your
dev server" rather than as a failed build.

The lock is unchanged otherwise: two builders racing `rm -rf` on one output tree is still
refused, and a stale lock still names its holder.

**`devServer(config, { test: true })` now defaults to port 8798** (Playwright's lane uses 8799),
so a test run no longer reclaims 8787 by killing the dev server you are looking at. An explicit
`PORT` or `config.port` still wins.

#### `haltijaDev` — Claude eyes on your running dev page

Set `haltijaDev: true` (or run with `HALTIJA_DEV=1`) and `bun start` gives a coding
agent (Claude) eyes **and hands** on your actual running page via
[haltija](https://github.com/tonioloewald/haltija): read the live DOM, click, type,
run JS, watch console/network, and **screen-capture** the rendered page — on the
real page you have open, with your real session state.

How it stays clean:

- The dev server injects a **one-line loader** into served HTML — a localhost-gated
  runtime `import()` of the local haltija channel's `dev.js`. Because it's pulled
  from the local server at runtime, **haltija is never bundled** (zero build bytes),
  and the `localhost` guard means it **self-disables** anywhere else.
- Injection happens **at serve time only**, so it never lands in the built output —
  your deployed static site is untouched.
- The dev server also **spins up (or reuses) a server-only haltija channel**
  (no desktop app) in `--both` mode: **HTTP 8700** (which the `hj` CLI drives) and
  **HTTPS 8701** (which the injected widget loads, so an HTTPS page has no
  mixed-content). Both certs are mkcert-signed — mkcert is already required for the
  dev server's own HTTPS — so there's no browser warning.

Then drive the page with the `hj` CLI (`hj tree`, `hj eval`, `hj click …`,
`hj screenshot`). The widget shows itself when the channel is active (Option+Tab to
toggle) — no silent snooping. For **screen capture** (`getDisplayMedia`, so no
Electron app needed), click the 🖥 button in the widget once to grant the share;
`hj screenshot` then writes a file and returns its path — no giant base64 in the
agent's context (add `--format webp --scale 0.5` for a compact capture, `--chyron
false` to drop the burned-in caption). Local dev only; off by default.

Tell your agent about it in the project's own agent instructions (`CLAUDE.md`,
`AGENTS.md`). `llms.txt` does not mention it: that file is for whoever consumes your
library, and how to run and drive your dev server is not their concern.

> The channel tracks haltija's **`@beta`** dist-tag, where the in-browser WebRTC
> screen capture landed ahead of `latest`.

##### `haltijaDev: 'tunnel'` — drive a page that is NOT on this machine

`true` is localhost-gated, and that gate is exactly what stops the feature working where
it is worth the most: a **VR headset**, or a phone. Debugging in a headset is close to
blind — no readable console, no devtools, no way to inspect the DOM without taking it off,
and taking it off is the thing you cannot do, because the bug is usually _about_ being in
it: viewport, pose, input, stereo layout.

Set `haltijaDev: 'tunnel'` and the channel is also served **same-origin over the tunnel**,
so the page in the headset attaches to the haltija running on your machine and `hj` drives
it exactly as it drives a local page:

```typescript
export default defineSiteConfig({
  haltijaDev: 'tunnel',
  preview: { tunnel: { url: 'https://myproject.edit.dev.example.com' } },
})
```

Redeem an edit link on the device (`tosijs-tunnel --link` gives you a 7-character code),
load the page, and it appears in `hj windows` like any other.

**Why it is a separate opt-in, not part of `true`.** `true` means "an agent may drive the
page on this machine", and the localhost gate is what makes that safe to leave on.
Upgrading must never silently turn that into "an agent may drive the page wherever it is
reachable". `HALTIJA_DEV=1` deliberately does **not** enable it either — that variable is a
convenience toggle, and this is not a convenience.

**What authorizes it.** Every piece — the loader, the component, and the socket — requires
a **live dev session that may write** (a share link's or the view key's read-only session
does not count), the same credential that gates source writes, decided by the same predicate
(`mayDriveWithAgent`, which delegates to `mayWriteSource`). The component and the socket must
also come from the workspace's own origin. Driving a page
with an agent is at least as powerful as writing source, so it gets no weaker a gate. As
everywhere else in this server, what authorizes is the **listener**, not the peer address —
a reverse tunnel counterfeits "local" by construction. Unauthorized requests get a **404**,
not a 403: whether this project enabled the bridge is nobody's business but the session
holder's.

**Why same-origin.** Over the tunnel, `localhost` is the **headset**. Every hardcoded
`https://localhost:8701` in the upstream loader chain resolves on the wrong machine. So the
dev server serves the two things the page actually needs — the component and the socket —
from the page's own origin under `/__haltija/`, and relays the socket to the local channel.
Nothing has to reach _into_ the device: the page dials **out**, which is what makes this a
relay rather than a hole.

**Requires haltija to be running** on your machine (`bun start` starts the channel). If it
is not, the component proxy answers `502` and says so rather than failing silently.

**What the session gate does NOT cover.** Everything above is about _our_ relay, and the relay
is gated. But enabling `haltijaDev` at all means haltija itself is running, and **its own port
is a separate surface we do not gate**. A default haltija binds beyond loopback, answers
`Access-Control-Allow-Origin: *`, and with no token set an unauthenticated caller — script on
any origin in a browser on the same machine, or anything on your LAN — can reach
`POST /terminal/command`, which spawns a shell. That is
[haltija#40](https://github.com/tonioloewald/haltija/issues/40), it is open, and it is a
deliberate posture question upstream rather than an oversight (the obvious per-endpoint patches
break the tunnel case itself).

So: set **`HALTIJA_TOKEN`** if you enable this on a network you do not control. It only became
genuinely usable in haltija 1.12.6 — before that the widget never sent the token, so requiring
one broke every page-side feature, which is why older advice said to skip it. Do not read our
404-on-unauthorized as covering the channel underneath; it covers the bridge, not the port.

#### `layout` — let a page opt out of the reading column

Prose wants a measure. 44em is roughly the line length people read comfortably, and it is the
default for good reason. But a doc site is not only prose: a demo, a dashboard, a wide table or
a canvas is _worse_ squeezed into a column, and having to pick one habit for the whole site is
what makes people build a second site.

Set `layout` in a page's metadata — the same place as `pin`, `order` or `parent`:

```markdown
<!--{ "layout": "full-width" }-->
```

```yaml
---
layout: full-width
---
```

The nav and the chrome stay; only the measure goes. Unset means the reading column, so nothing
changes for a corpus that never asks.

It is stamped into the **served HTML**, not applied on load, so the first paint is already
right — a layout applied by script would show the reading column and snap wide, which is the
flicker pre-rendering exists to prevent. Client-side navigation keeps it in step in both
directions.

`layout: "full-screen"` goes further: no measure **and** no nav, so the content is the whole
viewport — for a demo, a canvas, an embedded app. See [the demo page](/full-screen-demo/).

It works by putting `<tosi-sidenav>` into `alwaysCompact` with the content showing. Compact mode
already shows the nav _or_ the content and takes turns between them, which is exactly what a
full-screen page wants, so this reuses that behaviour rather than adding a second layout. Before
hydration there is no sidenav yet — just static markup — so a stylesheet rule covers the first
paint.

`"header": false` removes the site's header bar on a page, before hydration and after, and
gives its space to the page. With `layout: "full-screen"` the page has no site chrome at all,
for an app page with its own toolbar or an installed PWA (#226). The ☰ button lives in the
header, so such a page links back to the docs its own way. See [the demo page](/no-header-demo/).

`"home": true` (or the site's `home` option) makes a page the site root, `/`. README then moves
to `/readme/`, so **README's URL and canonical link change**: inbound links to `/` now reach
the new home page, and anything that linked to README by its root URL needs `/readme/` (#225).

#### Serving a page at a second path

To serve a page at another path as well as its own, name the path in `aliases`. An app at the
root that still wants its README at `/readme/` writes `aliases: { readme: '' }`; a site that keeps
an old address alive writes `aliases: { 'getting-started': 'guide' }`.

Each alias is a copy of the page written where it is served, because a page's stylesheet,
bundle, `docs.json` and nav links are relative to where it sits (that is what lets one build
work under any mount). A host rewrite from `/readme/` to the root page cannot work: it serves the
root page's bytes one level down, those references 404, and the page renders but never
hydrates. The browser is told about the aliases too, so `/readme/` opens as the README rather
than as whichever doc comes first.

An alias page's canonical link names the page's own path, and aliases stay out of the sitemap,
the nav, `llms.txt` and books, so search engines see one page. Links from an alias page go to
the pages' own paths, and a relative reference in the page (a README's `img/logo.png`) is
rewritten so it still reaches the same file. An alias is one lowercase path segment with no dot
(`readme`, `getting-started`); the build fails on one that is not, that takes a page's own path,
that names a static file or folder the site already has, or that names no page (#3513).

#### `preview` — deploy the built site to a host you control

A doc site is a folder of static files, so sharing one is a copy, not a pipeline. Set a
host and deploy:

```typescript
preview: {
  url: 'https://ui.dev.example.com', // optional; also names the route to register
}
```

**Do not put `host` in a committed config.** The bins resolve
`--host=` > `PREVIEW_HOST` > `PREVIEW_SSH` > config > `~/local-secrets/tosijs-preview.env`,
and a committed address means any fork running `bun run tunnel` opens outbound SSH to your
box. Keep it in `~/local-secrets/tosijs-preview.env` — a `700` directory beside your repos,
never inside one, sourced from `~/.zshenv` so scripts and agents see it as well as you.
A profile-only `export` is present for a human and absent for every tool.

That is a _structural_ guarantee rather than a rule, and it needed to be: this project
documented "keep it out of a committed config" here while its own `tosijs-site.config.ts`
published the address in a public repo's history for months. **A credential that has ever
been committed is public — rotate it.** Rewriting history does not un-publish anything.

```bash
bun run deploy        # DRY RUN — shows exactly what would change
bun run deploy --go   # sync, self-register, refresh the host's index
```

**Dry run is the default** because this is `rsync --delete` — the remote must mirror the
build so stale pages can't linger, which is destructive if aimed wrong. It also **warns
when your working tree is dirty**, since `/version.json` records the last _commit_ and a
build from a dirty tree may not match it.

The target must sit **inside a known preview root** — `/srv/preview`, `/srv/www`,
`/var/www/preview` or `/opt/preview` — and strictly inside one, never the root itself.
(An earlier rule accepted "any absolute path at least two levels deep", which happily
admitted `/usr/lib` and `/etc/caddy`; `rsync --delete` would have _mirrored_ those, i.e.
emptied them. Admitting the preview root itself was just as bad in a subtler way: one
dropped path segment would have deleted every other project on the box.) If your host
uses a different location, pass it explicitly and open an issue — the allowlist is
deliberately short.

**Projects register themselves.** With `url` set, the deploy writes a small Caddy
fragment declaring its hostname and root; the server glob-imports
`/srv/preview/_sites/*.caddy`. So adding a project touches no shared file, and with a
wildcard DNS record it needs no DNS change either. The deploy **validates the server
config before reloading** and refuses to reload if invalid — one malformed fragment would
otherwise break routing for every project on that host.

The host's root can serve a generated index of everything deployed (see
`deploy/build-index.sh` in the tosijs-ui repo), which makes it self-describing: what is
on it, and which commit each preview is serving.

> The _static_ preview host has **no write endpoint**, so its security question is
> disclosure rather than code execution. It is gated by an invite-link cookie rather than
> `basicauth`: a password dialog on every phone defeats the point of a shareable link,
> and one shared secret is no stronger. Do not confuse this with exposing a _dev server_
> — that has a write endpoint, and is gated by a per-session magic link; see
> [`preview.tunnel`](#previewtunnel--the-live-workspace) below.

#### Runtimes — what runs where

The doc-site system is a **bun** tool: it shells out (`Bun.$`), builds (`bun build`), and
spawns child processes. `import { buildSite } from 'tosijs-ui/site'` under plain Node
fails with `Cannot find package 'bun'`, which names a symptom rather than the cause — so
to be explicit:

| entry point                          | runtime                                                                    |
| ------------------------------------ | -------------------------------------------------------------------------- |
| `tosijs-ui/site`                     | **bun** — build/CLI only, never bundled into a page                        |
| `tosijs-ui`, `tosijs-ui/<component>` | a **browser** (or a bundler targeting one); bare Node has no `HTMLElement` |
| `tosijs-ui/icon-svg`                 | anything — deliberately DOM-free, which is why it exists                   |

Module _resolution_ works everywhere as of 1.9.1: shipped code uses explicit `.js`
specifiers, which Node ESM requires and bundlers accept. Before that, `dist/` carried
extensionless relative imports that only bun could resolve — so a Node consumer got
`Cannot find module` on entry points that had nothing to do with bun. That was invisible
here because every lane ran under bun; the consumer lane now imports through Node too.

#### Host bootstrap — do this once per box

Both `tosijs-deploy` and `tosijs-tunnel` write a Caddy fragment ending in
`import preview_site` / `import tunnel_site`. **Those snippets have to exist first**, or
`caddy validate` fails on every deploy forever — and the failure is per-project, so
nothing routes.

The package ships a template at `node_modules/tosijs-ui/deploy/Caddyfile`. It is a
_template_, not a drop-in: `{{ACME_EMAIL}}` (your Let's Encrypt account),
`{{PREVIEW_DOMAIN}}` (the domain your preview hosts live under), `__PREVIEW_TOKEN__` (the
shared invite secret) and `__PREVIEW_EDIT_TOKEN__` (the optional [global edit
key](#global-keys--one-link-for-every-workspace)) have to be filled in.

Installing it with the placeholders intact would give you a preview host whose invite gate
is a literal string published in a public repo, issuing certificates under someone else's
account. So don't do it by hand — **`tosijs-caddy-install` does it and refuses when it would
go wrong**:

```bash
# 1. on the box, once: the values the template needs
#    (a secret you invent; never in the Caddyfile, never in git)
cat > /etc/caddy/preview.env <<'EOF'
PREVIEW_TOKEN=<a long random string>
PREVIEW_EDIT_TOKEN=<another, only if you want the global edit key>
ACME_EMAIL=you@example.com
PREVIEW_DOMAIN=dev.example.com
EOF

# 2. from your project — DRY RUN, prints a diff against the live config, changes nothing
tosijs-caddy-install

# 3. once the diff looks right
tosijs-caddy-install --go
```

It reads `preview.host` from your site config (or `--host=user@box`, or `PREVIEW_HOST`), and:

- **substitutes on the box**, reading `/etc/caddy/preview.env` — so the installer never
  carries `PREVIEW_TOKEN` or `PREVIEW_EDIT_TOKEN` to your laptop, they never appear in your
  shell history, and they are redacted out of the diff before it is printed (the [global
  keys](#global-keys--one-link-for-every-workspace) are the one case where you copy them to a
  machine yourself). The redaction covers the keys the LIVE file holds too: after you rotate a
  key, the diff's removed lines carry the previous one, so every value in a key position
  (`tosi_preview=`, `tosi_edit=`, `query t=`) on either side is blanked, and so is every other
  place that value appears. Through 1.16.34 only the current keys were, and a dry run after a
  rotation printed the old key in clear: if you ran one, treat that key as seen by whatever kept
  your terminal output;
- **fills in an unknown value** for `__PREVIEW_EDIT_TOKEN__` when `PREVIEW_EDIT_TOKEN` is unset,
  so the edit key opens nothing, and refuses one shorter than 24 letters, digits, `-` or `_`,
  or one equal to `PREVIEW_TOKEN`;
- **refuses a `PREVIEW_TOKEN`** with anything but letters, digits, `-` and `_`: it goes into
  the gate's cookie regex, where a `.` would loosen the match;
- **refuses to install** if any placeholder survived substitution, naming the line — the
  guard that a hand-rolled `sed` pipeline does not have;
- **validates before swapping**: `caddy fmt` then `caddy validate` against a scratch file,
  and the live config is replaced by an atomic `mv` only once it parses, so a bad template
  leaves the running site untouched;
- **installs the file readable by Caddy and nobody else**, because it holds both keys: mode
  `640` with the group systemd runs Caddy as (`Group=` in the unit, or `User=`'s own group), or
  `600` when Caddy runs as root;
- **takes group and other access off the preview access logs** (`/var/log/caddy/preview*.log*`)
  that exist when you run `--go`, so only their owner (Caddy) and root read them. They record
  short-link codes (see "What it costs" under short links, below);
- **reloads Caddy, or starts it if it is stopped**, and if Caddy refuses the new config puts
  the previous one back (it stays in `/etc/caddy/Caddyfile.bak` either way) and exits non-zero.
  Versions 1.10.0 to 1.16.26 installed the file readable by root only, which a Caddy running as
  its own user cannot read: if you ran `--go` with one of those, re-run it;
- is **dry-run by default**, because it replaces `/etc/caddy/Caddyfile` wholesale — if the
  box serves anything else through Caddy, read that diff before `--go`.

Bring your own template with `--template=./path/to/Caddyfile`; the same guards apply.

**A template of your own does not pick up changes to the shipped one.** In particular it keeps
whatever gate page it had, and `tosijs-tunnel` keeps saying "the box's Caddyfile does not serve
short links yet" however often you re-run the installer, because short links need routes your
template does not have. To get them, copy from `deploy/Caddyfile` (in
`node_modules/tosijs-ui/deploy/`):

- into the block that gates your previews (`preview_site` in the shipped file), the `@unauthed`
  handler, so a browser without a cookie gets the page with the box for a link or a code:

  ```text
  handle @unauthed {
  	header Content-Type "text/html; charset=utf-8"
  	header Cache-Control "no-store"
  	respond "__GATE_PAGE__" 401
  }
  ```

- into your `{{PREVIEW_DOMAIN}} { … }` block, BEFORE its cookie gate (before `import
  preview_site`), everything from `@codefile {` to the end of `handle @codequery { … }`: the
  `_codes/` file server, its explicit 404, and the two routes that answer `/<CODE>` and `/?t=<code>`
  with the gate page.

The installer fills `__GATE_PAGE__` from `deploy/gate-page.html` in the package, and reads that
file only when your template names it.

You also want a wildcard DNS record (`*.dev.example.com`) pointing at the box, so a new
project needs no registrar visit, and sshd running `GatewayPorts no`.

#### Linking the books the build produces

The build writes an ePub per volume — but a file nobody links to is a file nobody can
download. Three ways to surface them, cheapest first.

**A marker in any page.** Drop this where you want the list:

```text
- [tosijs-ui](/tosijs-ui.epub) *(ePub)*
```

It is replaced at build time with one markdown link per volume, using each volume's title
and its real output URL. Substituted into the corpus before pages render, so the static
HTML and the hydrated SPA show the same thing.

**A manifest.** Every build writes `/epub-volumes.json` to the output dir:

```json
[
  { "book": "", "title": "my-project", "filename": "my-project.epub", "url": "/my-project.epub",
    "coverFilename": "my-project-cover.png", "coverUrl": "/my-project-cover.png" },
  { "book": "field-guide", "title": "my-project — field-guide",
    "filename": "my-project-field-guide.epub", "url": "/my-project-field-guide.epub",
    "coverFilename": "my-project-field-guide-cover.png",
    "coverUrl": "/my-project-field-guide-cover.png" }
]
```

`coverUrl` is the volume's cover as a plain image, written beside the ePub (a PNG when
generated; an explicit `cover` keeps its own type). The two cover fields are present only
when the build wrote that file: they are read back from the output dir after the ePubs are
built, so a volume with no cover (no `@resvg/resvg-js` and no explicit `cover`, or a failed
ePub build) simply lacks them. `epubVolumeIdentity` does not return them, and a build that
sets `epub.output` writes the cover beside that file where the manifest does not look.

**The helper**, if you are generating links in your own code:

```typescript
import { listEpubVolumes, epubVolumeIdentity } from 'tosijs-ui/site'
```

If you run your own build in place of `buildSite`, `attachCovers(volumes, filesInOutputDir,
basePath)` adds the cover fields for the covers `buildEpub` wrote, and
`printCoverManifestUrl(config)` is the value to pass `generateSite` as `bookVolumes` so Print
can find them (the manifest is `VOLUME_MANIFEST`, `epub-volumes.json`). Pass `ebooks` too:
`listEpubVolumes(docs, config)` mapped to `{ title, url }`, or `[]` for a site with no ePub.
It is what the settings menu's "Download ePub" entry is built from; left unset, the entry
links to a name derived from `projectName`, which is a 404 on a site that builds no book.

> **Do not hard-code the filename.** It is _derived_ — `<project>-<volume>.epub` — so a
> hand-written link rots the moment a volume is renamed, and rots silently, since nothing
> checks that a link points at a file the build made. That is exactly how a project ships a
> valid ePub that nobody can download. The marker, the manifest and the helper all derive
> the name from the same function the ePub build uses, so they cannot disagree.

Note the title is for humans and the filename is an identifier: `epub.volumeTitles` renames
the former without moving the latter, so published links survive a retitle.

#### `preview.tunnel` — the live workspace

The static preview publishes a _snapshot_. `preview.tunnel` publishes the **running dev
server on your machine**, at an authenticated public URL, so you can read and edit real
source from a phone or a borrowed laptop. The box does no compute — it terminates TLS and
routes — which is what lets one small VPS front many projects.

```typescript
preview: {
  host: 'me@vps.example.com',
  path: '/srv/preview/my-project',
  url:  'https://my-project.dev.example.com',        // static snapshot
  tunnel: {
    url: 'https://my-project.edit.dev.example.com',  // live workspace
  },
}
```

Two hostnames, because the postures genuinely differ:

| host                             | what it is                    | gate            |
| -------------------------------- | ----------------------------- | --------------- |
| `<project>.dev.example.com`      | read-only snapshot, shareable | invite cookie   |
| `<project>.edit.dev.example.com` | live workspace, yours         | session, always |

```bash
tosijs-tunnel            # open the tunnel (foreground; Ctrl-C closes it)
tosijs-tunnel --status   # is one already up?
tosijs-tunnel --link     # print a fresh edit link + its 7-character code (5 min)
tosijs-tunnel --close    # close any tunnel this project opened
```

**How the gate works.** `--link` prints a URL carrying a short-lived token (see _Link security_ below). Opening it
once exchanges the token for a durable `HttpOnly; Secure; SameSite=Lax` session cookie
and redirects to the same URL with the token stripped — so the token never lands in
history, in the address bar, or in a `Referer`. A second window shares the cookie. A link
that has already been used says so rather than failing silently, because a chat app's
link-preview bot will often spend it before you click.

**What authorizes a write is the LISTENER, not the peer or a header.** The dev server
binds a separate loopback-only port for tunnel traffic; anything arriving there needs a
valid session to write, whatever its address claims. This matters because a reverse
tunnel counterfeits "local" by construction — an earlier design inferred "local" from
absent `X-Forwarded-*` headers and therefore failed _open_ for every forwarder that
omits them.

| option                  | default                       | purpose                                                                                                    |
| ----------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `tunnel.url`            | —                             | the authenticated public URL fronting the workspace                                                        |
| `tunnel.requireToken`   | `true`                        | require a session even to VIEW; set `false` for a live read-only audience                                  |
| `tunnel.globalKeys`     | `true`                        | accept the preview host's [global keys](#global-keys--one-link-for-every-workspace): `'view'` for the view key only, `false` for neither |
| `tunnel.linkPolicy`     | `'window'`                    | `'window'` — a link is redeemable repeatedly until it ages out; `'single-use'` — spent on first redemption |
| `tunnel.linkTtlMinutes` | `5`                           | how long an EDIT link stays redeemable (write-capable, so keep it short)                                    |
| `tunnel.shareTtlMinutes`| `1440`                        | how long a `--share` link stays redeemable; read-only, capped at 7 days                                     |
| `tunnel.remotePort`     | derived from the project name | loopback port on the box; derived (FNV-1a into 9000-9899) so two projects can't collide                    |
| `tunnel.localPort`      | `port + 1`                    | the loopback port the tunnel forwards to                                                                   |

#### Global keys — one link for every workspace

The links above hand one workspace to one device, so every extra project means another
redemption, and a link only brings its browser back after a restart for a day. With several
projects tunnelled through one box, that is the recurring cost. The box can instead hold two
machine-wide keys:

| key                  | opens                                                          |
| -------------------- | -------------------------------------------------------------- |
| `PREVIEW_EDIT_TOKEN` | every static preview, and every workspace **for editing**      |
| `PREVIEW_TOKEN`      | every static preview, and every workspace **read-only**        |

`PREVIEW_TOKEN` is the static previews' invite secret you already have; the edit key is new
and optional. Put both in `/etc/caddy/preview.env` on the box (and re-run
`tosijs-caddy-install`, as after every tosijs-ui upgrade, so the box runs the current template), and the same values in `~/local-secrets/tosijs-preview.env` on each
machine that runs a dev server (or in its environment):

```bash
PREVIEW_TOKEN=<the view key>
PREVIEW_EDIT_TOKEN=<the edit key>
```

`tosijs-tunnel` then prints a **single-use link** for each key, which carries a one-use code,
never the key:

```
   Single-use links: each opens this workspace, then keeps that browser in every
   workspace on the box (and its root list) for 30 days:
   edit: https://ui.edit.dev.example.com/?t=KFMRPWQT
   view: https://ui.edit.dev.example.com/?t=HDTXBNEJ  (read-only)
```

Opening one starts a session in this workspace and sets an `HttpOnly`, `Secure` cookie across
the preview domain that lasts 30 days. A view link sets the view key. An edit link sets a
**pass for that one device**, signed with the edit key, so the key itself never reaches a
browser, and each device can be revoked on its own (see "Devices" below); it sets the view key
too, which is what opens the box's root list and the static previews. From then on that
browser opens every workspace holding the key, and the box's root list, without another link,
across dev-server restarts. The code is spent by the first browser that uses it, and both kinds
expire after 5 minutes: the cookie is what lasts, not the code that delivers it.
`tunnel.linkTtlMinutes` can shorten that window but not lengthen it, and `tosijs-tunnel` refuses
`--share-ttl` with `--global`. To give someone view access later, send them a plain `--link --share` link instead.
`tosijs-tunnel --link --global` prints a fresh edit one, and `--link --global --share` a fresh
view one. Plain `--link` and `--link --share` are unchanged: reusable for their window, and
they open this workspace only.

The tunnel offers a link only when it means what it says: the workspace accepts that key, and
the box holds the same one. It reads the box's `PREVIEW_DOMAIN` and a sha256 of each key over
ssh (the keys stay on the box), and when it cannot offer a link it prints why, such as a key
that differs between this machine and the box.

The **view** key can also be given by hand at any preview host with `?t=<view key>`; add
`&to=<label>` to be sent on to `<label>.<preview domain>`. `to` takes DNS labels only, so it
cannot redirect anywhere else. The **edit** key has no URL form (since 1.16.31): a URL holding
it would be a permanent bearer token for writing source on every workspace, sitting in browser
history and anywhere it was pasted. Use `tosijs-tunnel --link --global`.

With the cookie set, the box's root, `https://dev.example.com/`, is where you pick a workspace:
it lists every registered one and asks each what your key opens there (`edit`, `view`, or
nothing), and a workspace that is offline, or whose dev server predates tosijs-ui 1.16.26, says
it is not answering. A dev-server restart costs you nothing, which matters because `bun start`
restarts on any edit to a file in its import graph, examples in component sources included. The
next page load trades the cookie for a fresh session, and so does the next save from a page that
was open across the restart: a request from the workspace's own page renews the session in
place, so an unsaved edit is never lost to a reload, and so does the haltija bridge's
reconnection. "Own page" takes evidence: `Sec-Fetch-Site: same-origin`, or an `Origin` that is
this host. A request from another workspace's page gets nothing though it carries the same
cookie, and so does one with neither header (a script, `curl`). A session opened from a plain
`--link` holds no key, but it comes back the same way for a day: see "Restarts" below. A session started from the view key can browse and never save, never
drive the page with an agent.

**Use long keys.** The box compares them with no rate limit in front, unlike the link codes,
so both must be at least 24 letters, digits, `-` or `_`; the installer refuses a shorter edit
key, and the dev server ignores a short key and says so at launch. The two must differ.

**Devices.** Every `--link --global` edit link is a pass for one device, recorded on the box
when the link is printed. Name the device with `--label`:

```bash
tosijs-tunnel --link --global --label="iPad"   # a link for one device, named
tosijs-tunnel --devices                         # every pass issued, newest first, and which are revoked
tosijs-tunnel --revoke=k3v9x2mq7a               # end that device's edit access, on every workspace
```

Revoking adds the id to `revoked.txt` beside the box's root list. Every workspace sweeps that
list once a minute and ends the sessions a revoked device already holds when the sweep sees it.
To honour a new pass it uses its copy of the list, refreshing it behind the request once the
copy is a minute old, so a pass can be honoured on a copy up to ten minutes old while the box
is slow to answer. Revoking removes EDIT access only: an edit link also gave
that device the shared view key, so it can still browse, read-only, until you rotate the view
key. A workspace that cannot read the list (and has no copy less than ten minutes old) honours
**no** new pass until it can: it fails closed. Sessions a pass already opened carry on until
they expire (24 hours) or a readable sweep ends them. A pass also expires 30 days after it was issued (a
workspace refuses one claiming longer); a fresh link gives a fresh one.

Edit links from tosijs-ui 1.16.30 and earlier set the raw edit key as the cookie. That cookie no
longer opens a workspace, and a new link expires it, so each device needs one new
`--link --global`. The box itself still accepts it for the root list and the static previews,
because the dev servers present the edit key there to read `revoked.txt`.

**If you used `--global` edit links or `?t=<edit key>` on 1.16.26–1.16.30, rotate the edit
key.** Those versions put its raw value in browser cookies (for 30 days) and URLs (so browser
history and anywhere one was pasted), and in 1.16.31 that same value signs every device pass:
anyone holding a copy could sign passes of their own that `--revoke` cannot see. Set a new
`PREVIEW_EDIT_TOKEN` in `/etc/caddy/preview.env` on the box and in
`~/local-secrets/tosijs-preview.env` on each machine, re-run `tosijs-caddy-install --go`, and
restart the dev servers. Upgrade every project that tunnels through the same box first: a new
edit link expires the old `tosi_edit` cookie, so a workspace still on tosijs-ui 1.16.30 or
earlier then opens read-only in that browser.

**What it costs.** The view key is still a shared value in the cookie, because Caddy can only
compare a fixed value: a leaked view cookie is read access until you rotate the view key (edit
both files, re-run the installer, restart the dev servers). From 1.16.31 the edit key only signs
passes and never reaches a browser. Rotating it invalidates every pass at once; revoking ends
one device's edit access.

**The view key is your preview invite.** Everyone you have given a static-preview link (or its
30-day cookie) can also read every workspace that accepts the view key while it is up,
`requireToken` or not. That is what "global view" means here; if some of those people should
not see live work, set `tunnel.globalKeys: false` on that project, or rotate the invite
secret. The cookie is
`HttpOnly` and `Secure`, it goes only to hosts under your preview domain, and a page on one of
them cannot use it to write to another: source and data writes over the tunnel are refused
unless they come from the workspace's own origin (sibling hosts are the same *site*, so
`SameSite` alone would not stop them).

**Per project.** `tunnel.globalKeys: 'view'` lets either key browse a workspace from the list
and neither edit it; `false` makes it ignore both keys, so only its own `--link` opens it. Any
other value accepts neither key and says so at launch.

**Not logged.** The shipped Caddyfile redacts `t` and `token` from the URIs in its access log
(`/var/log/caddy/preview.log`), and Caddy keeps cookies out of it. Versions before 1.16.28
logged them: if you ever visited `?t=<key>` on such a box, scrub or delete the old logs
(`preview*.log*`), or rotate the key.

#### Restarts: a link keeps its browser in for a day

Sessions live in the dev server's memory and are never written to disk, and `bun start`
restarts on any edit to a file in its import graph. So a plain `--link` or `--share` session
used to end at the developer's next save, and the reader needed a new link. Now a link redeemed
through the tunnel also sets a **link pass**: a cookie holding the link's kind (edit, or
read-only for `--share`), the workspace host, and an expiry 24 hours later, HMAC-signed with a
secret this project keeps on disk. After a restart the next page load, or the next request
from the page that was open (a save, the haltija bridge), trades the pass for a fresh session,
exactly as a global key is traded, so an unsaved edit is not lost to a reload.

- **Kind**: the link's. A share link's pass only ever opens a read-only session.
- **Host**: the workspace it was issued on. The cookie is `__Host-tosi_link_pass` (host-only,
  `Secure`, `HttpOnly`, `Path=/`), so the browser sends it nowhere else and a sibling host
  cannot set one, and the server checks the signed host against the request's.
- **Expiry**: 24 hours after the link was opened, never extended. A session it mints lasts only
  as long as the pass has left. Past that, a restart walls the browser and it needs a new link.
- **The secret** is `.tosijs-dev/session-secret` in the project root: created on the first
  redeemed link with mode `0600` in a `0700` folder, with its own `.gitignore` so it is never
  committed in any repo. The dev server refuses to start if the output folder would contain
  it. If group or others have any access to it (`chmod 600` it), at launch or later, passes are
  off: none is honoured, new links get a session but no pass, and the console says why. On a
  filesystem that keeps no modes (a Windows drive under WSL, FAT, some network mounts) the file
  can never pass that check, so plain links there end on restart, as they used to.
- **Revoking**: delete the secret file. Every pass it signed stops opening anything on the next
  request, and the next redeemed link makes a new one. Sessions already open run on until the
  next restart, as they always did. Revoking a device (`--revoke`) does not touch link passes:
  a browser that also holds one keeps that link's access for the rest of its day.

**What it costs.** Before link passes, nothing about a link session outlived the process: a
stolen session cookie died with the server. A stolen link pass opens that workspace, with the
link's kind, for the rest of its day, across restarts, until you delete the secret. It is as
hard to steal as the session cookie beside it (`HttpOnly`, `Secure`); anyone who can read the
secret file can sign passes of their own, which is why it is owner-only and outside the served
folder. A granting (`--global`) link does not set one: its key cookie already outlasts restarts.

#### The token is eight letters, and you can type it

`--link` prints the URL **and the code on its own line**, because on the device this exists
for you are not pasting anything — you are reading characters off one screen and entering
them on another:

```
🔗 Edit link — usable on more than one device for 5 min, then it expires:
   https://myproject.edit.dev.example.com/?t=JCYGCCVX

   code:  JCYGCCVX   (case-insensitive)
```

It is **letters only, no digits** — and that last part is the point. On a headset the
letter↔number switch is a trip to a different keyboard page, and a reported XR session minted
`A70MDCD` and `X9Q3Z53`: three digits each in seven characters, so three round trips for a
code being gaze-and-pinched one glyph at a time. **The extra character is free; the mode
switch is not.**

`I`, `L`, `O` and `U` are still excluded. `I`/`L` are indistinguishable across case — which
matters _more_ now, not less, because the code is case-insensitive so the reader cannot use
case to tell them apart. `O` stays out for `O`/`Q` at headset rendering resolution, and `U` so
an unlucky token cannot spell an obscenity.

Case is folded on redemption, hyphens and spaces are ignored, and the digits people might type
for a letter they misread (`5`→`S`, `2`→`Z`, `8`→`B`, `6`→`G`) are mapped back — pure upside,
since a digit can never be part of a real code. The failure this designs out is a correct human
being told they typed it wrong.

The opposite failure is designed out too. A code that matches no live link gets the code box
back, saying so, with what you typed already in it, so a one-letter typo is a one-letter fix. A
code that has been spent, or has expired, says which (the server remembers retired codes for an
hour). Through 1.16.32 all three read "that invite link has been used", which sent a headset
user to mint a new link when retyping would have done.

Eight letters is ~35.7 bits — slightly _more_ than the seven base32 characters it replaced
(35.0) — and the server makes that plenty by capping how fast anyone can guess: **redemption is
serialized, and every attempt takes at least 100ms**, success or failure alike. Ten attempts a
second against 22⁸ ≈ 5.5 × 10¹⁰ is ~174 years to exhaust, and 2,944 guesses inside a five-minute
window: **about 1 in 18 million** (the throughput measured, not estimated — 20 concurrent
attempts complete at 9.8/sec).

Opening more connections buys nothing, because concurrency is one. The floor covers successes
too, so response time cannot answer _was that the right token?_ — delaying only failures would
turn the throttle into the oracle that `safeEqual`'s constant-time comparison exists to
prevent.

After **ten consecutive failures the slot widens to a second** — another factor of ten
against a guesser, and nothing at all against a human, who does not mistype seven characters
ten times running and would wait a second if they did.

There is **no lockout** for ordinary use: a lockout a few mistypes can trigger is a denial of
service against you, on the one credential you need in order to work. The door never closes on
a human; it only gets slower to knock on.

Under an actual flood the endpoint sheds load and refuses everyone, you included, and that is
deliberate. Redeeming a link _while someone is attacking the endpoint_ is a non-goal — what has
to hold is that brute force fails hard, and it does. Note where the security actually comes
from: the **slot** sets the guess rate, the queue depth only decides how a burst is absorbed.

#### Short links: `dev.example.com/<CODE>`, or type the code into the box

A `--global` link names its workspace (`https://ui.edit.dev.example.com/?t=RCQAMHFX`), and on a
headset `/`, `=` and digits are each a trip to another keyboard page. So the tunnel also tells
the box which workspace minted the code, and prints a short form with the code in two groups,
which are easier to hold in your head than eight letters:

```
🔗 Single-use link (that browser keeps every workspace for 30 days):
   https://ui.edit.dev.example.com/?t=RCQAMHFX
   https://dev.example.com/RCQAMHFX   (the same link, shorter)

   code:  RCQA MHFX   (case-insensitive; or open dev.example.com and type it into the box)
```

On a headset, the shortest route is to open `dev.example.com` (letters and dots only) and type
the eight letters into the box on the page that opens. That box is on the page a browser
without a cookie sees, and on the root list too, because a browser that still holds the view
cookie lands there instead. The box also takes a whole link pasted in, and follows it only to
a host under your preview domain. `?t=<code>` at the root, or on any preview host, works the same.

How it works: `tosijs-tunnel` writes `/srv/preview/_codes/<CODE>` on the box, holding the full
link, and sweeps codes past their five minutes. The box's gate page (`deploy/gate-page.html`,
embedded by `tosijs-caddy-install`) reads it and goes there, so the code is still redeemed by
the dev server that minted it, once, at the rate above. Only links from `--link --global` and
the ones printed at tunnel start are recorded; a plain `--link` opens one workspace and its
link already names it.

**What it costs.** The box answers "is this a live code?" with no rate limit in front, so a
guesser can test codes there faster than at the workspace. At a sustained 2,000 requests a
second for a code's whole five minutes, the odds of hitting a given live code are about **1 in
100,000**, against 1 in 18 million at the workspace. That was judged worth it for typing a short
link on a headset (the maintainer's call, 1.16.33). Short-link codes appear in the box's
access log as paths, unredacted; a code there is spent or expired within five minutes. Keep the
log readable only by root and the Caddy user. Caddy 2.6 (Debian's) has no setting for a log
file's mode, so `tosijs-caddy-install --go` takes group and other access off every
`/var/log/caddy/preview*.log*` that exists when it runs. That is what is guaranteed: the files
present at install time. A log Caddy creates afterwards (the first one on a new box, or the next
file when it rolls) gets whatever mode Caddy gives it, so check with `ls -l /var/log/caddy`, or
re-run `--go` once Caddy has written the log.

The **session** token is untouched at 128 bits. It lives in an HttpOnly cookie, is never
typed, and is the credential that actually authorises writes. That asymmetry is the design;
only the half a human has to transcribe got shorter.

#### Link security — pick your level

A link is redeemable for **5 minutes**, as many times as you like, and hands over a session
cookie. The cookie is the durable credential; the link only delivers it. For those five
minutes the link is a bearer token — anyone who sees the URL can mint a session — which is a
deliberate trade: a credential too painful to use is one people route around, and the
measured alternative was developers abandoning the tunnel to type LAN IP addresses instead.

```typescript
tunnel: {
  linkPolicy: 'single-use',  // spend it on first redemption
  linkTtlMinutes: 2,         // …and narrow the window
}
```

**This default was deliberately loosened.** Links used to be single-use, and that collided
with the feature's own purpose: glance at a link and close the tab and you need a new one;
open it on your laptop, then reach for your phone, and it is dead — in a workspace built for
reading your uncommitted tree _on a phone_. One project replaced it with a never-expiring
link of their own. That is the argument: security people route around is not security, it is
friction plus a weaker system built beside it.

So the bound moved from _uses_ to _time_. A token scraped from a log or a chat preview is
still worthless — after the window closes rather than after the first redemption — and the
window is short and configurable.

**Ratchet up** with `linkPolicy: 'single-use'` when a link travels somewhere you do not
control (pasted into a shared channel, say), and shorten `linkTtlMinutes`. **Ratchet down**
with a longer TTL for a link you want to keep working across a session. Widening reuse never
widens lifetime: an expired link is refused under either policy.

#### Sending a link to a PERSON: `--link --share`

Everything above is tuned for **device** handoff — you read a code off one screen and type it
into the headset in your hands, seconds later, and you want to edit. **Human** handoff is a
different shape and has its own flag:

```bash
tosijs-tunnel --link --share                 # read-only, 24 hours
tosijs-tunnel --link --share --share-ttl=120 # …or two
```

A share link is **read-only**: whoever opens it can browse the workspace and cannot save
source. That capability is exactly what pays for the long life, which is why it is one flag
rather than `--ttl` plus `--read-only` — two orthogonal knobs would let you build the thing
nobody should have, a week-long write-capable token sitting in a chat log, and nothing would
stop you. For the same reason `linkTtlMinutes` is the wrong place to solve this: raising it
gives you a long-lived link that can still write.

The session it mints is narrowed to match, and expires after **7 days** rather than the usual
30 — it belongs to someone else, so its horizon should be the demo rather than your dev
server's uptime. A `--share-ttl` beyond that is capped, because past it the link would outlive
the credential it can hand over.

It also ignores `linkPolicy: 'single-use'`. A link you text gets opened when the recipient
gets to it, sometimes twice, sometimes after being forwarded to the person who actually needed
it — and spending it on first redemption is the friction that led one adopter to replace the
whole mechanism with a permanent token of their own.

> **This still needs your machine on.** A share link fronts the *running dev server*. If you
> text one at 17:00 and your laptop sleeps, it is dead by the time they click it — which reads
> as *your feature is broken*, not *your link expired*. For a demo that has to survive your
> laptop, use the static preview.

`requireToken` defaults to **`true`**: a workspace mirrors an uncommitted tree, and the
hostname is not a secret — Let's Encrypt publishes every certificate it issues to public
Certificate Transparency logs, so the URL is discoverable by construction. If you want to
show someone the site, point them at the static preview; that is what it is for.

**What the hostname discloses, and what to do about it.** The edit host's _existence and
name_ are public by construction, even though its content is session-gated. Choose
accordingly:

- **Project name public, content private** — the normal case. Nothing to do; the session
  gate carries the weight. This is the posture the defaults assume.
- **Project _name_ sensitive** — use a random string as the subdomain. `tunnel.url` is
  arbitrary and self-registration doesn't care: `https://khx7q2mwp4.edit.dev.example.com`.
  CT logs then show only that something unnamed exists.
- **Project _existence_ sensitive** — don't use this feature. A public hostname whose
  existence must be secret is a contradiction, and no naming scheme fixes it.

A random-string hostname is a **capability**, and hostnames leak through different
channels than links do — DNS queries, browser history, `Referer`. It buys obscurity of
the name; it is never a substitute for the session gate.

> Not recommended: a wildcard certificate (`*.edit.dev.example.com` via DNS-01) would
> keep individual names out of CT entirely, but it puts a DNS API credential on the
> preview box — escalating a box compromise from "the previews it serves" to
> "cert-minting for the whole zone". That is the wrong trade for a convenience feature,
> and needing no credentials at all is exactly what makes on-demand HTTP certs and
> self-registration clean.

Writing source additionally requires [`editableSources`](#build-toggles--dev-server) to be
enabled. Note the two gates compose but are not the same: `editableSources` says _this
server may write to disk at all_; the session says _you may ask it to_.

The box should also run sshd with `GatewayPorts no`, so the forwarded port binds the
box's loopback rather than the internet. That is defence in depth, not the wall — verify
it yourself with `sshd -T | grep gatewayports`.

#### `book` and `hidden` — multiple volumes from one corpus

Two pieces of doc metadata decide **which book a page binds into, and whether it is
published at all**. Both are inherited down the `parent` chain, so you mark a section
rather than every leaf.

```text
<!--{ "book": "field-guide" }-->              → bind into a volume called "field-guide"
<!--{ "book": ["default", "field-guide"] }--> → bind into BOTH
<!--{ "book": "none" }-->                     → on the site, in NO book
<!--{ "hidden": true }-->                     → not published at all
```

| `book`        | result                                          |
| ------------- | ----------------------------------------------- |
| _(unset)_     | the default volume — `<name>.epub`              |
| `"some-name"` | its own volume — `<name>-some-name.epub`        |
| `["a", "b"]`  | bound into both volumes                         |
| `"default"`   | the main volume, named so a list can include it |
| `"none"`      | on the site, in no volume                       |

A list is what gets you shared front matter — a glossary, a licence page, a copyright
notice — bound into several volumes from one source file rather than copied per book.
`"none"` anywhere in a list wins: `["default", "none"]` is a contradiction, and the
reading that withholds is the safe one.

**The nearest declaration wins, outright.** A section can set `book: "field-guide"` and a
chapter inside it can still divert (`book: "other"`), join several volumes, or opt out
(`"none"`). A list _replaces_ an inherited value rather than adding to it, so a child is
never surprised by a volume it did not name.

**`hidden: true` means not published anywhere**: absent from `docs.json`, from the
generated pages, from every book, and from `llms.txt`. It is inherited, and a child
_cannot_ un-hide itself — accidentally publishing one chapter of a withheld section is
the failure worth preventing. `draft: true` in YAML frontmatter sets it.

> Before 1.9.0 `hidden` only removed a doc from the nav and the book, while its full text
> was still written into `docs.json` **and** it still got a pre-rendered page at its own
> URL. If you have been using `draft:` for working notes, they were public. They are not
> any more.

Volumes are discovered from the corpus — no extra configuration. Each is built in its own
child process, and `epub` settings (title, author, css, the `book` manifest) apply to all
of them. Note the two senses of the word: `config.book` is the **manifest** that curates
and orders docs _within_ a volume; a doc's `book` metadata selects **which** volume.


#### One source, several outputs: placement, insets and conditional text

A site and each of its books are different arrangements of the same sources. Three pieces of
markup keep them from becoming copies that drift apart. All three are resolved by the build,
so the pages, the ePubs, `llms.txt` and the markdown copies of pages all receive finished
text, and a mistake in any of them **fails the build** instead of leaving a page quietly
short.

**Placement: a page sits somewhere different in each volume.** A doc has one `parent` and one
`order`, and a book inherits them. `placement` overrides them per volume, keyed by `book` name
(`"default"` for the main volume):

```text
<!--{ "placement": { "language": { "parent": "for-programmers", "order": 20 } } }-->
```

Each entry may set `parent`, `order`, `pin` and `title`; `"parent": ""` puts the page at the
top level of that volume. Naming a volume here also binds the page into it, in addition to
whatever `book` says, so the name is written once. A page's own `"book": "none"` still
withholds it. The site nav is unaffected. A placement `parent` that is not a document in that
volume is a build error.

**Insets: write a passage once, show it in several pages.**

```text
<!--{ "inset": "install.md" }-->            → the whole document
<!--{ "inset": "install.md#with-bun" }-->   → one heading's section of it
<!--{ "inset": "_how-examples-work.md" }--> → a fragment file that is not a page
```

The part after `#` is the heading's id, the same one an in-page link uses (see "Doc format").

The reference is tried as a document first (filename, slug or title, the same matching
`parent` uses) and then as a **fragment file**, relative to the page that includes it. A
fragment is how a passage exists without being a page of its own: a markdown file whose name
starts with `_` is skipped by extraction and can still be inset.

A fragment is the only kind of file an inset reads. It must be markdown, its name must start
with `_`, and it must be in a directory the docs are extracted from: a directory under
`docPaths` that is not ignored. A single file listed in `docPaths`, such as `README.md`, does
not make its folder one. A symlink is judged by what it points at, name and place both. So an
inset cannot reach a `.env`, a source file, or a page you hid: a hidden or draft document is
not in the corpus to be found by name, and it is not a fragment to be found by path. Anyone
who can edit a page can read the `_` fragments in those directories by insetting them, which
is what they are for.

What arrives is the passage without its place in the other page's outline: a whole document
loses its title heading and metadata block, and a section loses its own heading and runs to
the next heading of the same or a higher level. Write the heading you want in the page that
includes it. Live examples and inline tests in the passage run in every page they are inset
into. Insets nest; a cycle, a document or file that does not exist, a heading that does not
exist, and a file that is not a fragment are all build errors.

**Conditional text: wording for the site or for books only.**

```text
<!--{ "only": "book" }-->

The examples in this chapter run in the online edition.

<!--{ "end": "only" }-->
```

`"only"` takes `"site"` or `"book"`. The site, `llms.txt` and the markdown copies get the
site wording; every ePub and the Print output get the book wording. Blocks do not nest, and
an unclosed or unmatched block is a build error.

`only` chooses wording, not access. Both wordings are in the published `docs.json`
(`bookText` beside `text`), so do not put anything in a book-only block that a site visitor
must not be able to find.

The inset and `only` directives go on their own line at the top level of the document, like
the metadata block they are spelled after. One inside a list item, a blockquote or the middle
of a paragraph is a build error. One shown inside a code fence is an illustration and is left
alone.

**Print is not a volume.** The in-app Print output is the whole visible site in site order,
with the book wording. `placement` and `book` arrange ePub volumes only.

**What does not work on an assembled page.** Saving an edited live example back to its source
is refused on a page whose source uses an inset or conditional text, because the examples on
the page are no longer the examples in that file, in that order. Edit the source file; an
inset example lives in the file it is inset from.

#### `/version.json` — what am I looking at?

Every build writes a small build-identity file to the web root:

```json
{
  "generator": "1.8.0",
  "site": "tosijs-ui",
  "commit": "66fbc589",
  "commitTime": "2026-07-30T09:10:52+03:00",
  "contentHash": "5a416e4c7fd54d1e"
}
```

`generator` is the `tosijs-ui` version that produced the site; `commit` /`commitTime`
identify **your project's** source. Nothing exposed this before — `src/version.ts` is
the library version and says nothing about which commit built a given deploy.

**`commit` is the last commit whose build _changed the site_, not necessarily the commit
you just built at.** When a rebuild produces output identical to what is already there
(same `contentHash`, which hashes everything except this file), the stamp is left alone.
Without that, every build→commit cycle would restamp the file with its own parent commit
and leave the tree dirty forever (#122). So a content-identical rebuild at `HEAD` keeps
reporting an older commit, and that is correct: the site you are serving is byte-for-byte
the one that commit built.

To check that committed output matches `HEAD`, don't compare `commit` to `HEAD`. Rebuild
and check that `git status` is clean. Delete `version.json` before building to force a
fresh stamp.

It matters most where a deploy is a _snapshot_: a preview host serves whatever was
last pushed to it, so a reviewer can report a bug you fixed this morning with no way to
tell from the page which of you is stale. Same after a partial deploy of a live site.

**Deliberately deterministic — there is no build timestamp.** `docs/` is committed in
these projects, so anything that varied per build would diff on every commit and train
everyone to ignore it. Identity comes from the commit, so rebuilding the same source
twice is byte-identical. There is likewise no `dirty` flag: a build from a dirty tree
reports its last commit, which may not describe what was built, so that warning belongs
at deploy time where a human can act on it — not baked into a committed file that would
then be permanently wrong.

Git fields are **omitted** (never blank) when git isn't available — an adopter need not
be in a repo, and a build must not fail for want of git metadata.

#### `openBrowser` — one dev tab per project

Set `openBrowser: true` and `bun start`, once the server is listening, opens the dev
page in your browser — and on the **next** launch or restart it brings that **same
tab** forward instead of stacking up a new one. This is create-react-app's "open the
tab" trick, adapted:

- **The tab's identity is the dev origin** — `https://localhost:<port>`. Because each
  project runs its own dev port, that yields exactly **one tab per project**: restart
  a project and its tab returns; start a sibling on another port and it gets its own;
  two runs of the same project never spawn a second tab. (The origin is the key on
  purpose — a name/hash marker in the URL would be dropped by the doc-browser SPA on
  the first in-page navigation and couldn't identify the tab later. The origin
  survives every navigation.)
- **macOS** does the reuse via AppleScript, auto-detecting a **running** Chrome / Brave
  / Edge / Chromium / Safari (so it never launches a browser you don't use). Driving
  another app by AppleScript triggers a **one-time** "…wants to control <Browser>"
  automation prompt — approve it once. Other platforms open via `open` / `xdg-open` /
  `start` (a normal new tab, no reuse).
- **Choosing the browser:** pass a string (`openBrowser: 'Google Chrome'`, `'safari'`,
  `'brave'`, …), or set `BROWSER=<name>` at the shell (which wins). `BROWSER=none`
  disables it for one run.
- **When it's skipped:** off by default; in **CI**; when stdout isn't a **TTY**; and
  on **watch rebuilds** (it only fires at launch). Best-effort throughout — a failure
  degrades to a plain open or a no-op and never blocks or crashes the server.

#### `llms.txt` and `llms-full.txt`

The default index follows [llmstxt.org](https://llmstxt.org): an H1 with your `name`, your
`description` as a `>` summary, your version and links (`baseUrl` → `Docs:`,
`projectLinks.github`/`.npm` → `Source:`/`npm:`; npm falls back to your package name), then
the pages as `- [title](url): description` lines, **in nav order**. A nav section with pages
under it is a `##` section of its own, its own page first; the top-level pages are `## Docs`.
Each link is the page's markdown copy (`/{slug}/index.md`) unless `markdownPages: false`, and
each description is the one the page's `<head>` has (see
[Titles and descriptions](#titles-and-descriptions-the-metadata-lint)). Hidden and `noindex`
pages are left out.

`llms.txt` is written **both** to the project root (so you can ship it in your package's
`files`) **and** to the served output dir, so `{baseUrl}/llms.txt` resolves for
crawlers/agents. `llms-full.txt` is the whole indexed corpus as one markdown file, in the same
order, each page followed by its URL; it is written to the output dir only. Set
`llmsTxt: false` to skip both, or pass a function `(docs) => string` to generate your own index
from the corpus (`llms-full.txt` is still written).

#### Share images

The image a link to a page unfurls with (`og:image`, `twitter:image`) is, in order: the page's
own `image` metadata; its card at `/og/<slug>.webp` (or `.png`/`.jpg`; the home page's is
`/og/index.webp`) when your static files have one; the site's `ogImage`; and the home page's
card. A local image also gets `og:image:width`/`height`, read from its header, so an unfurler
can lay the card out before fetching it; every image gets an alt of the page's title.

Cards are files like any other static asset, so make them however you like. In this repository
`bun run og` screenshots each page's first live example into a 1200×630 card in
`demo/static/og/`. It makes cards only where one shows something the title doesn't: the home
page, and each page with a live example and no `image` of its own. Every other page shares the
home page's card.

#### Titles and descriptions: the metadata lint

A search result, a link unfurl and an `llms.txt` reader see a page's `<title>` and description
instead of the page. Each page's description is its metadata `description`, else its first
paragraph (inline markdown removed, cut at 155 characters on a sentence boundary where it can
be), else the site `description`.

Every build checks each indexed page and lists what to fix, grouped by problem: no title, or
one under 20 or over 60 characters with the " — project" suffix; no description of its own,
one under 70 or over 155 characters, or one derived from a first paragraph that had to be cut
mid-sentence; and a title or description another page also has. Fix them in the page's
metadata block: `headTitle` sets only the `<title>`, so a terse nav label can stay terse. A
page that should not be found takes `noindex: true`. It warns, once per change under
`bun start`, and fails the build under [`strict`](#strict-builds-strict).

#### Strict builds (`strict`)

Some things the build can see are wrong are also things a project might do on purpose, so
by default it warns and carries on. A warning in scrollback under a green build is easy to
miss — one such reached a release candidate — so a project that never does these on purpose
can say so:

```typescript:static
defineSiteConfig({ strict: true /* … */ })
```

With `strict: true` each of these fails `buildSite` (it throws `SiteMisconfiguredError`,
exported from `tosijs-ui/site`, and the previous build output is restored):

- **`bundleEntry` does not register `<tosi-doc-system>`.** Every page would stay as inert
  pre-rendered markup. Deliberate only for a headless embedding.
- **The corpus has executable fences but `bundleEntry` does not register `<tosi-example>`.**
  The code blocks would render as plain text.
- **The corpus has live `tjs` / `ts` examples but `tjs-lang/browser` cannot be resolved.**
  No same-origin copy is written to `/tjs/`, so those examples load tjs-lang from a CDN at
  the version tosijs-ui pins, not the one your project has installed. Without `strict` this
  now prints a warning; before 1.16.7 it was silent.
- **The packages the editor is built from resolve two copies of `@codemirror/state` or
  `@codemirror/view`**, on a site with a `bundleEntry`. CodeMirror recognises extensions by
  identity, so every `<tosi-code>` in that bundle renders blank ("Unrecognized extension
  value in extension set" in the console) while the build and the tests pass. It usually
  arrives through an upgrade: the lockfile kept an older hoisted copy and the package manager
  nested the newer one under `node_modules/tosijs-ui/`. The warning lists both locations;
  `bun update @codemirror/state @codemirror/view` (npm: `npm dedupe`) brings them back to
  one. Without a `bundleEntry` the site ships tosijs-ui's prebuilt bundle, which the install
  cannot break, so `buildSite` says it once and never fails on it; `restampSite` does fail
  on it under `strict`, since a project that calls it built a bundle of its own. A copy of your own at the
  project root, for an editor of your own, is not reported.
- **A bundle you wrote over `/iife.js` does not register the doc system**, when you call
  [`restampSite`](#bundles--live-examples-read-this) after replacing it. That one throws from
  `restampSite`, before it writes anything, so there is no build output to restore.

- **A local image cannot be bound into the ePub** (missing, outside the site, or not an ePub
  image type; see [the book](#the-book-epub--the-book-manifest)). The warning lists them.

- **A page's title or description needs writing** (see
  [the metadata lint](#titles-and-descriptions-the-metadata-lint)).

- **An in-page link points at nothing.** `[text](#some-heading)` where the page has no such
  id is a link that looks live and does nothing. The warning names the page and the anchor.
  Not checked: `#/route` and `#!…` (hash routers), `#top`, and `#example-2`, the number a
  live example gets in the browser. An id that only exists once a script has run (a live
  example rendering its HTML, say) is reported, because the build cannot see it.

One related failure shows up in the browser, not the build. `bundleEntry` replaces the bundle
that supplied `tosijs` and `tosijs-ui` to live examples. Examples still get `tosijs` (the doc
system hands them its own copy). They get `tosijs-ui` only if your entry supplies it:

```typescript:static
import * as tosijsUi from 'tosijs-ui'
;(globalThis as any).xinjsui = tosijsUi
```

An example that imports `tosijs-ui` on a site that has not done this fails with a message
saying exactly that, and the doc-test lane reports it as a failed example.

#### Markdown copies of pages (`markdownPages`)

Every page is written twice: `/{slug}/index.html`, and its markdown source as
`/{slug}/index.md` beside it. The HTML carries
`<link rel="alternate" type="text/markdown" href="index.md">`, and `llms.txt` links the
markdown copies.

This is for readers that want the text and not the page: an LLM's URL fetcher, a script,
`curl`. The static HTML already has the whole article in it with no JavaScript needed, but
not every fetcher copes — one reported "no `<body>`" for a page that plainly had one — and
markdown is something none of them can misread.

- The file is the doc's source as extracted: a `.md` file verbatim, or the joined `/*#`
  comment blocks of a source file. Wikilinks, footnotes and metadata comments are left as
  written.
- A `noindex` page gets neither the file nor the link, the same rule the sitemap applies.
  Hidden docs never reach the build at all.
- The build writes an empty `.nojekyll` whenever this is on, whatever `host` is. GitHub
  Pages otherwise runs Jekyll, which renders `guide/index.md` to `guide/index.html`, the
  path of the real page.
- `markdownPages: false` turns all of it off.

#### The book (ePub) & the `book` manifest

Set `epub: true` (or `{ author, title, css, cover, coverIcon, coverColor, printCover }`) and every build
emits `{name}.epub` into the output dir, one chapter per doc in nav order, with a
Contents page, EPUB3 nav + EPUB2 ncx, and a cover (an explicit `cover` image, or
one generated from the title + `coverIcon`, an SVG, or failing that your `favicon`; install `@resvg/resvg-js` to
render the generated one). The doc-browser's settings menu links to it as
"Download ePub", at the URL the build wrote it to; a site with several volumes gets a
submenu with one entry each, and a site that builds no ePub gets no entry. `bun bin/build-book.ts` builds it standalone. PDF is the
in-browser **Print** button, not a batch job. Print opens with the default volume's
cover, then the title page and contents. It reads the cover from `/epub-volumes.json`
and checks the image loads first, so it prints from the title page as before when there
is nothing to show: a site without `epub`, a build that wrote no cover, or a corpus
where every doc names a volume (there is no default volume). Set
`epub: { printCover: false }` to leave the cover out of Print and keep it in the ePub.

**Images are bound into the book.** A local image (`![alt](…)`, or `<img src>` and `srcset` in
raw HTML) is copied into the ePub,
listed in its manifest once however many chapters use it, and pointed at from each chapter.
The file is the one the page shows: a path starting with `/` (`/images/fig.jpg`, with your
`basePath` or without it) is looked up in the built site, which already holds your
[`staticDirs`](#static-assets); a relative path is resolved against the page's own URL, `/slug/`,
as the browser resolves it on the site. Encode spaces in file names (`/images/fig%201.jpg`),
as a URL needs anyway. Remote and `data:` images stay as they are, and a reader needs a network
connection for a remote one. A local image that is missing, lies outside the site, or is not
JPEG, PNG, GIF, SVG or WebP (the ePub core image types) is left as it was and listed in a
warning; under [`strict`](#strict-builds-strict) it fails the build, an image only your host
serves (a CDN rewrite, a proxied path) included, since the book cannot reach it.
`<picture><source>` and SVG `<image>` are not bound. Before 1.16.38 no image was bound, so
every local figure was a broken image in every reader.

By default **the book is the whole visible corpus** — zero config. To emit a
_subset_ in a _curated order_ (a library that also ships a book, a novel with
front/back matter) add a `book` manifest. It shapes only the book artifact; the
live-site nav is unchanged (one source, two outputs). Every field is an overlay
on the defaults — it never adds a new ordering mechanism, it overlays each doc's
`order` so the same nav sort sequences the book (pins/parents still apply):

```typescript
book: {
  include: ['chapters/**', 'front/**', 'back/**'], // globs (path or filename); default: all
  exclude: ['**/drafts/**'],                        // removed after include
  order: ['title', 'copyright', 'dedication'],      // lead sequence; by filename/slug/title
  sort: 'filename',                                 // 'nav' (default) | 'filename' natural sort
}
```

- **Front/back matter** are just regular docs — name them in `order` (or give
  them a per-doc `order` in frontmatter) to place them; there's no special
  front-matter concept.
- `sort: 'filename'` makes a folder of `01-*.md`, `02-*.md`, … sequence with no
  metadata; a per-doc `order` still wins.
- Identity (title / author / cover) comes from `epub`, not here.

## Host presets & custom domains

| `host`                      | `.nojekyll` | `CNAME`  | `basePath`               | other                             |
| --------------------------- | :---------: | :------: | ------------------------ | --------------------------------- |
| `github-pages` + `domain`   |     ✅      | `domain` | `/`                      | —                                 |
| `github-pages`, no `domain` |     ✅      |    —     | set `'/<repo>'` yourself | —                                 |
| `firebase`                  |      —      |    —     | `/`                      | optional `firebase.json` rewrites |
| `static` (default)          |      —      |    —     | `/`                      | nothing host-specific             |

`domain` is derived from `baseUrl`'s hostname when omitted (and
`host: 'github-pages'`), so the common case needs no extra config; set it
explicitly to override (apex vs `www`, or a domain that differs from the
canonical origin). A custom domain always serves from root, so it forces
`basePath: '/'`.

### Mount-agnostic builds (`basePath` only affects metadata)

The build emits every **functional** URL — nav / content links, `scriptUrl`,
`stylesUrl`, favicon, `docsUrl` — **relative to each page**, so a single build
works at a `/repo` project page, a custom-domain root, or a moved mount with **no
rebuild**. `basePath` now affects only **metadata** URLs (`canonical`, `og:url`,
`og:image`, `sitemap.xml`), which need the real absolute served path for SEO.

The practical payoff: adding a custom domain to a project page — GitHub flips the
site to the domain root the instant you set it — no longer serves a broken,
unstyled shell in the window before you rebuild with `basePath: '/'`. The assets
resolve at whatever mount the page is served from. (Keep `basePath` correct anyway
so crawlers see canonical URLs at the real path; a stale `basePath` now only
mis-states metadata, it doesn't 404 the page.)

Two runtime pieces are still mount-locked (tracked in issue #16): the hydrated
SPA's own nav/`pushState` hrefs (they use a root-absolute `/slug/`, correct at a
root mount, drifting under `/repo`) and the same-origin tjs-lang loader base
(`__TJS_LOCAL_BASE`). Body-content **wikilinks** (`[[slug]]` → `/slug/`) are
likewise absolute, since they come from the renderer shared with the client. None
of these affect first paint or the no-JS asset load — the custom-domain-cutover
case is fully covered.

## Doc format

- **`.md` files** are included whole.
- **`/*#` … `*/` block comments** in `.ts`/`.js`/`.css` are extracted as
  markdown. The first heading is the page title.
- **Metadata** via a JSON block — `<!--{ "pin": "top" }-->` (html) or
  `/*{ "pin": "bottom" }*/` (ts/js/css) — controls nav ordering, plus per-page
  SEO overrides (`description`, `keywords`, `image`, `noindex`, `headTitle`), the
  section `parent`, and the page's shape (`layout`, `header`, `home`), all in the same
  block. YAML frontmatter can set `title`, `order`, `author`, `date`, `draft`, `layout`,
  `header` and `home`.
- **Nav order** is: pin bucket (`top` → none → `bottom`), then `order`, then
  title, then filename. Use **`order`** (a number, **lower first**; default 500)
  to rank items _within_ the same `pin` — e.g. two `"pin": "top"` docs with
  `"order": 1` and `"order": 2`. Siblings inside a section sort the same way.
- **Every heading has an `id`**, so `[see the errata](#the-1987-errata)` links to a
  heading on the same page and `/page/#the-1987-errata` links to it from anywhere. The id is
  the heading's text as GitHub slugs it: lowercased, everything that is not a letter, a
  number, a space, `-` or `_` dropped, and each space turned into `-`. So "What's new?" is
  `whats-new` and "Foo & Bar" is `foo--bar`. Letters of any script are kept. A heading
  repeated on one page gets `-1`, `-2`, and so on, in order. An entity such as `&copy;` adds
  nothing to the id. One overlap to know about: live examples are `example-1`, `example-2`, …
  in the browser. A heading titled "Example 2" (or a second heading titled "Example", which
  is `example-1`) can therefore share an id with a live example on the same page, and a link
  to it reaches whichever comes first. The build does not check for this; title such
  headings differently if you link to them. The build warns about an
  in-page link that matches no id on its page (it fails under `strict`).
- **Wikilinks**: `[[target]]` links to another page, `[[target|label]]` with your own text.
  The target is a page's slug (its file name, `[[registry]]` → `/registry/`) or, when no page
  has that slug, its title (`[[The feature registry]]` → `/registry/`). A slug always wins, and
  a title two pages share reaches neither. A wikilink names a **doc page**: link a static file
  or a hand-written page under your static folder with an ordinary `[label](/path/)`. The build
  warns about a wikilink that reaches no doc page, naming the file and the target (it fails
  under `strict`).

### Which fence languages EXECUTE

**Six languages run. Everything else is display-only.** This catches people out because two
of the six do not look like code you are asking to be *run* (#146):

| fence | what happens |
|---|---|
| `js` `ts` `tjs` | executed as the example's script (`ts`/`tjs` are transpiled) |
| `html` | **rendered as the example's markup** |
| `css` | **injected as a `<style>` on the page** |
| `test` | executed as assertions, in its own scope |
| a registered dialect | executed as the example's script, by that dialect |
| anything else | display-only — highlighted, never run |

A site can add languages to the first row with `registerDialect` from `tosijs-ui/live-example`
(called in your `bundleEntry`, before the doc system starts), and any fence can pass JSON
options to its dialect: `` ```tjs {"runTests": "report"} ``. See the live-example page,
"Adding a dialect". **List them in the site config too**, `dialects: ['ajs']` (and any
built-in you replace, `dialects: ['ajs', 'tjs']`): the build can't see your page's
registrations, so this keeps the build-time highlighter off those fences, stops the build
checking and pre-transpiling a built-in you've replaced, and lets the page report a declared
dialect that nothing registered. Declared names stay registered for the life of the build process,
so after removing one from a running dev server's config, restart the server.

The two in bold turn ordinary API documentation into live bugs, and neither fails the build:

- A ` ```css ` fence showing *"here is how you'd style this in your app"* is applied to the
  whole page. A rule like `tosi-widget { background: white }` then fights the doc system's
  own theme and is a white slab in dark mode.
- A lone ` ```html ` fence showing *"the markup this compiles to"* renders as an example —
  typically unstyled, because the rules live in a component's shadow DOM and there is no
  component around it. It reads as a broken demo rather than an illustration.

#### Marking a fence display-only: `:static`

Add `:static` to the fence and it is highlighted, never run — **whatever its language**:

    ```html:static
    <tosi-widget></tosi-widget>
    ```

This is the thing to reach for. Before it existed the only escape was to lie about the
language — ` ```typescript ` for TS, ` ```xml ` for markup — which worked, and which this
document recommended, but costs you correct highlighting for the language you are actually
writing. tjs-lang made the argument that settled it (#155): a TJS colon example,
`function greet(name: 'Alice')`, is a **value**, and rendered with TypeScript's token model
`'Alice'` takes the *type* colour — so the page visually asserts the exact confusion the
document exists to correct. On the web that is cosmetic. In a printed book it is permanent.

So tag the fence with its real language and add `:static`. The old alternatives still work and
nothing has been removed; they are simply no longer the price of not running something.

#### Making display-only the DEFAULT: `liveExamples: 'opt-in'`

For a corpus that is mostly illustration — a book, a prose site, an API reference — invert it:

```typescript
export default defineSiteConfig({
  liveExamples: 'opt-in', // nothing runs unless the fence asks
})
```

Under `'opt-in'` a fence has to ask, with `:inline`, `:iframe` or `:ide`. The default is
`'auto'`, where the six languages above run and `:static` opts an individual fence out.

**If you build an ePub, set this deliberately.** The book's "▶ Run this example live" links
point at anchors on the web page, so the two only agree if they were built under the same
policy.

#### No-code examples: `liveExamples: 'no-code'`

A doc system shown inside a host app usually runs under that app's Content Security Policy,
and a policy without `'unsafe-eval'` cannot run a script example at all: compiling one is
exactly what it forbids. `'no-code'` is the mode for that host:

```typescript
export default defineSiteConfig({
  liveExamples: 'no-code', // html and css run; script and test fences are shown as code
})
```

- Only `html` and `css` fences run. `js`, `ts`, `tjs`, a registered dialect and `test` are
  highlighted as code, whatever their `:mode`, so the doc-test runner has nothing to run either.
- Nothing on a no-code example's path constructs a `Function` or calls `eval`: not the
  preview, not an edit, not a chat reply. There is no Console tab, since its REPL evaluates.
- The code panel shows the `html` and `css` tabs, and a Chat tab when the page registers one.
  `exampleTabs` chooses others site-wide; `{"tabs": ["html"]}` on a fence, for one example.

An example becomes live without code through three hooks your `bundleEntry` registers. They
are functions, so they cannot live in the site config:

```typescript:static
import { setExampleHooks } from 'tosijs-ui/live-example'

setExampleHooks({
  // the markup, parsed but inert, before it enters the preview: clean it in place
  sanitize(fragment, example) {
    for (const el of fragment.querySelectorAll('script, [onclick]')) el.remove()
  },
  // the preview, once the markup is in it; `signal` aborts on the next run
  hydrate(preview, signal, example) {
    bindDeclaratively(preview, appState, { signal })
  },
  // a panel for the Chat tab: it reads the source and applies replacements
  chat({ source, apply, signal }) {
    return myAssistantPanel({
      context: () => source(), // { html, css }
      onReply: (html) => apply({ html }), // runs like an edit: sanitize, insert, hydrate
      signal,
    })
  },
})
```

The hooks apply in every mode, not only `'no-code'`: a script example's markup is sanitized
and its preview hydrated after its script runs. A host that serves its own app's data to
examples binds it in `hydrate`; nothing the example's author writes needs to run.

#### Script examples under a strict CSP: `exampleModules: true`

A site that keeps its `js`/`ts`/`tjs` examples can still run them under `script-src 'self'`:

```typescript
export default defineSiteConfig({
  exampleModules: true,
})
```

The build already compiles every example to check it. With this it also writes each one to
`<outputDir>/_examples/<hash>.js`, and the page runs an example by `import()`-ing its module, which
`'self'` allows, instead of compiling its source. The module's lines are the example's own, so an
error still names its line.

What still compiles in the browser, and so still needs `'unsafe-eval'`:

- an example once a reader **edits** it. Under the policy it says so, and that reverting runs
  the published one;
- the Console tab's REPL (the example's logs still show there);
- `test` blocks, which are off on a deployed site;
- `:iframe` and `:ide` examples, whose code runs in the frame's own realm.

It needs `checkExamples` (the default), which is what produces the modules; turning that off
with this on is reported.

If you want the styles to be real *and* scoped, write them against the doc system's theme
variables (`--tosi-bg`, `--tosi-text`, …) rather than literal colours, and scope the selector
to your example's own markup.

### How fences GROUP into examples

**Adjacent fences form one `<tosi-example>`; any prose between them starts a new one.**

So this is one example with three tabs:

    ```html
    <tosi-widget></tosi-widget>
    ```
    ```js
    preview.querySelector('tosi-widget').value = 3
    ```
    ```test
    test('it renders', () => { … })
    ```

…and moving that `test` block under its own `## Testing` heading silently detaches it from
the demo it was written against. The tests still run; they just no longer run against that
example's DOM. A detached ` ```test ` block renders its **results** rather than an empty
editor, so a test-only example looks like what it is instead of looking broken — but it is
still not testing the demo you wrote it for.

One executable script block per example: `js`, `ts` and `tjs` are the same slot, so
consecutive ones discard all but the last (#139).

## Syntax highlighting

Static code blocks are highlighted **at build time**, so the tokens are in the pre-rendered
HTML, the ePub and the printed page — places a runtime highlighter could never reach. There is
nothing to configure; it happens.

Token colours come from the doc-system stylesheet, which ships two contrast-checked palettes (a
light default and a dark one under `.darkmode`).

### A language that ships its own grammar

27 languages are built in. If yours is not one of them — a DSL, or a language too young for
Prism — supply a grammar rather than waiting for us:

```typescript
import { registerGrammar } from 'tosijs-ui/site'
import { tjsGrammar } from 'tjs-lang/prism'

registerGrammar('tjs', tjsGrammar)
```

Call it before `buildSite`. A registered grammar wins over a built-in one of the same name, so
this is also how you override a grammar you disagree with.

> **Register in both places if you also want it client-side.** The build-time registry lives in
> the build process and does not cross into the browser, so a page that is navigated to
> client-side (rather than hard-loaded) highlights with whatever the browser knows. For an ESM
> consumer, import `tosijs-ui/doc-system/highlight` in your `bundleEntry` and register there
> too. A CDN `<script>` consumer has no seam for this yet.

### tosijs-ui and the `Prism` global

From 1.15.0 this package uses [Prism](https://prismjs.com/) for highlighting, and Prism is a
free-global library: its grammar files register against `window.Prism` at load time, so load
order decides who owns it.

**If your page already has a Prism, we adopt it** rather than importing our own over the top —
your languages and plugins keep working and our grammars are added to your instance. We also
never set `Prism.manual` on an instance we did not create, because that flag stops *your*
`highlightAll()` from running.

**If it does not, we create it**, and from that point the page's `Prism` is ours. If you load
your own Prism afterwards it will win, and our highlighting stops — that is Prism's model, not
a bug we can fix from here. Load yours first, or use `registerGrammar` to add a language to
ours instead of bringing a second copy.

### Highlighting code at runtime

If code arrives *after* the build — a fetched snippet, an API response, a chat message — use
[`<tosi-highlight>`](/highlight-block/), which is a separate subpath import and not in the root
barrel. If the code is in your markdown you do not need it.

## Notes & gotchas

- **Build-time only.** The orchestrator and generators run under Bun and never
  enter a browser bundle. Only the runtime `<tosi-doc-system>` component ships
  to the page (and is tree-shaken away for consumers that don't use it).
- **Dependency direction for `tosijs` itself.** If the core `tosijs` repo uses
  this to build _its_ docs, that's a **build-time-only** dependency on
  tosijs-ui — the published `tosijs` library still depends on nothing upstream.
  It is not circular, but CI must build/resolve tosijs-ui first.
- **Not every site fits.** This is for reference/doc sites built from markdown.
  A bespoke scroll-driven marketing page (e.g. `tosijs-product`) wants a
  different page model — use tosijs-ui's _components_ there, not this doc
  system (or host its API docs as a separate site).
- **Relative asset URLs break (migration gotcha).** Each doc is served at its
  own path (`/{slug}/`), so a `./asset` reference inside a `/*# … */` block now
  resolves under that slug, not the site root. Use **root-absolute** URLs
  (`/asset`) for images and links in doc content.
- **`prebuild` runs before `outputDir` is wiped.** Use it for source-tree codegen
  (version stamp, icon data, …) and write assets into a `staticDirs` folder — not the
  output dir, which the build resets immediately after.
- **`dist/` is reset only when this build owns it (#130).** `libraryBundle`, `emitLibrary` or
  `libraryTsconfig` means the build produces `dist/`, so it clears it first. A
  `libraryBuild` function owns the directory and may deliberately emit a subset, so it is
  never cleared — and with no library configured, the doc build writes nothing into `dist/`
  and does not touch it. **If you supply `libraryBuild`, cleaning `dist/` is yours.**
- **Anything `prebuild` writes into a watched path is an infinite rebuild loop.** The
  file it writes triggers the watcher, which rebuilds, which writes it again. The dev
  server's storm detector will stop you (and name the file), but the fix is on your side:
  write outside the watched paths, or add the file to the watcher's ignore list. This is
  why `version.ts` and `icon-data.ts` — both written by tosijs-ui's own `prebuild` — are
  ignored by the watcher.

## Where the code lives

Everything below is in `src/doc-system/`, and the build half is what `tosijs-ui/site`
exports.

| concern                                     | module                                                                   |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| config type + `defineSiteConfig`            | `site/site-config.ts`                                                    |
| orchestrator (`buildSite`)                  | `site/orchestrator.ts`                                                   |
| dev server (`devServer`)                    | `site/dev-server.ts`                                                     |
| machine-health preflight                    | `site/preflight.ts`                                                      |
| dependency audit gate                       | `site/audit-guard.ts`                                                    |
| open dev browser tab (reuse per project)    | `site/open-browser.ts`                                                   |
| doc extraction                              | `site/docs.ts`                                                           |
| section docs + TOC blocks                   | `site/sections.ts`                                                       |
| static page generator                       | `site/generate-site.ts`                                                  |
| theme → static CSS (subprocess)             | `site/generate-css.ts`                                                   |
| DOM shim for the CSS subprocess             | `site/build-dom-shim.ts`                                                 |
| ePub (+ its child-process CLI)              | `site/epub.ts`, `site/epub-cli.ts`                                       |
| `llms.txt`                                  | `site/make-llms-txt.ts`                                                  |
| build guards (bundle, output dir, examples) | `site/bundle-guard.ts`, `site/output-guard.ts`, `site/check-examples.ts` |
| runtime component                           | `src/doc-system/` (ships in the bundle)                                  |

Icon data is generated by the `tosijs-make-icons` bin (see [make-icon-data](/make-icon-data/)).
