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.

Status: shipped. The whole system — build tooling and runtime component — lives in src/doc-system/ and is importable as tosijs-ui/site. See "Where the code lives" at the bottom.

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)

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 initial build still runs your steps explicitly:

const build = async () => {
  if (!(await buildSite(config))) throw new Error('site build failed')
  await buildMyIifeBundle() // re-create what buildSite's rm -rf removed
}
if (!(await buildSite(config))) process.exit(1)
await buildMyIifeBundle()
if (process.argv.includes('--build')) process.exit(0)
await devServer(config, { build }) // ← watcher runs the full pipeline

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. They're declared as optional peers, so install whichever the build reports missing:

bun add -d happy-dom tjs-lang marked

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:

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

Doc sources

field default purpose
docPaths ['src', 'README.md'] dirs scanned for /*# + .md files (list root .md files explicitly)
sectionsDir 'src/docs' where auto-created section docs + their <!-- toc --> blocks are written (must be inside a docPath, not named docs)
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
bundleExternals modules left external, e.g. ['jolt-physics']
scriptUrl /iife.js bundle URL pages load (fallback + output name)

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, …). Runs before dist/output are reset — don't write there
emitLibrary false also build the library: tsc --declaration --incremental --outDir dist (for repos publishing a package + their docs)
libraryTsconfig run tsc -p <path> for the library build instead (handles root noEmit, removeComments, custom outDir); supersedes emitLibrary
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 the llms.txt index — true, false, or (docs) => string for a custom one (see below)
epub false build + ship an ePub of the corpus every build — true or { author, title, css, cover, coverColor } (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
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? }. Only host is required; 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 a single-use 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. 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".)
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 synchronously on the initial build and before the dev server binds its port; ungated high+ advisories fail the build (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.

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.

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

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: {
  host: 'root@203.0.113.10',        // ssh target
  url: 'https://ui.dev.example.com', // optional; also names the route to register
}
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: substitute {{ACME_EMAIL}} (your Let's Encrypt account) and {{PREVIEW_DOMAIN}} (the domain your preview hosts live under), and put the shared invite secret in /etc/caddy/preview.env as PREVIEW_TOKEN=… rather than in the file.

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 the registration step refuses rather than guessing, and names the missing snippet when validation fails for that reason.

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" },
  { "book": "field-guide", "title": "my-project — field-guide",
    "filename": "my-project-field-guide.epub", "url": "/my-project-field-guide.epub" }
]

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

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

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 single-use edit link
tosijs-tunnel --close    # close any tunnel this project opened

How the gate works. --link prints a URL carrying a single-use token. 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.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

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.

/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"
}

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.

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

The default index is built from your config — name, description, baseUrl (→ Docs: link), and projectLinks.github/.npm (→ Source:/npm: links; npm falls back to your package name) — plus one entry per documented src/*.ts with a dist/*.js pointer. It's 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. Set llmsTxt: false to skip, or pass a function (docs) => string to generate your own from the corpus.

The book (ePub) & the book manifest

Set epub: true (or { author, title, css, cover, coverColor }) 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 + your favicon; install @resvg/resvg-js to render the generated one). The doc-browser's settings menu links to it as "Download ePub". bun bin/build-book.ts builds it standalone. PDF is the in-browser Print button, not a batch job.

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

Notes & gotchas

Where the code lives

The extraction is done: everything below is in src/doc-system/, and the build half is what tosijs-ui/site exports. Nothing here is imported from bin/ any more.

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)

What remains in bin/ is not part of the system — it is this project's own wiring, plus one tool that hasn't been generalized:

file what it is
bin/dev.ts tosijs-ui's own build entry — a thin wrapper over buildSite/devServer (declarative config in tosijs-site.config.ts, imperative prebuild codegen here)
bin/build-book.ts standalone ePub CLI (bun book) — a wrapper over the exported buildEpub
bin/docs.ts back-compat shim, re-exports site/docs.ts; kept because package.json#files ships it and import … from 'tosijs-ui/bin/docs' consumers exist
bin/generate-og.ts not extracted. Per-page Open Graph cards (bun run og). Opt-in and rarely re-run: it needs Playwright, ffmpeg, and a running dev server to screenshot live examples, so it is a manual step, not part of buildSite
bin/make-icon-data.js icon codegen (icons/src/icon-data.ts); also shipped as the tosijs-make-icons bin