Skip to content

Docs Conventions — what's generated, what's hand-owned, and what not to edit

What's generated vs. hand-owned

zensical reads mkdocs.yml natively (make docs); the C API pages are prebuilt separately with real mkdocs against a second nav file (make gen-c-api runs mkdocs build -f mkdocs-capi.yml). Most of docs/ is hand-written prose, but several pieces are generated and must never be hand-edited — check this table before editing anything that looks suspiciously uniform or mechanical:

Path Generated by Regenerate with
docs/c-api/** (except index.md) mkdoxy, from native/inc/ Doxygen comments make gen-c-api
docs/c-api/index.md (hand-written — restored via git checkout after gen-c-api wipes it)
docs/api/*.md — page prose (hand-written)
docs/api/*.mddoppler.x.Y directive output mkdocstrings, from the Python docstring, at build time edit the docstring, not the page
docs/api/*.md## Related pages block scripts/gen_related_pages.py make docs-relink
README.md — entire body below the badges scripts/gen_readme.py, from docs/index.md make docs-relink
tests/install/build-*-deps.sh (rendered into install docs) scripts/gen_install_scripts.py, from jb.toml's [dev.*] lists make docs-relink
docs/design/index.md, docs/dev/index.md, docs/gallery/index.md (hand-written — completeness CI-enforced) add a bullet by hand; gated by check_nav_index.py
docs/benchmarks.md make bench-docs make bench-docs
docs/specan/frames.json make record-demo make record-demo
docs/assets/*.png (gallery figures) make gallery make gallery
everything else under docs/ (hand-written)

The general rule: if a path above says "generated by," never hand-edit it — a hand edit is either silently discarded on the next regen (Related pages, c-api/) or just drifts further from reality until someone notices (benchmarks, specan frames). Edit the source the generator reads from instead (a Doxygen comment, a Python docstring, a benchmark run) and re-run the command in the table.

The CI-enforced completeness gates

The checks in CI's docs job keep the docs tree from rotting the way design/index.md and dev/index.md already had, twice, before the nav-index coverage gate below existed: a page gets added, gets forgotten in its section's hand-curated index, and nobody notices until someone manually audits the tree. All follow the same house idiom used by Doc Examples and check_api_docs.pydiscovered, not registered — so a new page is covered the moment it exists, with no opt-in list to remember.

Check Script What it needs from you
API docs coverage scripts/check_api_docs.py Every public symbol named somewhere under docs/api/
Nav-index coverage scripts/check_nav_index.py Every page linked from its section's index.md
Related-pages generation scripts/gen_related_pages.py Nothing — see below
README sync scripts/gen_readme.py Nothing — see below
Install-script sync scripts/gen_install_scripts.py Nothing — edit jb.toml, run make docs-relink
Version strings scripts/check_version_strings.py Never hand-type the current release version in prose
Site internal links scripts/check_site_links.py Internal links/anchors resolve in the built site
Strict build zensical build --strict Zero build warnings (bad refs, includes)
Init-param optionality scripts/check_init_param_optionality.py A published constructor signature is callable

(The three fence gates and the example gate live in the python-tests job — see Doc Examples for the whole testing story.)

Init-param optionality (check_init_param_optionality.py)

A constructor argument must be optional on both faces or neither. jm can render a stub parameter as x: T = ... — omittable — while the generated PyArg_ParseTupleAndKeywords format string places it before the | and requires it. Nothing else catches that: the stub parses, the extension compiles, the tests pass because they pass every argument, and the only symptom is that following the published signature raises TypeError. A type checker blesses the failing call, because the stub is all it can see.

The check parses the committed .pyi and the committed _ext_*.c — no import and no construction, so classes whose constructors need live resources are covered like the rest. It matches on names via the kwlist, never on position: stub order and binding order can differ (jm reports that separately as kwargs-drift), and comparing positionally names the wrong parameter.

Accepted divergences live in scripts/.init-param-optionality-ignore as Class.param, with the reason. Both faces are jm-generated, so the entries there are upstream-blocked rather than deferred work — and the check reports an entry that has stopped diverging, so the list cannot outlive its reason.

Run it locally: python scripts/check_init_param_optionality.py.

For docs/design/, docs/dev/, and docs/gallery/, this unions every on-disk *.md file (excluding archive/) with every mkdocs.yml nav entry under that section, and fails if any of them has no bullet in that section's index.md.

What to do: when you add a new page to one of these three sections, add a real one-line bullet for it to the section's index.md in the same PR — - [Title](file.md) — a real one-line description, matching the existing entries' style. Don't write a generic stub description; these are the only hand-curated lines left in this system, so they carry actual editorial context a bare file listing can't.

What NOT to do: don't add the page only to mkdocs.yml's nav (or only create the file) without touching index.md — that's exactly the drift this gate exists to catch, and CI will fail with a clear "add a bullet for X" message telling you which file and which page.

Run it locally: python scripts/check_nav_index.py.

Every docs/api/*.md page gets a generated ## Related pages section listing every gallery, guide, design, and dev page that mentions one of its documented classes/functions — grouped by family, with a marked region (<!-- related-pages:start --><!-- related-pages:end -->) so the generator can find and replace its own output on every run.

How to be picked up: the generator only counts a symbol mention inside a backtick span or a markdown link's text — `SymbolSync`, [`track.SymbolSync`](../api/python-track.md), or [SymbolSync](...). A bare, unformatted prose mention does not count. This is deliberate, not a bug: several doppler class names double as common English words (Plan, Segment, Reader, Writer, Push, Pull, Timeline, Corr, Composer, spelled without backticks right here on purpose, so this paragraph doesn't itself show up as a related page for all nine of them) — a bare word-boundary match would produce constant false positives from ordinary prose. If you want your new gallery, guide, or design page to show up on a class's API page, just name the class in backticks somewhere on the page — nothing else to configure.

What NOT to do:

  • Never hand-edit the block between the related-pages:start/:end markers. It is fully regenerated on every --write and any hand edit is silently discarded; CI's --check step also fails if the committed block doesn't match a fresh regeneration, so a hand edit shows up as permanent, unresolvable drift.
  • Don't add your own ## Related pages heading anywhere else — the heading name is reserved for this generator. Use ## See also (already hand-written on a few pages) for anything you want to write by hand; the generator always appends after it and never touches it.
  • Don't reach for the manual escape hatch first. For a purely conceptual reference with no matching class name, docs/api/.related-pages-manual.yml exists — but prefer naming the class in backticks on the source page instead whenever you can; the escape hatch is meant to stay small.

Regenerating locally: make docs-relink (wraps --write), or python scripts/gen_related_pages.py --check to preview drift without writing anything. If you add/edit a gallery, guide, design, or dev page and CI's related-pages check fails, run make docs-relink and commit the result — you don't need to hand-author anything.

A page only qualifies for scanning once it satisfies check_api_docs.py (its symbol is documented somewhere under docs/api/, generally via a doppler.<module>.<Symbol> mkdocstrings directive, or a ## `Symbol` heading for the rare hand-written page). Nothing else is required to add a new docs/api/*.md page — the next --write picks it up automatically.

README sync (gen_readme.py)

docs/index.md and README.md deliberately show the identical landing content, but the two pages render on different engines — mkdocs-material admonitions (!!! tip) on the docs site, GitHub's native alert syntax (> [!TIP]) on GitHub — so they can never be byte-identical. Keeping them in sync by hand rotted repeatedly (a live tagline edit, a missing git clone step, a stale quickstart link — and later a Performance/Licensing drift in exactly the sections a first, quickstart-only version of the generator didn't cover), so docs/index.md is the single source of truth: gen_readme.py extracts everything after its <!-- readme-sync:source-start --> marker (tagline, navigation, Why, Performance, Quick start, Build, Docs, Licensing — through end of file), rewrites admonitions into GitHub's alert syntax, rewrites relative doc links to their docs/-prefixed form (README.md lives at the repo root, one level shallower than docs/index.md), and writes the result into README.md between <!-- readme-sync:start --><!-- readme-sync:end --> markers — same idiom as gen_related_pages.py's block. Only the wordmark + badge header above the markers stays hand-owned in each file (the two heads genuinely differ: image sizing, glightbox classes, absolute-vs-relative badge targets).

What NOT to do: never hand-edit the block between the readme-sync:start/:end markers in README.md. Edit docs/index.md below its readme-sync:source-start marker instead, then run make docs-relink (or python scripts/gen_readme.py --write) and commit the result.

Regenerating locally: make docs-relink, or python scripts/gen_readme.py --check to preview drift without writing anything.

Install-script sync (gen_install_scripts.py)

jb.toml's [dev.*] package lists are the single source of truth for doppler's system dependencies — make install-deps, CI, and both Dockerfiles read them via just-bashit. The per-distro tests/install/build-*-deps.sh scripts that the install docs render via --8<-- includes are generated projections of those lists (minus patchelf and rust, which only the wheel-repair and Rust-test paths need — see the script's docstring). Never edit the scripts: change jb.toml and run make docs-relink; CI fails on any drift.