Skip to content

Docs: publish the C++ API reference on libtmux.org - #12

Merged
tony merged 30 commits into
masterfrom
docs-site
Sep 20, 2026
Merged

tony merged 30 commits into
masterfrom
docs-site

Conversation

@tony

@tony tony commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Publishes this repository's headers as the C++ port's API reference on libtmux.org, skins the output to match the shared site shell, and gates the two ways that reference can silently go blank.

Reference generation

Doxyfile and docs/conf.py run Doxygen and render its XML through Breathe, one page per header for the 31 headers under include/. The pages sit directly under docs/ rather than in a nested api/ subdirectory, which was nesting inside the deployed api/ prefix and giving /cxx/<version>/api/api/<header>/.

Doxygen reads the comments now

Every briefdescription in the generated XML was empty — 995 of 995 — because the headers document themselves with plain //, which Doxygen treats as an ordinary source comment rather than a documentation block. It reads ///, //!, /** and /*!, and include/ had none of them.

956 comment lines across 27 headers become ///, each one directly above the declaration it describes. The diff is comments only. File-level blocks stay // because they document a file rather than the next declaration. With JAVADOC_AUTOBRIEF the XML carries 285 brief descriptions where it carried none.

.github/WRITING.md asked for the opposite — "write plain // prose above the declaration" — on the reasoning that tools/docs/api_index.py harvests it. It still does; Doxygen does not. The policy now asks for /// and says why a file-level block is the exception.

One comment was breaking the parse, and it was not the one that warned

Doxygen reported Found unknown command '\t' in options.hpp, two lines below the real defect. The cause is a code span three lines earlier: Doxygen treats a ' that is not between two word characters as a quote delimiter even inside a code span, so '' swallowed the text after it and desynchronised every backtick in the paragraph — which left \t and \ooo outside any span, where they read as commands.

Both spans are written with double backticks now, which Doxygen parses and which is also the CommonMark-correct form for a span whose content begins with a space. docs/api.md renders exactly as it did.

45 declarations were reaching the site as raw text

The published C++ pages are a Sphinx build: build-site.sh runs Doxygen into a scratch directory, then sphinx-build over this docs/ with Breathe against that XML, and copies the result into the assembled site. That build was emitting 77 warnings nobody was reading.

45 of them are the inline constexpr field handles in entities.hpp. Sphinx's C++ domain cannot parse a lambda inside an initialiser, so each one arrived on the page as literal text — unstyled, and without the _CPPv4 anchor that makes a declaration linkable or indexable. MAX_INITIALIZER_LINES now keeps the initialiser out of the XML, and the declarations render properly; docs/api.md, whose renderer keeps a signature with its initializer, still carries the values.

The other 32 are the namespace each page re-declares, since Sphinx holds one global C++ scope across a build and this reference is one page per header. That is what a per-header reference is, so conf.py suppresses duplicate_declaration.cpp.

Three gates, because the failure modes are different

Markup that did not parse is a Doxygen warning, and WARN_AS_ERROR in the Doxyfile now makes it fail — in a local doxygen Doxyfile, in the lint job, and in the site build, which run the same file. BUILTIN_STL_SUPPORT clears the four std::hash warnings that would otherwise hide a real one.

A comment Doxygen never sees produces no warning at all: with WARN_IF_UNDOCUMENTED off and HIDE_UNDOC_MEMBERS on, a // block above a declaration leaves the member undocumented and drops it. That is the original 995-of-995 defect, and it passes the warning gate. api_index.py --check now reports every such block by file and line, since it already walks exactly those declarations.

A declaration Sphinx cannot parse is invisible to both of those — the XML is well-formed and the comment is there; only the render degrades. The lint job now runs the same sphinx-build with -W.

Theming

