diff --git a/template/.github/workflows/cicd.yaml b/template/.github/workflows/cicd.yaml index 38eac7e..eba78ac 100644 --- a/template/.github/workflows/cicd.yaml +++ b/template/.github/workflows/cicd.yaml @@ -51,3 +51,17 @@ jobs: run: uv sync --no-sources - name: Run pytest run: uv run --no-sources pytest + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Build documentation (strict) + run: make docs-strict + - name: Run docs drift tests + run: make test-docs diff --git a/template/Makefile b/template/Makefile index f3899eb..c5c0eed 100644 --- a/template/Makefile +++ b/template/Makefile @@ -42,3 +42,61 @@ pre_commit: format: # format all code uv run ruff format + +# --------------------------------------------------------------------------- +# Documentation targets — merged into the project Makefile by the +# update-docs skill. Assumes Sphinx source is at docs/source/ and output +# lands at docs/build/. +# +# This header also serves as the merge sentinel: the scaffolding step looks +# for it in the project Makefile to detect that the block was already merged +# (idempotency). Keep the header text intact when editing. +# --------------------------------------------------------------------------- + +# Command prefix that runs tools inside the project environment. These +# targets are opinionated on uv (the `uv sync` calls below assume it) and on +# a src/ layout (docs-live watches src/); RUN stays a variable only so +# one-off invocations can override it. +RUN ?= uv run + +.PHONY: docs docs-strict docs-linkcheck docs-live test-docs docs-doctest clean-docs + +# Build HTML documentation. +docs: + uv sync --group docs + $(RUN) sphinx-build -a -b html docs/source docs/build/html + @echo "Open docs/build/html/index.html" + +# Strict build: fail on any warning. +# Use this in CI to catch broken cross-references, orphaned pages, and +# autodoc surprises before they reach the main branch. +docs-strict: + uv sync --group docs + $(RUN) sphinx-build -W --keep-going -a -b html docs/source docs/build/html + +# Check external links. Kept separate from docs-strict because it depends on +# the network and is therefore flaky in CI and useless offline. +docs-linkcheck: + uv sync --group docs + $(RUN) sphinx-build -a -b linkcheck docs/source docs/build/linkcheck + +# Live-reload mode: rebuild on file change. Watches both docs source and src. +docs-live: + uv sync --group docs + $(RUN) sphinx-autobuild -a -b html docs/source docs/build/html \ + --watch src \ + --open-browser + +# Run the docs drift-tripwire test suite. +test-docs: + $(RUN) pytest tests/docs + +# Run Sphinx doctest builder to verify code examples in docs. +docs-doctest: + uv sync --group docs + $(RUN) sphinx-build -a -b doctest docs/source docs/build/doctest + +# Remove build artefacts and auto-generated API stubs. The _apidoc path must +# match the ``:toctree:`` directory in docs/source/reference/python-api.md. +clean-docs: + rm -rf docs/build docs/source/reference/_apidoc diff --git a/template/README.md.jinja b/template/README.md.jinja index 68a62dc..bebdb02 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -37,6 +37,31 @@ make test make lint ``` +## Documentation + +Documentation is a [Sphinx](https://www.sphinx-doc.org/) + [MyST](https://myst-parser.readthedocs.io/) +site. Only `docs/source/` is rendered; it is organised by audience (orientation / +users / operators / maintainers) following the [Diátaxis](https://diataxis.fr/) +model. Author meta-rules live under `docs/source/contributing/` +(`documentation_guide.md`, `voice.md`, `writing_documentation.md`). + +```bash +make docs # build HTML into docs/build/html +make docs-strict # build with -W (warnings are errors) — use this in CI +make docs-live # live-reloading preview server +make test-docs # run the docs drift-tripwire tests in tests/docs/ +``` + +`tests/docs/` pins documented facts (exported symbols, defaults, the toctree +structure) to the source so that code/doc drift fails the build. Extend it as +you document the project. + +The scaffolding is maintained with the +[`update-docs` and `check-docs`](https://github.com/mariushelf/claude-swe-tools) +skills: `check-docs` audits the docs against the code and emits a report; +`update-docs` repairs the gaps. Run `check-docs` periodically (or in CI) and +feed its report to `update-docs` to keep the docs current. + ## Releasing Releases are managed via [python-semantic-release](https://python-semantic-release.readthedocs.io/). diff --git a/template/docs/source/_static/.gitkeep b/template/docs/source/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/template/docs/source/_templates/.gitkeep b/template/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/template/docs/source/_templates/autosummary/module.rst b/template/docs/source/_templates/autosummary/module.rst new file mode 100644 index 0000000..bc54cf8 --- /dev/null +++ b/template/docs/source/_templates/autosummary/module.rst @@ -0,0 +1,65 @@ +{{ fullname | escape | underline }} + +.. automodule:: {{ fullname }} +{%- if modules %} + :no-members: +{%- endif %} + +{% if not modules %} + {% block attributes %} + {%- if attributes %} + .. rubric:: {{ _('Module attributes') }} + + .. autosummary:: + {% for item in attributes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {% block functions %} + {%- if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {% block classes %} + {%- if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {% block exceptions %} + {%- if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} +{% endif %} + +{% block modules %} +{%- if modules %} +.. rubric:: {{ _('Submodules') }} + +.. autosummary:: + :toctree: + :recursive: +{% for item in modules %} + {{ item }} +{%- endfor %} +{% endif %} +{%- endblock %} diff --git a/template/docs/source/adr/README.md b/template/docs/source/adr/README.md new file mode 100644 index 0000000..9df6f5c --- /dev/null +++ b/template/docs/source/adr/README.md @@ -0,0 +1,4 @@ +# Architecture decision records (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/adr/index.md b/template/docs/source/adr/index.md new file mode 100644 index 0000000..805aecb --- /dev/null +++ b/template/docs/source/adr/index.md @@ -0,0 +1,26 @@ +# Architecture Decision Records + +ADRs record significant architectural decisions: the context, the options +considered, and the rationale for the choice made. They are not changed after +the decision is accepted; superseded records are moved to `obsolete/`. + +ADR files are named `NNN-short-slug.md` (e.g. `001-choose-web-framework.md`). + + + +| ADR | Title | Status | +|-----|-------|--------| +| — | *(no ADRs yet)* | — | + + + +```{toctree} +:maxdepth: 1 +:hidden: +:glob: + +obsolete/* +``` diff --git a/template/docs/source/adr/obsolete/index.md b/template/docs/source/adr/obsolete/index.md new file mode 100644 index 0000000..7c670e1 --- /dev/null +++ b/template/docs/source/adr/obsolete/index.md @@ -0,0 +1,7 @@ +# Obsolete ADRs + +ADRs that have been superseded are moved here from `adr/`. They are kept for +the historical record; the decisions they document no longer apply. + +Do not delete this page: the `obsolete/*` glob in the ADR index toctree must +always match at least one document, or a strict (`-W`) Sphinx build fails. diff --git a/template/docs/source/architecture/README.md b/template/docs/source/architecture/README.md new file mode 100644 index 0000000..c034169 --- /dev/null +++ b/template/docs/source/architecture/README.md @@ -0,0 +1,4 @@ +# Architecture (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/architecture/implementation/index.md b/template/docs/source/architecture/implementation/index.md new file mode 100644 index 0000000..84c923a --- /dev/null +++ b/template/docs/source/architecture/implementation/index.md @@ -0,0 +1,11 @@ +# Implementation + +Detailed implementation notes: data flow, module responsibilities, and +non-obvious design choices that are not obvious from the code alone. + + + +```{toctree} +:maxdepth: 1 + +``` diff --git a/template/docs/source/architecture/index.md b/template/docs/source/architecture/index.md new file mode 100644 index 0000000..5bec76e --- /dev/null +++ b/template/docs/source/architecture/index.md @@ -0,0 +1,23 @@ +# Architecture + +How the project is built — its structure, key components, and the reasoning +behind major design decisions. This section is aimed at maintainers and +contributors who need to understand the internals. + +```{toctree} +:maxdepth: 2 + +implementation/index +``` + +## Overview + + + +```{mermaid} +graph TD + A[Entry point] --> B[Core] + B --> C[Adapter A] + B --> D[Adapter B] +``` diff --git a/template/docs/source/concepts/README.md b/template/docs/source/concepts/README.md new file mode 100644 index 0000000..5b608a0 --- /dev/null +++ b/template/docs/source/concepts/README.md @@ -0,0 +1,4 @@ +# Concepts (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/concepts/index.md b/template/docs/source/concepts/index.md new file mode 100644 index 0000000..18e6991 --- /dev/null +++ b/template/docs/source/concepts/index.md @@ -0,0 +1,12 @@ +# Concepts + +Explanation pages — the "what" and "why" of the project. Each page covers one +concept in depth: its purpose, its place in the overall design, and the +trade-offs behind it. + + + +```{toctree} +:maxdepth: 1 + +``` diff --git a/template/docs/source/conf.py.jinja b/template/docs/source/conf.py.jinja new file mode 100644 index 0000000..1d5a374 --- /dev/null +++ b/template/docs/source/conf.py.jinja @@ -0,0 +1,126 @@ +"""Sphinx configuration. + +Project identity below (``PROJECT_NAME``, ``DIST_NAME``, ``AUTHOR``) is filled +in from the copier answers when the project is generated. The package version +is looked up from the installed distribution's metadata via +``importlib.metadata.version(DIST_NAME)``; if the project is not an installable +distribution, replace the lookup with a hard-coded string. + +The ``sys.path`` insert assumes a ``src/`` layout (the package lives at +``src/``). Adjust it if your layout differs. + +Run the build via ``make docs`` at the repo root. +""" + +from __future__ import annotations + +import os +import sys +from importlib.metadata import PackageNotFoundError, version + +# --------------------------------------------------------------------------- +# Project identity (filled from copier answers at generation time) +# --------------------------------------------------------------------------- +PROJECT_NAME = "{{ project_name }}" # human-readable project title +DIST_NAME = "{{ project_slug | replace('_', '-') }}" # name on PyPI / in pyproject [project].name +AUTHOR = "{{ author_name }}" # author or organisation + +# Make the package importable for autodoc/autosummary even when Sphinx is +# invoked outside an editable install (e.g. on Read the Docs or in CI). +# Adjust this path if your package lives somewhere other than ../../src. +sys.path.insert(0, os.path.abspath(os.path.join("..", "..", "src"))) + +# --------------------------------------------------------------------------- +# Project metadata +# --------------------------------------------------------------------------- +project = PROJECT_NAME +author = AUTHOR + +try: + _pkg_version = version(DIST_NAME) +except PackageNotFoundError: + _pkg_version = "0.0.0" + +release = _pkg_version +version = _pkg_version + +# --------------------------------------------------------------------------- +# General configuration +# --------------------------------------------------------------------------- +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.doctest", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinxcontrib.mermaid", +] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +templates_path = ["_templates"] + +exclude_patterns = [ + "_build", + # Sphinx's ``**/README.md`` glob does not match a top-level README, so + # both patterns are needed to exclude every folder landing page. + "README.md", + "**/README.md", +] + +root_doc = "index" + +# --------------------------------------------------------------------------- +# Napoleon +# --------------------------------------------------------------------------- +# Render docstring ``Attributes`` sections as :ivar: field lists to avoid +# duplicate-object-description warnings when autodoc also documents the +# same attribute from class-body annotations. +napoleon_use_ivar = True + +# --------------------------------------------------------------------------- +# MyST +# --------------------------------------------------------------------------- +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +# Render ```mermaid fenced blocks via sphinxcontrib-mermaid. +myst_fence_as_directive = ["mermaid"] + +myst_heading_anchors = 3 + +# --------------------------------------------------------------------------- +# Autodoc / autosummary +# --------------------------------------------------------------------------- +autosummary_generate = True +autosummary_imported_members = False +autodoc_member_order = "bysource" +autoclass_content = "both" +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "undoc-members": True, + "show-inheritance": True, + "exclude-members": "__weakref__,__dict__,__module__", +} + +# --------------------------------------------------------------------------- +# Intersphinx +# --------------------------------------------------------------------------- +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# --------------------------------------------------------------------------- +# HTML output +# --------------------------------------------------------------------------- +html_theme = "pydata_sphinx_theme" +html_static_path = ["_static"] +html_title = f"{project} {release}" diff --git a/template/docs/source/contributing/README.md b/template/docs/source/contributing/README.md new file mode 100644 index 0000000..8c33d1b --- /dev/null +++ b/template/docs/source/contributing/README.md @@ -0,0 +1,4 @@ +# Contributing (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/contributing/ci_cd.md b/template/docs/source/contributing/ci_cd.md new file mode 100644 index 0000000..576a6f0 --- /dev/null +++ b/template/docs/source/contributing/ci_cd.md @@ -0,0 +1,7 @@ +# CI/CD + +Overview of the continuous integration and deployment pipeline and how to +interpret failures. + +This page is a scaffold stub. Author it from the repository: describe the +pipeline stages, where they are defined, and what to do when a stage fails. diff --git a/template/docs/source/contributing/code_style.md b/template/docs/source/contributing/code_style.md new file mode 100644 index 0000000..1bbfb77 --- /dev/null +++ b/template/docs/source/contributing/code_style.md @@ -0,0 +1,7 @@ +# Code style + +Formatting, linting, and naming conventions for code in this project. + +This page is a scaffold stub. Author it from the repository: document the +formatter and linter configuration and any conventions not enforced by +tooling. diff --git a/template/docs/source/contributing/documentation_guide.md b/template/docs/source/contributing/documentation_guide.md new file mode 100644 index 0000000..3789287 --- /dev/null +++ b/template/docs/source/contributing/documentation_guide.md @@ -0,0 +1,184 @@ + + +# Documentation guide + +How the documentation under `docs/source/` is organized, and the conventions +every page follows. This is the reasoning behind the +[step-by-step how-to](writing_documentation.md): that page gives the +procedure, this one explains why. Voice and tone are governed separately by +[Voice and tone](voice.md). + +## How the documentation is organized + +Two axes structure the docs. + +The **primary axis is audience** — the top-level sections divide by who is +reading: + +- `glossary`, `index` — orientation for any reader. +- `concepts/`, `guides/` — library or service users. +- `operations/` — operators running the system: deployment, configuration, + monitoring, runbooks. +- `architecture/`, `architecture/implementation/`, `contributing/`, `adr/` — + library or service maintainers. + +The **secondary axis is document type** — within an audience section, each +page is one of: + +| Type | Answers | Lives in | +|------|---------|----------| +| Explanation | what / why | `concepts/`, `architecture/` | +| How-to | how is a task done | `guides/`, `operations/`, `contributing/` | +| Reference | what are the exact facts | `glossary`, `reference/`, API docs | +| Decision record | why a choice was made | `adr/` | + +### Relation to Diátaxis + +The four types above are the four Diátaxis quadrants (tutorial, how-to, +reference, explanation; see `diataxis.fr`). The difference is the *primary* +split. Diátaxis organizes a whole site by type; these docs organize first by +audience, and let types appear within each audience section. A library user +finds the explanation of caching (`concepts/cache`) and the how-to for +processing records (`guides/process_records`) in the same place, rather than in +separate site-wide "explanation" and "how-to" trees. The two systems are +compatible, not opposed. + +## Separate the what, the how, and the why + +A single page should not define a concept, walk through using it, and justify +the design all at once. Conflating the three produces a page no reader can act +on: the library user wades through internal rationale; the maintainer hunts +for an invariant buried under a tutorial. Each concern gets its own page, +linked to the others: + +- **What** a thing is → its concept page (`concepts/`). +- **How** it is used → a how-to (`guides/`). +- **How** it is built → its implementation page (`architecture/implementation/`). +- **Why** it was decided → an ADR (`adr/`). + +A concept page may carry light "why" inline. Deep rationale belongs in an ADR, +which the concept page links to. + +## Page shapes + +Pages of the same type cover the same ingredients. These are *ingredients, not +templates*: a page covers them in flowing prose under headings that fit its +subject, never a fixed boilerplate that announces each section by name. A +concept page reads like an explanation, not a filled-in form. + +### Concept pages + +Cover, in whatever order reads naturally: + +- The concept itself — a definition and a working mental model, in plain prose. +- Its boundaries — what the library does and does not model here, and why. A + `{important}` admonition suits a deliberate non-feature. +- Where it is used — a pointer to the relevant `guides/` how-to. +- How it is built — a pointer to the `architecture/implementation/` page. +- The decisions behind it — links to the relevant ADRs. + +Write it the way the Polars user guide explains a concept: describe the thing +well. Do not label the prose with a heading like "What is this concept" — the +reader already knows they are reading about a concept. + +### Implementation pages + +For maintainers. Cover: + +- A conceptual anchor — a pointer up to the matching `concepts/` page. +- Where it lives — module paths and `file:line` references. +- How it is built — the internal types and how they collaborate. +- The replacement seam — the port or protocol that gates swapping the + implementation, with a `file:line`. +- Invariants and edge cases. +- The decisions — ADR cross-references. + +Concept and implementation pages do not map one-to-one. One concept may +decompose into several implementation pages (a cache splits into admission, +eviction, and persistence); another stays a single page. Let the implementation +drive the split. + +### How-to pages + +Title them by the reader's goal ("Processing records", not "The +`process_records()` API"). Lead with the task, give ordered steps, prefer +imperatives. + +### Reference pages + +Exhaustive and scannable — the glossary, the FAQ, the generated API docs. +Optimized for lookup, not for reading start to finish. + +## Conventions + +### Only `docs/source/` is rendered + +`docs/source/` is the only tree Sphinx renders. Siblings under `docs/` +(`docs/reviews/`, specs, working notes) are not in any `toctree` and never +appear in the built site, so development artifacts can live next to the docs +without polluting them. + +### Code is the source of truth + +Every factual claim about architecture or behavior must be traceable to +current source. The existing prose is not evidence — verify against the code. +Do not write what cannot be verified; flag an uncertain point with a +`{caution}` admonition rather than guessing. The `tests/docs/` suite pins a set +of these claims — exported symbols, enum values, defaults — and fails when code +and docs drift apart. + +### Document the present, label the planned + +Describe what the code does today, in the present tense. For behavior that is +designed but not yet implemented, say so explicitly in a self-contained +`{caution}` admonition — state what is unbuilt and where the design appears. +Never present an aspiration as current behavior. + +### Folder indexes + +Every content subdirectory of `docs/source/` carries two index files: + +- `index.md` — the Sphinx section index, holding the section's `toctree`. +- `README.md` — a thin stub pointing at `index.md`, so the folder renders a + landing page when browsed on github.com. README files are excluded from the + Sphinx build via `exclude_patterns`; they never appear in the rendered site. + +Underscore-prefixed directories (`_static/`, `_templates/`, `_apidoc/`) and +pure asset directories are exempt. + +### Naming + +- Name pages for what a reader seeks, in full words a newcomer recognizes. + Internal jargon does not belong in a filename. +- Use full words in prose and identifiers; do not abbreviate technical terms + to save characters. +- Cite external systems (third-party services, vendors) only for concrete, + system-specific values such as endpoint URLs or rate-limit tiers, not as + a generic stand-in for a concept. + +### Cross-references + +- Link in-tree pages with the MyST `{doc}` role and a source-root-absolute + docname — `` {doc}`/concepts/cache` `` (leading slash, no `.md`). Markdown + `../` links across directories break the strict build. +- ADRs carry no `Status:` field. Reference them as links and let them hold the + rationale rather than restating it. +- Use `file:line` pointers freely in commit messages and pull requests, + sparingly in reader-facing prose. + +### Voice + +[Voice and tone](voice.md) is the authoritative register: sober, direct, no +second-person familiarity, no marketing. It is the one document this guide does +not restate — read it before drafting. + +## Building and verifying + +- `make docs` builds the HTML site. +- `make docs-strict` builds with warnings-as-errors (`sphinx-build -W`). A new + page must appear in a `toctree`, or the strict build fails. Clear + `docs/build/` first — incremental builds hide cross-reference + warnings. +- `make test-docs` runs the `tests/docs/` tripwires. +- `make docs-doctest` runs examples authored as `{doctest}` / `{testcode}` + blocks. diff --git a/template/docs/source/contributing/getting_started.md b/template/docs/source/contributing/getting_started.md new file mode 100644 index 0000000..7aafe4c --- /dev/null +++ b/template/docs/source/contributing/getting_started.md @@ -0,0 +1,7 @@ +# Getting started + +How to set up a local development environment for this project. + +This page is a scaffold stub. Author it from the repository: describe the +required tooling, how to install dependencies, and how to run the project +locally. diff --git a/template/docs/source/contributing/git_workflow.md b/template/docs/source/contributing/git_workflow.md new file mode 100644 index 0000000..29f10b0 --- /dev/null +++ b/template/docs/source/contributing/git_workflow.md @@ -0,0 +1,7 @@ +# Git workflow + +Branching strategy, commit conventions, and the pull-request process. + +This page is a scaffold stub. Author it from the repository: document the +branching model, commit message conventions, and how changes get reviewed and +merged. diff --git a/template/docs/source/contributing/index.md b/template/docs/source/contributing/index.md new file mode 100644 index 0000000..daa1aaa --- /dev/null +++ b/template/docs/source/contributing/index.md @@ -0,0 +1,25 @@ +# Contributing + +Guidelines and procedures for contributors and maintainers. + +- **Getting started** — set up a local development environment. +- **Code style** — formatting, linting, and naming conventions. +- **Testing** — how to write and run tests. +- **CI/CD** — pipeline overview and how to interpret failures. +- **Git workflow** — branching strategy, commit conventions, and PR process. +- **Writing documentation** — how to add or update docs. +- **Documentation guide** — structure, voice, and Diátaxis audience model. +- **Voice** — tone and style rules that apply to all documentation. + +```{toctree} +:maxdepth: 1 + +getting_started +code_style +testing +ci_cd +git_workflow +writing_documentation +documentation_guide +voice +``` diff --git a/template/docs/source/contributing/testing.md b/template/docs/source/contributing/testing.md new file mode 100644 index 0000000..c440c43 --- /dev/null +++ b/template/docs/source/contributing/testing.md @@ -0,0 +1,7 @@ +# Testing + +How to write and run tests for this project. + +This page is a scaffold stub. Author it from the repository: document the +test runner, how the test suite is organised, and the conventions tests are +expected to follow. diff --git a/template/docs/source/contributing/voice.md b/template/docs/source/contributing/voice.md new file mode 100644 index 0000000..29c63e3 --- /dev/null +++ b/template/docs/source/contributing/voice.md @@ -0,0 +1,151 @@ + + +# Voice and tone + +The authoritative voice register for all documentation under `docs/source/`. +The [documentation guide](documentation_guide.md) — and the repository's agent +instructions (`AGENTS.md` or similar), where present — defer to this file: the +guide covers structure and conventions, this file covers voice. +It is the long-term home for voice policy — recurring voice issues found in +review are resolved by updating the rules here. + +## Purpose + +Documentation is read by library or service users, maintainers, and AI +agents — three audiences that share one thing: they need to act on what is +written, not be entertained by it. The voice is tuned for that: a sober +technical register that does not waste words and does not adopt false +familiarity with the reader. + +## The dial + +The target is roughly the temperature of the Stripe API docs or the Polars +user guide. Not corporate-stiff, not chatty. + +Concretely, voice that is in-band: + +- Direct definitions and pointers (*"This section covers X."*). +- Noun-heavy constructions for invariants (*"Each page is self-contained."*). +- Passive voice where the library is the actor and the reader is not + (*"the invariants that must hold"*, not *"the invariants you must not break"*). +- One short, specific verb where a longer phrase would tempt cute connectors. + +Voice that is out of band: + +- Familiarity tics: *"your code does the math"*, *"jump straight to"*, + *"from there you can browse"*, *"learn how to"*. +- Collective-noun warmth: *"two big ideas"*, *"a few moving parts"*, + *"the library handles everything"*. +- Colloquial replacements for technical terms: *"gotchas"* (use "edge cases" + or "common pitfalls"), *"goes red"* (use "fails"), *"folks"* (do not use). +- Cheerleading: *"This is where the magic happens."* Documentation describes; + it does not sell. + +## Rules + +### 1. Do not use the word "people" + +In an audience-framing sentence, replace *"people who maintain the library"* +with *"library maintainers"*, *"anyone maintaining the library"*, or +*"those maintaining the library"*. The noun form ("maintainers", "authors", +"developers") is preferred when it exists; "anyone who" / "those who" are +the fallbacks. + +### 2. Avoid second-person familiarity + +Second person ("you", "your") implies a personal relationship the +documentation does not have. Most second-person sentences can be rewritten +in passive voice or in terms of the actor: + +- *"You can read about what those ideas mean in Philosophy."* → + *"Philosophy explains the reasoning."* +- *"Your code decides which records to process."* → + *"Callers decide which records to process."* +- *"If you are new to the library, start with X."* → + *"X is the recommended starting point."* +- *"The invariants you must not break."* → + *"The invariants that must hold."* + +Exception: how-to instructions that genuinely address the reader as a +guide author may use second person ("you can override the default by +…"). Even there, prefer imperative forms ("override the default by…") where +they read naturally. + +### 3. Cut familiarity tics + +Phrases that read as a podcaster speaking to a listener should be cut: + +- *"From there, you can…"* → *"The remaining pages cover…"*. +- *"Jump straight to…"* → *"… is the entry point for…"*. +- *"Browse the catalog of…"* → *"… catalog of…"* or *"covers the…"*. +- *"Learn how to…"* → *"… how to…"* or just describe the page contents. +- *"Reading order is loose."* → *"Each page is self-contained."*. + +### 4. No colloquial substitutes for technical terms + +- *"gotchas"* → *"common pitfalls"* or *"edge cases"*. +- *"the job goes red"* → *"the job fails"*. +- *"a hairy edge case"* → *"a non-trivial edge case"* or just *"an edge case"*. +- *"under the hood"* → *"internally"* or *"in the implementation"*. + +### 5. No marketing/cheerleading + +The documentation does not motivate the reader to use the library. It +describes what the library is and how to use it. Sentences that read as +trying to *convince* the reader belong on a marketing page, not in +reference documentation. + +- *"The library's two big ideas: caching and processing."* → + *"The library draws a hard line between two responsibilities."* +- *"Everything else, the library handles."* → + *"… sit on the library side of that line."* + +### 6. Italics for definitional emphasis only + +Italics emphasize the *what* of a concept the first time it is introduced, +or call out a positional metaphor ("link *up* to concept pages"). Italics +are not used for general emphasis ("this is *very* important") — if a +sentence needs that kind of emphasis, rewrite it. + +### 7. Direct readers without leading them + +Documentation routes readers to other pages by stating what the page covers, +not by inviting them with second-person verbs: + +- *"You'll want to read X next."* → *"X covers …"*. +- *"Check out Y for more."* → *"Y enumerates …"*. +- *"See Z if you're curious about W."* → *"Z explains W."*. + +## Worked examples + +Each row pairs a before/after rewrite with the rules it invokes. + +| File | Before | After | Rules invoked | +|---|---|---|---| +| `docs/source/index.md` | *"The library's two big ideas: your code does the math."* | *"The library draws a hard line between two responsibilities. **Adapters** connect: sources, sinks, and transforms."* | 2, 3, 5 | +| `docs/source/concepts/index.md` | *"If you are orienting for the first time, X and Y are good entry points."* | *"X and Y are the recommended starting points."* | 2, 3 | +| `docs/source/guides/index.md` | *"people who write adapters and configs … not people maintaining the library itself"* | *"anyone authoring adapters and configurations … as distinct from maintaining the library itself"* | 1, 2 | +| `docs/source/architecture/index.md` | *"people who maintain the library"* | *"library maintainers"* | 1 | +| `docs/source/reference/index.md` | *"common gotchas"* | *"common questions and edge cases"* | 4 | +| `docs/source/architecture/implementation/index.md` | *"the invariants you must not break"* | *"the invariants that must hold"* | 2 | + +## When to break a rule + +Rules 1–7 describe the default register. They are not a uniform. Three +situations override them: + +1. **The user-facing CLI**: terminal output, error messages, and short hints + may use second person if it makes the message clearer (*"You must specify + a config file."*). Reference documentation about the CLI follows the + default register. + +2. **Quickstart and tutorial pages**: when the reader is being walked + through a concrete sequence of steps, second-person imperatives ("now + call `process_records()`") are sometimes the most direct form. + Even then, prefer imperatives over "you can". + +3. **A figurative phrase that earns its place**: if a sentence is concretely + improved by one figurative phrase that no rewrite captures, keep it. Be + ready to defend the choice in review. + +The principle: when in doubt, sober wins. diff --git a/template/docs/source/contributing/writing_documentation.md b/template/docs/source/contributing/writing_documentation.md new file mode 100644 index 0000000..8dc788a --- /dev/null +++ b/template/docs/source/contributing/writing_documentation.md @@ -0,0 +1,30 @@ + + +# Writing documentation + +The procedure for adding or changing a page under `docs/source/`. Each step +links to the reasoning in the [documentation guide](documentation_guide.md). + +1. **Find the page's home.** Place it by audience first, then document type. + A concept goes in `concepts/`, a task in `guides/`, `operations/`, or + `contributing/`, exact facts in `reference/` or the glossary. See *How the + documentation is organized* in the [documentation guide](documentation_guide.md). +2. **Pick the page shape.** Cover the ingredients for that type as flowing + prose, not a boilerplate with named sections. See *Page shapes* in the + [documentation guide](documentation_guide.md). +3. **Match the voice.** Follow [Voice and tone](voice.md): sober, direct, no + second-person familiarity, no marketing. +4. **Ground every claim in code.** Verify architecture and behavior against + current source. Do not write what cannot be verified. +5. **Label the planned.** Describe what the code does today; mark + designed-but-unimplemented behavior with a self-contained `{caution}` + admonition stating what is unbuilt and where the design appears. +6. **Cross-reference correctly.** Link in-tree pages with the MyST `{doc}` role + and a source-root-absolute docname — `` {doc}`/concepts/cache` `` — and add + the new page to its section `toctree`. +7. **Verify the build.** Run `make docs-strict` (warnings-as-errors) and + `make test-docs` before committing; `make docs-linkcheck` when external + links changed. + +For the reasoning behind any step, see the +[documentation guide](documentation_guide.md). diff --git a/template/docs/source/glossary.md b/template/docs/source/glossary.md new file mode 100644 index 0000000..8629503 --- /dev/null +++ b/template/docs/source/glossary.md @@ -0,0 +1,18 @@ +# Glossary + +This page defines the ubiquitous language of the project: terms that carry a +precise meaning within the codebase and its documentation. When a term appears +in code, issue trackers, or docs, its meaning here is authoritative. + + + + + +example-term +: A placeholder entry. Replace with a real domain term and its definition. diff --git a/template/docs/source/guides/README.md b/template/docs/source/guides/README.md new file mode 100644 index 0000000..534a4af --- /dev/null +++ b/template/docs/source/guides/README.md @@ -0,0 +1,4 @@ +# Guides (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/guides/index.md b/template/docs/source/guides/index.md new file mode 100644 index 0000000..dcd3001 --- /dev/null +++ b/template/docs/source/guides/index.md @@ -0,0 +1,12 @@ +# Guides + +Task-oriented how-to pages for users. Each guide walks through a specific goal +from start to finish, assuming the reader already understands the relevant +concepts. + + + +```{toctree} +:maxdepth: 1 + +``` diff --git a/template/docs/source/index.md.jinja b/template/docs/source/index.md.jinja new file mode 100644 index 0000000..dc97b06 --- /dev/null +++ b/template/docs/source/index.md.jinja @@ -0,0 +1,39 @@ +# {{ project_name }} + +{{ project_short_description }} + +This is the documentation for **{{ project_name }}**. + +--- + +```{toctree} +:maxdepth: 1 +:caption: Orientation + +glossary +``` + +```{toctree} +:maxdepth: 2 +:caption: Users + +concepts/index +guides/index +reference/index +``` + +```{toctree} +:maxdepth: 2 +:caption: Operators + +operations/index +``` + +```{toctree} +:maxdepth: 2 +:caption: Maintainers + +architecture/index +contributing/index +adr/index +``` diff --git a/template/docs/source/operations/README.md b/template/docs/source/operations/README.md new file mode 100644 index 0000000..1b701eb --- /dev/null +++ b/template/docs/source/operations/README.md @@ -0,0 +1,4 @@ +# Operations (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/operations/index.md b/template/docs/source/operations/index.md new file mode 100644 index 0000000..dbedfb6 --- /dev/null +++ b/template/docs/source/operations/index.md @@ -0,0 +1,12 @@ +# Operations + +How to operate the system in production: deployment, configuration, +monitoring, and runbooks. Pages here are aimed at operators who run a live +instance, not at developers changing the code. + + + +```{toctree} +:maxdepth: 1 + +``` diff --git a/template/docs/source/reference/README.md b/template/docs/source/reference/README.md new file mode 100644 index 0000000..0cff245 --- /dev/null +++ b/template/docs/source/reference/README.md @@ -0,0 +1,4 @@ +# Reference (folder index) + +The section index for this folder is in [index.md](index.md). +This README exists only as a landing page when browsing the folder on GitHub — the canonical entry point is `index.md`. diff --git a/template/docs/source/reference/index.md b/template/docs/source/reference/index.md new file mode 100644 index 0000000..0f6c25c --- /dev/null +++ b/template/docs/source/reference/index.md @@ -0,0 +1,16 @@ +# Reference + +Exhaustive lookup material. Pages here describe the full interface of the +project — settings, endpoints, data schemas, and the Python API — without +explaining motivation or providing worked examples. + +The Python API reference is auto-generated from source docstrings; see +{doc}`python-api`. + + + +```{toctree} +:maxdepth: 1 + +python-api +``` diff --git a/template/docs/source/reference/python-api.md.jinja b/template/docs/source/reference/python-api.md.jinja new file mode 100644 index 0000000..a6b4b52 --- /dev/null +++ b/template/docs/source/reference/python-api.md.jinja @@ -0,0 +1,12 @@ +# Python API + +The API reference below is auto-generated from source docstrings by +`sphinx.ext.autosummary`. Re-run `make docs` to refresh it after code changes. + +```{eval-rst} +.. autosummary:: + :toctree: _apidoc + :recursive: + + {{ project_slug }} +``` diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index f0ebe2e..8272c99 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -98,3 +98,10 @@ dev = [ "ruff>=0.15", "ty>=0.0.17", ] +docs = [ + "sphinx>=7", + "myst-parser>=2", + "sphinxcontrib-mermaid>=0.9", + "sphinx-autobuild>=2024.4", + "pydata-sphinx-theme>=0.17.1", +] diff --git a/template/tests/docs/__init__.py b/template/tests/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/template/tests/docs/test_doc_claims.py b/template/tests/docs/test_doc_claims.py new file mode 100644 index 0000000..9110c22 --- /dev/null +++ b/template/tests/docs/test_doc_claims.py @@ -0,0 +1,188 @@ +"""Drift tripwire tests for documentation claims. + +This module is a template dropped by the ``update-docs`` skill into +``tests/docs/test_doc_claims.py``. Its purpose is to pin factual claims made +in the documentation to the actual source code so that the build fails when +they drift. + +Each test targets one specific, checkable claim: a public symbol that must +remain importable, a documented default value that must not change silently, +or a structural invariant of the docs tree itself. When a test fails, it +points directly at the drift — the code changed but the docs were not updated, +or vice versa. + +Convention (matches the project standard): every test function carries at +least a one-line docstring describing what is being checked and why. + +EDIT: Replace the placeholder assertions below with real claims sourced from +the project's documentation. +""" + +from __future__ import annotations + +import re +from fnmatch import fnmatch +from pathlib import Path + +import pytest + +# EDIT: replace ``your_package`` with the real top-level package name. +# import your_package + +# --------------------------------------------------------------------------- +# Structural tests (no project-specific knowledge required) +# --------------------------------------------------------------------------- + +DOCS_SOURCE = Path(__file__).parent.parent.parent / "docs" / "source" + +# Matches rst explicit-title syntax: ``Some Title ``. +_EXPLICIT_TITLE = re.compile(r"^.*<([^<>]+)>\s*$") + + +def _add_toctree_entry( + entries: set[str], globs: set[str], item: str, base: str +) -> None: + """Normalise one toctree body line into *entries* or *globs*. + + Skips directive option lines (``:maxdepth:``, ``:glob:``, ...) and reduces + explicit-title entries (``Some Title ``) to the docname. A glob + pattern (``obsolete/*``) references many documents, not one docname, so it + is resolved against *base* (the containing file's directory relative to the + source root) and collected separately for ``fnmatch``-based reachability. + """ + if not item or item.startswith(":"): + return + match = _EXPLICIT_TITLE.match(item) + if match: + item = match.group(1).strip() + if any(ch in item for ch in "*?["): + globs.add(f"{base}/{item}" if base else item) + return + entries.add(item) + + +def _collect_toctree_entries(root: Path) -> tuple[set[str], set[str]]: + """Return (docnames, glob patterns) referenced in toctrees under *root*. + + Handles both MyST ````{toctree}```` fences (terminated by the closing + fence) and rst ``.. toctree::`` directives inside ``eval-rst`` blocks. + Per standard rst block rules, an rst directive body ends at the first + non-empty line that is not indented relative to the directive line (or + at the closing fence of the surrounding ``eval-rst`` block), so ordinary + paragraphs following the directive are not harvested as entries. + Glob patterns come back root-relative, ready for ``fnmatch``. + """ + entries: set[str] = set() + globs: set[str] = set() + for md_file in root.rglob("*.md"): + base = str(md_file.parent.relative_to(root)).replace("\\", "/") + base = "" if base == "." else base + lines = md_file.read_text(encoding="utf-8").splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if "```{toctree}" in line: + # MyST fence: body runs until the closing ``` fence. + i += 1 + while i < len(lines) and not lines[i].lstrip().startswith("```"): + _add_toctree_entry(entries, globs, lines[i].strip(), base) + i += 1 + elif line.lstrip().startswith(".. toctree::"): + # rst directive: body is the indented block that follows. + directive_indent = len(line) - len(line.lstrip()) + i += 1 + while i < len(lines): + body_line = lines[i] + if not body_line.strip(): + i += 1 # blank lines may appear inside the body + continue + if body_line.lstrip().startswith("```"): + break # closing fence of the eval-rst block + body_indent = len(body_line) - len(body_line.lstrip()) + if body_indent <= directive_indent: + break # dedented line: the directive body has ended + _add_toctree_entry(entries, globs, body_line.strip(), base) + i += 1 + continue # re-examine the line that terminated the body + i += 1 + return entries, globs + + +def test_every_md_appears_in_a_toctree() -> None: + """Every .md page under docs/source/ must be reachable via a toctree. + + The exceptions are README.md (excluded from the build) and section + ``index.md`` files, which are the toctree targets themselves. Orphaned + pages produce Sphinx warnings (promoted to errors with -W) and are + invisible to readers navigating via the sidebar; this test surfaces the + problem at pytest time, before the build runs. + """ + if not DOCS_SOURCE.is_dir(): + pytest.skip("docs/source not scaffolded") + + toctree_entries, toctree_globs = _collect_toctree_entries(DOCS_SOURCE) + orphans = [] + for md_file in DOCS_SOURCE.rglob("*.md"): + if md_file.name in ("README.md",): + continue + rel = md_file.relative_to(DOCS_SOURCE) + # index.md files are the toctree entry targets themselves, not children. + if md_file.stem == "index": + continue + docname = str(rel.with_suffix("")).replace("\\", "/") + leaf = rel.stem + # Accept a full relative docname, a bare leaf name, or a glob match + # (e.g. an obsolete ADR pulled in via ``obsolete/*``). + if ( + docname not in toctree_entries + and leaf not in toctree_entries + and not any(fnmatch(docname, pattern) for pattern in toctree_globs) + ): + orphans.append(str(rel)) + + assert not orphans, ( + "The following pages are not referenced in any toctree:\n" + + "\n".join(f" docs/source/{p}" for p in sorted(orphans)) + ) + + +# --------------------------------------------------------------------------- +# Symbol existence tests +# EDIT: replace with real exported symbols from the project. +# --------------------------------------------------------------------------- + + +# EDIT: Uncomment and adapt the example below. The import is the whole test; +# the bound name is intentionally unused, so append a ``noqa: F401`` comment to +# the import line to silence Ruff. +# +# def test_documented_public_symbol_exists() -> None: +# """The symbol ``your_package.SomeClass`` must remain importable. +# +# The reference docs at docs/source/reference/python-api.md advertise this +# class as part of the public API. If it is renamed or removed the docs +# become misleading; this test fails at that point. +# """ +# from your_package import SomeClass + + +# --------------------------------------------------------------------------- +# Default-value tests +# EDIT: replace with real settings/constants from the project. +# --------------------------------------------------------------------------- + + +# EDIT: Uncomment and adapt the example below. +# +# def test_documented_default_value() -> None: +# """The documented default for ``SomeSettings.timeout`` is 30 seconds. +# +# docs/source/reference/settings.md states: "Default: 30". If the default +# changes without updating the docs, this test fails. +# """ +# from your_package.settings import SomeSettings +# +# assert SomeSettings().timeout == 30, ( +# "docs/source/reference/settings.md documents the default timeout as 30; " +# "update the docs if the default has intentionally changed." +# ) diff --git a/tests/test_template.py b/tests/test_template.py index 549d1c4..d6f322b 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -54,6 +54,33 @@ def test_template_renders(project_path): assert (project_path / "tests" / "test_test_project.py").is_file() +def test_docs_scaffold_renders(project_path): + """Verify the documentation scaffold is present and placeholders resolved. + + The docs scaffold must ship complete so a generated project builds its + Sphinx site immediately; this checks the key entry points exist and that + the templated identity made it into conf.py and index.md. + """ + source = project_path / "docs" / "source" + assert (source / "conf.py").is_file() + assert (source / "index.md").is_file() + assert (source / "reference" / "python-api.md").is_file() + # Meta-layer vendored verbatim into contributing/. + assert (source / "contributing" / "voice.md").is_file() + assert (source / "contributing" / "documentation_guide.md").is_file() + # Drift-tripwire harness. + assert (project_path / "tests" / "docs" / "test_doc_claims.py").is_file() + + # Copier placeholders must be resolved, not left verbatim. + conf = (source / "conf.py").read_text(encoding="utf-8") + assert 'PROJECT_NAME = "Test Project"' in conf + assert 'DIST_NAME = "test-project"' in conf + assert "{{" not in conf + index = (source / "index.md").read_text(encoding="utf-8") + assert index.startswith("# Test Project") + assert "{{" not in index + + def test_generated_tests_pass(project_path): """Run pytest in the generated project and verify it passes.""" result = subprocess.run( @@ -123,3 +150,39 @@ def test_pre_commit_passes(project_path): assert result.returncode == 0, ( f"pre-commit failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" ) + + +def test_make_docs_strict(project_path): + """Verify `make docs-strict` builds the docs with zero warnings. + + The scaffold is contracted to pass `sphinx-build -W` immediately after + generation, before any page is authored — broken cross-references, + orphaned pages, or autodoc surprises must fail here, not silently ship. + """ + result = subprocess.run( + ["make", "docs-strict"], + cwd=project_path, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"make docs-strict failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +def test_make_test_docs(project_path): + """Verify `make test-docs` passes on the freshly generated project. + + The drift-tripwire suite in tests/docs/ must be green out of the box; its + structural checks (every page reachable from a toctree) guard the scaffold + itself, independent of any project-specific claims added later. + """ + result = subprocess.run( + ["make", "test-docs"], + cwd=project_path, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"make test-docs failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + )