Documentation¶
How the docs site is built, and the gates that keep what it says true.
Building documentation¶
pip install --require-hashes -r requirements/docs.txt
mkdocs serve # local preview at http://127.0.0.1:8000
mkdocs build # build static site to site/
requirements/docs.txt is generated — the [docs] extra in pyproject.toml is the
single source of truth, and the lockfile is compiled from it (same pattern as
requirements/bench.txt). After changing the extra, regenerate:
uv pip compile pyproject.toml --extra docs --generate-hashes -o requirements/docs.txt
Doc-test recipes¶
Cookbook examples are executed in CI against the shipped wheel — a wrong or
broken snippet turns the suite red (#154). This kills "recipe rot": output
claims that are wrong at authoring time, or that silently break when the API
moves. The harness is Sybil; it runs every
fenced python block in an allowlisted page and checks any assert it
contains.
Run the doc-tests locally (they need the [test] extra, which pulls in Sybil):
pip install -e ".[test]"
python scripts/run_doc_tests.py # all pages, each in its own process
pytest docs/user-guide/filenames.md # a single page
The runner executes each page in a separate process. Some documented APIs
mutate process-global state (register_lang is not reversible), so running every
page in one process would let one page's registration leak into another and break
exact-output examples. pytest docs/ (one process) is therefore not the gate.
Recipe template. Assert outputs; never decorate them with # =>:
```python
from disarm import sanitize_filename
assert sanitize_filename("café.txt") == "cafe.txt"
```
Rules:
- Assert, don't comment.
assert f(x) == "y"is checked;f(x) # => "y"is not. The# =>pattern is what we are removing (#156). - Public API only. Reaching into internals (
disarm._...) in a published example is itself a doc bug — the example must exercise what users can call. - One namespace per page. Blocks share state top-to-bottom, so import once and reuse the binding in later blocks.
- Hide setup that would clutter the prose in an invisible block — it runs but does not render:
<!--- invisible-code-block: python
tmp = make_fixture()
-->
- Skip a block that is intentionally not runnable (e.g. pseudo-code or a
shell transcript mislabelled
python) with<!--- skip: next -->.
Enabling a page. Two lists in docs/conftest.py, and the difference is what
the page claims:
| list | blocks run | assertions checked | use it when |
|---|---|---|---|
EXECUTED_RECIPES |
yes | yes | the examples assert their outputs |
EXECUTE_ONLY_RECIPES |
yes | nothing to check | the examples only need to not raise |
The ratchet is about assertions and is unchanged: a page joins
EXECUTED_RECIPES only once its examples assert rather than decorate with # =>.
What the second list removed (#656) is the third state — a page with python
blocks that nothing ran at all, so a signature change broke a published
example in silence. Eight pages were in it.
tests/test_doc_recipe_coverage.py keeps that state gone: a page with a python
block and no listing fails there. Add the page to a list rather than widening the
exclusion.
README.md is the source; docs/index.md is generated (#656)¶
Do not edit docs/index.md. It is produced by scripts/generate_docs_index.sh
from README.md plus docs/_index_nav.md, which rewrites the (docs/…) link
prefixes and appends the site navigation. Edit one of the two sources and
regenerate:
bash scripts/generate_docs_index.sh # write it
bash scripts/generate_docs_index.sh --check # fail if it is out of date
The banner at the top of the file said this already, and it did not hold. Before
the --check gate existed the file had drifted both ways at once: two
Features bullets lived only in the generated file, where the next run would have
deleted them, and a Node.js nav entry, a whole-script-spoof example and a
coverage-residue note lived only in the sources and had never reached the site.
The second kind is the dangerous one — the change appears on GitHub, so it looks
applied.
This is also what executes the README. Every python block in README.md
lands in docs/index.md, which is first on EXECUTED_RECIPES and runs under
Sybil on every CI run. In sync, the README's examples are asserted; out of sync,
they are not. So a README example is written to the same standard as any other
recipe: assert outputs, never decorate them with # =>.
Per-language usage tabs (Rust & Ruby)¶
User-guide pages show usage in pymdownx.tabbed tabs — === "Python" /
=== "Rust" / === "Ruby" — over shared, language-neutral concept prose (#50).
Each binding's tab may only use functions that binding actually exposes (Rust
≈ the full disarm::api; Ruby is a smaller surface — see
bindings/ruby/lib/disarm.rb). Do not invent a call; if a topic isn't in a
binding, omit that tab. Every tab is gated:
python scripts/check_doc_rust_examples.py # compile + run every ```rust block
ruby scripts/check_doc_ruby_examples.rb # eval every Ruby `# =>` line (needs the built gem)
- Rust tabs use
assert_eq!. The gate extracts every``rust block, wraps each in a#[test], and compiles + runs it against the pure core with#![deny(unused_must_use)]— so an example that **discards** its result (aResult,Vec, orCow) is a hard error. Assert the output; don't leave a bare call with a// =>comment. Mark a genuinely illustrative block (a trait sketch, a macro) with(the Rust gate's own opt-out — distinct from Python's`, which Sybil would choke on before a non-Python block). - Ruby tabs document outputs with
# =>and start withrequire "disarm". The gate evals eachDisarm.* # => valueline against the freshly-compiled gem (it tolerates trailing prose after the literal). It runs in the Ruby workflow onbindings/ruby/**anddocs/**changes.