docs/_static/libtmux-org.css imports the shell's tokens.css and maps its semantic tokens onto Furo's --color-* contract. Each var() carries Furo's own stock value as its fallback: a var() on an undefined custom property with no fallback resolves to the guaranteed-invalid value, so an unreachable tokens.css would unset every remapped colour rather than degrade to unmodified Furo.

html_js_files loads the shared shell from a root-relative /_shell/shell.js. An absolute https://libtmux.org/_shell/ URL resolves nowhere until the site is deployed, which left the injected chrome invisible in every local preview and the token bridge unverifiable.

Search

docs/_templates/search.html redirects Furo's search page to the shell's Pagefind index at /search/, which covers every port. Furo's own page needs a client-side index this build does not emit at that path. Overriding the template needs an explicit templates_path, which this conf.py never set. The stub carries html[lang] so Pagefind stops warning about it while indexing the assembled site.

Smaller things

  • .gitignore covers the xml/ tree the site build regenerates on every assembly — roughly 30 MB of derived output that Breathe consumes and nothing else reads.
  • include/libtmux/testing/capabilities.hpp was listed on the index as an umbrella header with no page of its own. It declares LIBTMUX_REQUIRES_TMUX and LIBTMUX_SKIP_TMUX_DEFECT, which docs/api-testing.md has carried all along; the site reference was the only one missing them, and now has them.
  • api_index.py --check reported its parser fixture as stale and named no command to fix it, because none existed. --write-fixture is that command, sharing one renderer with the check so the two cannot disagree.

tony and others added 25 commits September 20, 2026 06:53
Doxygen emits XML only; Breathe feeds it to Sphinx so the C++ reference
renders through the same theme as the other libtmux ports and no
Doxygen-generated HTML is published.

The public headers wrap every declaration in an ABI-versioned inline
namespace via LIBTMUX_NAMESPACE_BEGIN. Without expanding that macro through
PREDEFINED, Doxygen sees an unbalanced brace and silently drops every
declaration in the file, leaving an empty reference that still builds.

docs/ holds internal engineering material, so conf.py excludes all of it
except this reference.
Sphinx's own api/ subdirectory nested inside the deployed api/ prefix,
giving /cxx/<version>/api/api/<header>/. Hoist the header pages so the
toctree lives in index.rst directly.
Furo's own search page needs a JS index this build doesn't serve at
this path; the shell already indexes every port at /search/.
Overriding the search.html template needs an explicit templates_path,
which this conf.py never set (unlike the Python port's gp_sphinx
default).
Closes notes/status.md's "Python and C++ are unskinned islands" glitch:
without this file Furo has no --lt-* mapping and paints its own stock
blue. Imports https://libtmux.org/_shell/tokens.css and maps the shared
~25 semantic tokens onto Furo's --color-* contract. The html_static_path,
html_css_files and html_js_files entries wiring it (plus shell.js) into
conf.py landed in 30d0038 alongside an unrelated search-page fix from
concurrent work in this worktree.
…hable

A var() on an undefined custom property with no fallback resolves to the
guaranteed-invalid value, so every remapped --color-* would compute to
unset until libtmux.org resolves (notes/status.md: never deployed) --
worse than the stock-Furo glitch this adapter exists to close. Fall back
to Furo's own stock color per property instead, so an unreachable
tokens.css degrades to unmodified Furo rather than a broken page.
Pagefind logs a missing html[lang] warning when indexing the assembled
site.
An absolute https://libtmux.org/_shell/ URL resolves nowhere until the site
is deployed, so the injected chrome was invisible in every local preview and
the design-token bridge could not be checked at all. A root-relative path
resolves on both the deployed origin and a local preview server.
scripts/build-site.sh runs doxygen into ./xml/ on every assembly, so the
tree is dirty after any local docs build. It is derived output — Breathe
consumes it and nothing else reads it — and it is about 30 MB, which is a
poor accident to commit.
Every briefdescription in the generated XML was empty — 995 of 995 —
because the headers document themselves with plain `//`. Doxygen reads
`///`, `//!`, `/**` and `/*!`, and treats everything else as an ordinary
source comment. There were zero of the former in `include/`.

