How to assemble a complete local copy of libtmux.org, and how the version manifest it serves is produced.
Only Node and pnpm are required. Everything else — uv, bun, doxygen,
docfx, swift — is optional: each self-hosted port's reference generator
is skipped, not fatal, when its toolchain isn't on PATH or isn't fully
usable (see "Reference generator statuses" below).
Two of them need help finding themselves, and the build supplies it rather
than asking you to. bun installed through npm leaves a shim whose
postinstall never ran; a working copy in a mise install directory is
preferred over it. docfx is a .NET global tool, so it needs DOTNET_ROOT
set — mise installs the SDK outside every location the apphost searches, and
without it docfx exits with "You must install .NET to run this
application" even though .NET is right there. Both are silent no-ops on a
machine that has neither.
$ ./scripts/build-site.shThis assembles _site/ at the repo root:
- The Astro shell, built once for shared prose (landing page, concepts, guides, examples, parity) at the site root.
- The Astro shell again, once per self-hosted port times version
(
latest,stableby default), each withLIBTMUX_DOCS_BASEand the version env vars set to that port+version's own values, output under_site/<port>/<version>/. - Each self-hosted port's reference generator, run once per port+version
and copied to
_site/<port>/<version>/api/when its toolchain is usable and the checkout has the generator's config/entrypoint in place. - One Pagefind pass over the assembled
_site/tree, so search spans the shell and every generator's output together.
Ecosystem ports (Rust, Go, Java — see site/src/lib/ports.ts) get no
version subtree at all: the site links out to their canonical host
(docs.rs, pkg.go.dev, javadoc.io), so there is nothing local to build.
The script ends with a summary table, one row per port+version (a single row for ecosystem ports, which have no version subtree):
PORT VERSION MODE STATUS REASON
py latest sphinx built [Sphinx + sphinx-gp-theme] ...
py stable sphinx built [Sphinx + sphinx-gp-theme] ...
ts latest astro skipped [@microsoft/api-extractor JSON] bun not usable: ...
rs - ecosystem n/a deep-links to docs.rs, no local output
built— the generator produced final HTML, copied into<port>/<version>/api/. True today for Sphinx (Python, C++) and DocC (Swift), which render their own final pages.model-only— the generator ran and produced an intermediate model (TypeScript'sdocs/api.md, .NET's docfx YAML), but nothing is copied: the shell has no page route yet that renders that model into HTML at/<port>/<version>/api/.site/src/lib/ports.ts'sreferenceUrl()already promises that URL; closing this gap means either wiring a content-collection loader + page route for it, or moving that port toreferenceMode: 'ecosystem'.skipped— the toolchain isn't usable (absent fromPATH, or present but broken — see below) or the checkout doesn't have the generator's config/entrypoint yet. Never aborts the run.failed— the toolchain is usable and the generator was actually invoked, but it exited non-zero. Recorded in the table, logged under_site/.build-logs/<port>-<version>.log, and makes the script exit 1 after printing the full table — every other port still gets built.
A toolchain is checked by actually running it (uv --version, and so on),
not just by resolving it on PATH: an installed-but-broken binary (seen in
this sandbox — an npm-shimmed bun whose postinstall was skipped) is
treated the same as an absent one, a skipped row with the first line of
the error, rather than surfacing as a confusing generator failed.
Side effect worth knowing about: the TypeScript generator is that
repo's own documented invocation, bun run docs:api
(~/work/libtmux/libtmux-ts's AGENTS.md: "Run bun run docs:api and
commit the result") — it writes packages/libtmux/docs/api.md in that
checkout, a tracked file. Running a full build with a working bun
toolchain may leave a real diff there; review or discard it as you would
any other generated-file diff.
A broken shell build (an actual Astro/content error, not a missing reference toolchain) aborts the whole run — that's not something this script tries to work around, since it means the site itself doesn't build, not that one port's reference is unavailable.
$ ./scripts/build-site.sh --ports py,ts --versions latest$ ./scripts/build-site.sh --skip-refs$ ./scripts/build-site.sh --skip-pagefind--ports limits which self-hosted ports get a shell build and a reference
generation pass (comma-separated slugs from site/src/lib/ports.ts).
--versions overrides the default latest,stable. --skip-refs builds
only the shell and search index. --skip-pagefind skips the final
indexing pass, useful while iterating on everything before it.
Port CI binds any named tree to one source checkout. All identity inputs are required together; branches, PRs, tags, and aliases are refused without them:
$ LIBTMUX_DOCS_PORT=ruby \
LIBTMUX_DOCS_VERSION=pr-42 \
LIBTMUX_DOCS_VERSION_KIND=pr \
LIBTMUX_DOCS_SOURCE_REF=0123456789abcdef0123456789abcdef01234567 \
LIBTMUX_DOCS_SOURCE_SHA=0123456789abcdef0123456789abcdef01234567 \
LIBTMUX_DOCS_IS_DEFAULT=false \
LIBTMUX_DOCS_CHECKOUT_RUBY=/workspace/source \
./scripts/build-site.sh \
--ports ruby \
--versions pr-42 \
--skip-refs \
--skip-pagefindAn alias also sets LIBTMUX_DOCS_RESOLVES_TO to the immutable tag it names.
The preflight verifies the ref, SHA, checkout HEAD, native export, API model,
staged guides, examples, manifest identity, canonical URLs, and robots policy
before producing the version subtree.
The command reference uses the revisions in site/src/data/tmux/versions.json.
Each snapshot combines that revision's manual with the corresponding binary's
list-commands output. The generator starts and cleans up a private server.
It also retains the upstream license and command source locations.
With mandoc 1.14.6 and binaries installed as <binaries>/<version>/bin/tmux,
regenerate the committed snapshots:
$ node scripts/gen-tmux-reference.mjs --source ~/study/c/tmux \
--binaries ~/.local/share/libtmux-tmux-matrix --mandoc mandocVerify that the snapshots match both the pinned source and installed binaries:
$ node scripts/gen-tmux-reference.mjs --source ~/study/c/tmux \
--binaries ~/.local/share/libtmux-tmux-matrix --mandoc mandoc --checkThe formatter's build date and host labels are excluded. Command syntax, descriptions, manual anchors and the source hashes remain version-specific.
Four scripts assert things the build itself cannot notice. None of them needs an assembled site; all four are fast enough to run before a commit.
$ node scripts/check-citations.mjsEvery source path the prose cites must exist in the port it names — the
file="…" fences, the // From <path> comments, and each page's "Where this
comes from" table. A fence read from a checkout fails the build when its file
disappears; a hand-quoted one does not, and that is the gap this closes. It
does not require a hand-quoted excerpt to match its source byte for byte:
several are excerpts with a clarifying comment added, which is what those
tables already say.
$ node scripts/gen-mcp-tools.mjsRegenerates site/src/data/mcp-tools.json, the cross-port MCP tool matrix
/mcp/tools/ renders. Pass --check to fail instead of writing when the
checked-in file is stale. It refuses to write a matrix unless its Python rule
reproduces libtmux-mcp's own documented tool set name for name, every port
declaring a wire prefix carries it on every tool, and every configured MCP
checkout is present — a partial matrix looks exactly like a finding. Ports
whose product status is unpublished, such as Lua, are excluded explicitly.
$ node scripts/gen-registry.mjsRegenerates site/src/data/registry.json, which records what each port's
package registry carries right now: a stable release, a prerelease only, or
nothing at all. installCommand in
ports.ts composes that with the install spellings
to produce the command a page or an agent prompt shows, so the command cannot
name a version the registry does not have.
It matters because most ports have no stable release, and several
resolve nothing unless the prerelease is named: Cargo treats an alpha as out of
range for a plain requirement, go get without a version resolves the highest
release and a prerelease is not one, and SwiftPM's from: excludes prereleases
from its range.
The published site does not read the committed file. deploy-shell.yml
resolves the registry against the live registries on every deploy, including
a scheduled one four times an hour, and publishes the result as /registry.json; a
release therefore reaches the site without a commit here. The committed file
is what pull requests, local builds and tests use, so their results never
depend on another repository publishing, and nothing requires refreshing it.
Pass --check to fail instead of writing when a file differs from what the
script would write. Pass --offline to re-emit the baseline without contacting
any registry, for a job that must not depend on ten third-party services; the
test gate runs --check --offline. Pass --baseline <file> to fall back on a
file other than the committed one, as the deploy does with the copy it last
published. A probe that fails for one port keeps that port's baseline entry
rather than reporting it unpublished, the same way gen-versions.mjs falls
back to its seed.
A tag is recorded only once the registry carries its version. A port tags first and publishes minutes later, and a tag with no package behind it would send an install command to nothing.
Release tags are read per port. Rust is a Cargo workspace and tags each crate
(libtmux@v0.1.0-alpha.10); Go is multi-module and tags submodules under a
path (mcp/v0.0.1-alpha.9) while the library tags bare. tagPrefix in
ports.ts declares the first case, and its absence rejects any tag carrying /
or @, so a submodule's release is never read as the library's.
Assemble a complete preview:
$ LIBTMUX_DOCS_LOCALES_ROOT=/pr-42 \
LIBTMUX_DOCS_VERSION=pr-42 \
LIBTMUX_DOCS_VERSION_KIND=pr \
pnpm build:site --versions latestAudit the assembled preview before publishing:
$ bash scripts/check-preview.sh _site pr-42Checks the actual preview artifact for URLs escaping its prefix and internal links whose pages or fragments are missing. The deployment workflow runs this audit before uploading the complete preview, including its port/version trees.
$ node scripts/audit-site.mjs && node scripts/crawl-site.mjsThese two do need an assembled _site/: the first flags empty, thin and
admonition-rendering pages, the second follows every internal link from /.
site/public/versions.json is a committed seed with a latest trunk entry
per port, so the version switcher (see
site/src/components/VersionSwitcher.astro) has something to render
before any real deploy has produced a richer manifest. build-site.sh
regenerates a fuller one from the checkouts it finds and writes it over the
seed at _site/versions.json, so a local assembly shows real tags where a
checkout is present.
To regenerate the manifest by hand, or to check what a fresh derivation looks like:
$ node scripts/gen-versions.mjsWriting straight to a file instead of stdout:
$ node scripts/gen-versions.mjs --out site/public/versions.jsonEntries are derived per port from each checkout named in
site/src/lib/ports.ts (~/work/python/libtmux, and so on): git tags
matching that port's grammar become tag entries, local branches matching vX.x
become branch entries, the checkout's current HEAD is always latest
(kind trunk), and stable is an alias resolving to the newest
non-prerelease tag. A prerelease-only port gets next; it does not get a
fabricated stable. RubyGems and LuaRocks release spellings keep their native
forms. A port whose checkout is absent falls back to the same latest-only seed
that --seed produces, with a note on stderr. Set
LIBTMUX_DOCS_CHECKOUT_<PORT> to derive refs from an exact CI checkout.
To regenerate the committed seed itself (only needed if the manifest shape
in site/src/lib/versions.ts changes):
$ node scripts/gen-versions.mjs --seed --out site/public/versions.json--overrides <path> merges a partial manifest over the derived one —
entries merge by slug within each port, defaultVersion keys replace
outright:
$ node scripts/gen-versions.mjs --overrides overrides.jsonsite/src/pages/search.astro builds its Pagefind bundle URL from
import.meta.env.BASE_URL — correct for the one Pagefind index Starlight
or a single-version site would have, but this site runs Pagefind exactly
once over the whole assembled _site/ tree (/pagefind/...), not once per
port+version. A search page rendered under _site/<port>/<version>/search/
computes /<port>/<version>/pagefind/..., which doesn't exist — only the
root-level _site/search/ page's BASE_URL of / happens to line up.
SiteHeader.astro's search link already points at the root /search/
unconditionally, so this doesn't break site navigation today, but the
per-port/version copies of that page are dead weight and would actively
break if anything ever links to them directly. Worth an absolute
/pagefind/ path in search.astro rather than a BASE_URL-relative one —
flagged here for whoever owns that file, not fixed here.