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 maps each part to its module.

What you get

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 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, #145, #146).

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

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:

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:

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:

{
  "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):

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:

bunx tosijs-dev-certs

This ships with tosijs-ui — it uses 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:

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:

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 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:

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

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:

// 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>' }):

bunx tosijs-make-icons --input ./my-icons --output ./src/my-icons.ts
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:

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

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 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:

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 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. 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.

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
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:

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 and preview.tunnel

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:

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:

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:

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:

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):

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.

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:

// 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: 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:

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:

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, 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:

<!--{ "layout": "full-width" }-->
---
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.

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.

"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:

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.

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 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) 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:

# 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:

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/):

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:

- [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:

[
  { "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:

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.

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
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: '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

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):

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:

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.

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.

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.

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.

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.

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.

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:

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:

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 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.

<!--{ "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):

<!--{ "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.

<!--{ "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.

<!--{ "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:

{
  "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:

llms.txt and llms-full.txt

The default index follows 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). 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)

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:

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):

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:

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 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; 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 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):

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
}

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

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:

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:

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:

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

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

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':

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':

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:

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 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>, 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

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).