The prose was already there. 1,105 comments across 31 headers, written
deliberately, and the toolchain threw all of it away: anything consuming
the XML — Breathe, Sphinx, or a custom generator — got structure with no
descriptions, so the published C++ reference had no text against any
symbol.

913 comment lines in 260 blocks are converted here, each one directly
above the declaration it describes. Nothing else changes: the diff is
comments only, and file-level blocks are left as `//` because they
document a file rather than the next declaration.

With `JAVADOC_AUTOBRIEF`, the XML now carries 261 brief descriptions
where it carried none.

Two comments needed escaping once Doxygen started parsing them:
`<ostream>` read as an HTML tag, and `\t` and `\ooo` inside a sentence
about escaping read as commands.
Without it a multi-line /// block becomes the detailed description and
the brief stays empty, which is what a listing shows. The headers
document themselves in prose paragraphs, so their first sentence is the
summary a reader wants: 261 briefs reach the XML with this on and none
without.

Pairs with docs/doxygen-comment-style on the library side, which
converts the // comments Doxygen was ignoring entirely.
why: The reference generator stripped a bare `//`, so every `///` block
converted in bab15c1 rendered with a stray slash on each line and lost
its paragraph breaks: `///` separators became `/` rather than blank.
That is 239 of the page's blocks, and it is what fails the lint gate.

what:
- Strip `///` and `//!` alongside `//` through one `_uncomment` helper,
  used by both the declaration and the file-overview readers
- Convert the parser fixture to `///` and add coverage for a bare `///`
  separator, a `//!` block, and a plain `//` block that still reads
- Regenerate docs/api.md, whose entries render byte-identically again
why: Doxygen treats a `'` that is not between two word characters as a
quote delimiter even inside a code span, so ``''`` swallowed text and
desynchronised every backtick after it in the paragraph. Two lines
below, `\t` and `\ooo` then sat outside any span and were read as
commands. bab15c1 answered the warning by doubling the backslashes,
which silenced it but printed `\\t` in both the XML and docs/api.md.

Rejected escaping the backslashes: it treats the symptom, and the text
a reader sees is wrong in both renderers. A double-backtick span is
also the CommonMark-correct form for content beginning with a space,
so docs/api.md renders exactly as before.

what:
- Write the `''` and ` #';${}` spans with double backticks
- Restore single backslashes in the `vis` escape list
- Regenerate docs/api.md; doxygen over include/ now warns 0 times
  about comments
why: 23 blocks across 7 headers still documented a declaration with a
plain `//`, which Doxygen reads as an ordinary source comment. Their
prose reached docs/api.md but not the XML, so the published reference
had text against 261 of 284 documented symbols and silence against the
rest — the same defect bab15c1 set out to close, on the remainder.

what:
- Write the 43 remaining declaration comment lines as `///`
- Covers CommandRuntime's destructor, every Capability enumerator,
  Chain::send_text and Chain::command, four FailureKind values,
  Connection::set_pane_output, TargetError::separator_in_name, and the
  two testing skip macros
- Comments only: docs/api.md regenerates byte-identically, and the XML
  carries 284 briefs where it carried 261
why: The `''` span this branch fixed had been warning for as long as the
Doxyfile existed, on a build that exited zero, so the reference shipped
with the text after it swallowed and nobody saw. A warning here always
means prose did not survive the parse; there is no benign case.

Rejected setting WARN_AS_ERROR only in CI: the site build and a local
run would then disagree with the gate, and the first anyone heard of a
warning would be a red lane. One Doxyfile, one behaviour everywhere.

what:
- Set WARN_AS_ERROR so `doxygen Doxyfile` fails on a warning
- Set BUILTIN_STL_SUPPORT, without which entities.hpp's four std::hash
  specialisations warn and lose their comments
- Install doxygen in the lint job and run it there, printing the
  version so a drift in the runner's package shows in the log
- Update the brief count in the JAVADOC_AUTOBRIEF note to 285
why: `--check` reported the fixture's golden file as out of date and
named no command, because none existed — the only way to rewrite it was
to import the module and call the private renderer by hand. That is the
first thing anyone touching the parser hits.

what:
- Add --write-fixture, sharing one _render_fixture with the check so
  the two cannot render differently
- Name that command in the error message
- Make --include and --output optional, and reject them by hand when a
  page is what was asked for
- Document it, and `doxygen Doxyfile`, in CONTRIBUTING
why: The policy still told a contributor to write plain `//` above a
declaration, on the reasoning that api_index.py harvests it. Doxygen
harvests it too now, and drops a `//` block entirely — so following the
rule as written puts the prose in docs/api.md and nowhere on
libtmux.org. The whole branch contradicts it.

what:
- Ask for `///` on a declaration, and say why a file-level block is
  still `//`
- Keep the no-tags rule, whose reason is unchanged: both generators
  render the block as prose
- Name the two Markdown traps the gate now catches, so a contributor
  meets them in the policy rather than in a red lane
- Write both examples the way the rule asks
why: d7056e3 added uv.lock as a byproduct of running uv while wiring up
Doxygen and Sphinx. It locks only what pyproject.toml already declared —
jsonschema, referencing, tomlkit — and nothing from the docs toolchain,
so it is unrelated to the change this branch makes.

Nothing reads it: CI runs `uv run --no-project`, and mcp_swap.py is a
PEP 723 script whose dependencies travel in its own header.

what:
- Remove uv.lock, restoring the tree master already has
why: The JAVADOC_AUTOBRIEF note carried the number of briefs the setting
produces, which moved from 261 to 285 the moment a header gained a
comment. WRITING.md's upkeep gate names exactly this shape: a comment
hand-syncing a value the code owns is false the first time it moves.

what:
- State that the briefs are empty without the setting, not how many
  are full with it
why: Nothing catches the defect this branch exists to fix. A `//` block
above a declaration is not a Doxygen warning — with WARN_IF_UNDOCUMENTED
off and HIDE_UNDOC_MEMBERS on it leaves the member undocumented and
drops it silently, which is how 995 of 995 briefs came to be empty. The
prose still reaches docs/api.md, so the page and the site disagree and
both look fine. WARN_AS_ERROR cannot see it.

what:
- Have --check report every declaration block carrying no `///` or
  `//!` line, by file and line, over the headers the page covers
- Collect them where they are already found, in _prose_above, behind an
  optional argument so nothing else changes shape
- Cover it with the fixture's own plain-comment declaration
why: The published reference is a Sphinx build — build-site.sh runs
sphinx-build over docs/ with Breathe against the Doxygen XML — and it
was emitting 77 warnings nobody read. 45 were `inline constexpr` field
handles in entities.hpp whose lambda initialiser Sphinx's C++ domain
cannot parse: each reached the page as unparsed text, unstyled and
without the anchor that makes it linkable. Doxygen warns about none of
it, so neither existing gate could see it.

Rejected keeping the initialisers and living with the warnings: a
declaration that does not parse has no anchor, so nothing can link to
it and the index does not carry it. The values are still in
docs/api.md, whose renderer keeps a signature with its initializer.

what:
- Set MAX_INITIALIZER_LINES so declarations reach Sphinx without the
  initialiser Sphinx cannot read
- Suppress duplicate_declaration.cpp, which is what one page per header
  is: every page re-declares the namespace its header opens
- Run the same sphinx-build in the lint job with -W
why: The index called it an umbrella header that "only re-exports
others, so they have no reference page of their own". It declares
LIBTMUX_REQUIRES_TMUX and LIBTMUX_SKIP_TMUX_DEFECT, the two macros a
test author reaches for first, and both are in the Doxygen XML with a
brief. docs/api-testing.md has carried them all along, so the site was
the only reference missing them.

what:
- Add docs/testing-capabilities.rst, path-qualified like the sibling
  capabilities.hpp page so Breathe can tell the two apart
- List it in the toctree and drop it from the umbrella section, which
  now names the one header that is one
libtmux.org links /en/cxx/latest/ from the port switcher and the sidebar
of every page, and nothing has ever published there — the URL 403s today.
This is what publishes it.

The tree is built by libtmux/docs's assembly rather than by anything in
this repository: build-site.sh renders the shared concepts, guides and
examples with this port's code fences, reading this checkout for the
example sources those pages quote. So the job checks out both
repositories and points LIBTMUX_DOCS_CHECKOUT_CXX at this one. Without that
override the build resolves a developer's home directory, finds nothing,
and fails on every inlined example.

No port toolchain is needed on the runner. --skip-refs builds the prose
tree only; this port's API reference is generated in libtmux/docs's own
root pass and published at /en/reference/cxx/, not here.

Both references to libtmux/docs pin one full-length commit SHA rather
than a tag, with the release name in a trailing comment: this repository
runs that repository's build script under a role that writes the live
bucket, and a tag can be repointed. They must be bumped together.

Temporary and deliberately conservative: no port has published through
this workflow yet, so it publishes trunk at cxx/latest with is-default
false — reachable and linkable, out of the search index, and the bare port
root still falls through to the landing page until the shape is proven.
This repository's lint job runs actionlint, and actionlint 1.7.12 does not
know the `queue` concurrency key — it fails the workflow on it. `queue`
is in GitHub's published schema and libtmux/docs uses it with successful
runs, so this is the linter lagging rather than an error. But a lint that
fails is a lint that blocks, and `cancel-in-progress: false` gets the
behaviour that matters here: a second push waits rather than cancelling
the publish in flight.
That tree 403s on libtmux.org today — nothing has ever published under a
port version prefix. This lets the branch publish without merging.

Also points pnpm setup at the docs checkout's package.json (this repo has
none of its own) and pins the manifest jq fix. Drop docs-site from the
trigger in the change that merges to master.
why: The headers gained a second renderer, and the reference a reader is
pointed at gained the prose it never carried. Both decide whether
someone reading the published surface sees anything against a symbol,
which is what an entry here is for.

what:
- Record the Doxygen and Breathe reference under Documentation
- Record that declaration comments reach it, and why they did not
- Record the `<ostream>` the Markdown reference was dropping
The C++ reference showed 85 types with a name, a member list and no sentence
saying what they were. This writes the missing prose to the rules in
WRITING.md: `///` above the declaration, no Doxygen tags, and what the reader
cannot see from the signature.

The recurring subject is snapshot semantics, because it is the design and it
surprises: an entity reads its own fields without touching tmux, so every
value it reports is the listing's moment rather than now, and equality is the
server incarnation together with the id so a handle kept across a restart does
not silently become a handle to whatever reused the number.

`expected` and `unexpected_t` are documented on both arms of their `#if`;
Doxygen resolves the preprocessor and would otherwise see only the branch
without the comment.
"Exchange positions with another window, keeping both ids." sat above
next_layout's comment block, which meant Doxygen read it as the first
line of next_layout's brief. The published C++ reference described
Window::next_layout as swapping two windows.

It belongs to swap_with, which had no comment of its own, and is moved
back there.
why: Port builds still use the previous docs shell.

what:
- Pin the docs checkout and deploy workflow to the published refresh.
Mark public declaration comments for Doxygen and add reference pages for error and wait headers. Regenerate the API indexes so the documentation gate and rendered reference stay current.
@tony
tony merged commit 284c7dc into master Sep 20, 2026
34 checks passed

This branch was successfully deployed

1 active deployment
docs — 621623c3 Deployed Sep 20, 2026 by tony via publish / publish #5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant