/tutorialNNN.py"`
- # (resolved against the repo root regardless of the build's working
- # directory; the extension's default base_path is the CWD).
- # `check_paths: true` + `strict: true` turn a renamed/deleted example into a
- # build failure instead of a silently empty code block.
+ # (resolved against the repo root, which is the build's working directory).
+ # `check_paths: true` turns a renamed/deleted example into a build failure
+ # instead of a silently empty code block.
- pymdownx.snippets:
- base_path: !relative $config_dir
+ base_path: [.]
check_paths: true
- pymdownx.tilde
- pymdownx.inlinehilite
- pymdownx.highlight:
pygments_lang_class: true
- - pymdownx.extra:
- pymdownx.superfences:
- custom_fences:
- - name: mermaid
- class: mermaid
- format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: pymdownx.superfences.fence_code_format
+ # Zensical re-implements the emoji extension; the generator/index functions
+ # live under `zensical.extensions.emoji`, not `material.extensions.emoji`.
- pymdownx.emoji:
- emoji_index: !!python/name:material.extensions.emoji.twemoji
- emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ emoji_index: zensical.extensions.emoji.twemoji
+ emoji_generator: zensical.extensions.emoji.to_svg
options:
custom_icons:
- docs/.overrides/.icons
@@ -170,23 +168,22 @@ markdown_extensions:
custom_checkbox: true
- sane_lists # this means you can start a list from any number
+# Zensical natively watches these beyond docs/: page content is assembled
+# from src/ (mkdocstrings) and docs_src/ (snippet includes), so serve
+# live-reload must react to both.
watch:
- src
- docs_src
-hooks:
- - docs/hooks/llms_txt.py
-
+# Zensical natively re-implements `search`, `glightbox` and `mkdocstrings`; it
+# does not run arbitrary MkDocs plugins or hooks. The former `gen-files`,
+# `literate-nav` and `llms_txt` hook are handled by the standalone scripts
+# under `scripts/docs/` (see scripts/docs/build.sh). The `social` plugin was
+# dropped: Zensical has no social-card support, and the cards were gated on
+# ENABLE_SOCIAL_CARDS, which no workflow ever set.
plugins:
- search
- - social:
- enabled: !ENV [ENABLE_SOCIAL_CARDS, false]
- glightbox
- - gen-files:
- scripts:
- - docs/hooks/gen_ref_pages.py
- - literate-nav:
- nav_file: SUMMARY.md
- mkdocstrings:
handlers:
python:
diff --git a/pyproject.toml b/pyproject.toml
index e41416b8ac..7c4e4ceed1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -76,16 +76,29 @@ dev = [
"opentelemetry-sdk>=1.39.1",
]
docs = [
- # MkDocs 2.0 is a ground-up rewrite (no plugin system) that is incompatible
- # with mkdocs-material and every plugin below; stay on the 1.x line.
- "mkdocs>=1.6.1,<2",
- "mkdocs-gen-files>=0.5.0",
- "mkdocs-glightbox>=0.4.0",
- "mkdocs-literate-nav>=0.6.1",
- # docs/extra.css overrides Material-internal nav selectors; revisit it on a
- # major bump before raising this cap.
- "mkdocs-material[imaging]>=9.7.0,<10",
- "mkdocstrings-python>=2.0.1",
+ # Zensical is the Material team's successor to MkDocs; it natively
+ # re-implements search, glightbox and mkdocstrings but runs no arbitrary
+ # MkDocs plugins or hooks, so the API reference (formerly gen-files +
+ # literate-nav) and llms.txt (formerly a hook) are generated by the
+ # standalone scripts under scripts/docs/. See scripts/docs/build.sh.
+ # 0.0.48 fixed relative/scoped cross-references for mkdocstrings-python
+ # (which the mkdocstrings config in mkdocs.yml relies on) but broke
+ # search; 0.0.50 fixes it. The toolchain is pinned exactly: Zensical is
+ # pre-1.0 and the build guards key on its rendering behavior, so bumps
+ # should be deliberate.
+ "zensical==0.0.50",
+ # Zensical's mkdocstrings compatibility layer targets the mkdocstrings 1.x /
+ # mkdocstrings-python 2.0.5+ API (griffe 2 / griffelib); the older
+ # mkdocstrings 0.30 / python 2.0.1 line renders API pages with an
+ # unregistered-autorefs KeyError under Zensical.
+ "mkdocstrings==1.0.4",
+ "mkdocstrings-python==2.0.5",
+ # scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly.
+ "pyyaml>=6.0.2",
+ # gen_ref_pages.py imports griffe directly. griffelib is not a typo: it is
+ # griffe's successor distribution (same author) and still imports as
+ # `griffe`; the old `griffe` distribution is the incompatible 1.x line.
+ "griffelib==2.1.0",
]
codegen = ["datamodel-code-generator==0.57.0"]
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh
index 5a61309acf..8286786091 100755
--- a/scripts/build-docs.sh
+++ b/scripts/build-docs.sh
@@ -1,13 +1,17 @@
#!/usr/bin/env bash
#
-# Build combined v1 + v2 MkDocs documentation for GitHub Pages.
+# Build combined v1 + v2 documentation for GitHub Pages.
#
# v1 docs (from the v1.x branch) are placed at the site root.
# v2 docs (from main) are placed under /v2/.
#
-# Both branches are fetched fresh from origin, so the output is identical
-# regardless of which branch triggered the workflow. This script is intended
-# to run in CI; for local single-branch preview use `uv run mkdocs serve`.
+# The two lines use different toolchains: v1.x still builds with MkDocs, while
+# main builds with Zensical (which needs a pre-build step to materialise the API
+# reference and a post-build step for llms.txt — see scripts/docs/). Each branch
+# is fetched fresh from origin and built with its own synced `docs` group, so
+# the output is identical regardless of which branch triggered the workflow.
+# This script is intended to run in CI; for a local v2 preview use
+# `scripts/serve-docs.sh`.
#
# Usage:
# scripts/build-docs.sh [output-dir]
@@ -30,7 +34,21 @@ cleanup() {
}
trap cleanup EXIT
-rm -rf "${OUTPUT_DIR:?}"/*
+# Build the checked-out worktree into its local `site/`, picking the toolchain
+# from the branch's own files rather than hard-coding it here: a branch that
+# ships the Zensical build recipe (scripts/docs/build.sh) builds with it,
+# otherwise it falls back to MkDocs. This keeps the combined build correct
+# regardless of which branch triggered it. Zensical requires site_dir to live
+# within the project root, so both paths build to the local `site/` and let
+# the caller copy it to its destination.
+build_site() {
+ if [[ -f scripts/docs/build.sh ]]; then
+ bash scripts/docs/build.sh
+ else
+ uv sync --frozen --group docs
+ NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site
+ fi
+}
build_branch() {
local branch="$1" worktree="$2" dest="$3"
@@ -43,11 +61,15 @@ build_branch() {
(
cd "$worktree"
- uv sync --frozen --group docs
- uv run --frozen --no-sync mkdocs build --site-dir "$dest"
+ rm -rf site
+ build_site
+ mkdir -p "$dest"
+ cp -a site/. "$dest/"
)
}
+rm -rf "${OUTPUT_DIR:?}"/*
+
build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR"
build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2"
diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh
new file mode 100755
index 0000000000..8dce3afd4f
--- /dev/null
+++ b/scripts/docs/build.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+#
+# Build the v2 documentation site for this checkout into `site/`.
+#
+# Zensical runs no MkDocs plugins or hooks, so the build is three steps:
+# materialise the API reference pages and the concrete config, build the
+# site strictly, then generate llms.txt and the per-page markdown
+# renditions. This script is the single owner of that recipe, dependency
+# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh
+# all call it. The toolchain detection in docs-preview.yml and build-docs.sh
+# keys on this file's path and expects the site under site/.
+#
+# Usage:
+# scripts/docs/build.sh
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Snippet includes (`--8<--`) resolve against the working directory, which
+# must therefore be the repo root.
+cd "$SCRIPT_DIR/../.."
+
+uv sync --frozen --group docs
+
+# Zensical's incremental cache is unsound: a warm rebuild where only some
+# pages re-render silently drops cross-references to cache-hit pages, and
+# HTML for since-deleted pages lingers in site/. Build cold so the output
+# (and the checks below) are deterministic.
+rm -rf .cache site
+
+uv run --frozen --no-sync python scripts/docs/build_config.py
+uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict
+
+# Zensical stays green even under --strict when a cross-reference fails to
+# resolve (rendered as literal bracket text) or an objects.inv inventory
+# fails to download (every link through it silently degrades to plain text);
+# MkDocs strict mode aborted on both. Validate the built site instead.
+uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site
+
+uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site
diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py
new file mode 100644
index 0000000000..daba648344
--- /dev/null
+++ b/scripts/docs/build_config.py
@@ -0,0 +1,90 @@
+"""Produce the concrete Zensical build config from `mkdocs.yml`.
+
+Zensical builds from `mkdocs.yml` directly, but it has no equivalent of
+mkdocs-literate-nav: the "API Reference" navigation has to be materialised
+as explicit entries. This script regenerates the `docs/api/` tree (via
+gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced
+in — that generated file is what `zensical build`/`serve` consumes.
+
+Usage:
+ python scripts/docs/build_config.py
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+from pathlib import Path
+
+# Both scripts live in this directory, which Python puts on sys.path[0] when
+# `build_config.py` is run directly (its documented invocation).
+import gen_ref_pages
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not
+# a page path (same classifier as llms_txt.py; a `://` test would misread
+# scheme-only URIs as pages).
+_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
+
+
+def _nav_pages(nav: list) -> set[str]:
+ """Collect every local page reference in the nav (external links excluded)."""
+ pages: set[str] = set()
+ for entry in nav:
+ value = next(iter(entry.values())) if isinstance(entry, dict) else entry
+ if isinstance(value, list):
+ pages |= _nav_pages(value)
+ elif not _EXTERNAL.match(value):
+ pages.add(value)
+ return pages
+
+
+def _validate_nav(nav: list, docs_dir: Path) -> None:
+ """Fail on nav/page drift in either direction.
+
+ Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken
+ link without any diagnostic even under --strict, and publishes a page
+ that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the
+ build on both (--strict with `validation.omitted_files: warn`).
+ Validating here keeps those guarantees. The generated `api/` tree is
+ exempt from the orphan check: its nav is spliced in from the same
+ generator that writes the files, so it cannot drift.
+ """
+ pages = _nav_pages(nav)
+ # Containment before existence: `docs_dir / page` would happily resolve
+ # an absolute value or a `../` escape against the wrong root.
+ if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")):
+ raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}")
+ if missing := sorted(page for page in pages if not (docs_dir / page).is_file()):
+ raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}")
+ # Dot-directories (e.g. `.overrides` theme files) are not pages: the site
+ # builder ignores them, so the orphan check must too.
+ relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md"))
+ on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)}
+ if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")):
+ raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}")
+
+
+def build_config() -> None:
+ config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8"))
+
+ api_nav = gen_ref_pages.generate()
+ if not api_nav:
+ raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?")
+ for entry in config["nav"]:
+ if isinstance(entry, dict) and "API Reference" in entry:
+ entry["API Reference"] = api_nav
+ break
+ else:
+ raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav")
+
+ _validate_nav(config["nav"], ROOT / "docs")
+
+ output = ROOT / "mkdocs.gen.yml"
+ output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8")
+
+
+if __name__ == "__main__":
+ build_config()
diff --git a/scripts/docs/check_crossrefs.py b/scripts/docs/check_crossrefs.py
new file mode 100644
index 0000000000..39f866a00f
--- /dev/null
+++ b/scripts/docs/check_crossrefs.py
@@ -0,0 +1,170 @@
+"""Fail the docs build when a page's cross-references did not resolve.
+
+Zensical (0.0.48) stays green even under `--strict` on two failure modes
+MkDocs strict mode aborted on:
+
+- An unresolvable `[text][identifier]` cross-reference renders as literal
+ bracket text (mkdocs-autorefs used to warn). The generated API index and
+ the docstring cross-references rely on such references resolving.
+- A failed `objects.inv` inventory download is logged as an ERROR record and
+ otherwise ignored, silently degrading every link through that inventory
+ (thousands of standard-library links alone) to plain text.
+
+Both are caught from the built site itself, so no log-wording change can
+disarm the check: an unresolved reference leaves a tell-tale bracket
+sequence in prose text (code blocks legitimately contain `][`, e.g. dict
+indexing, so only text outside ``/`` counts), and every inventory
+declared in `mkdocs.yml` must contribute at least one resolved reference —
+an `autorefs-external` anchor, which hand-authored prose links to the same
+host never carry — to the site (an inventory that contributes none is dead
+config and fails too).
+
+Offline contributors can skip the inventory check by setting
+`DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
+
+Usage:
+ python scripts/docs/check_crossrefs.py --site-dir site
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# Unresolved cross-reference tell-tales in extracted prose (`\x00` marks a
+# skipped code element, see _ProseTextExtractor): the two-part
+# `[text][identifier]` reconstruction — the identifier part is always plain
+# text, so a code mark inside the second brackets means indexing prose like
+# `data[`x`][`y`]`, not a reference — and the shortcut `[`identifier`]` form,
+# which extracts as `[\x00]` unless a preceding word character or bracket
+# makes it a subscript like `list[`str`]`.
+_UNRESOLVED = re.compile(r"\]\[[^\]\s\x00]*\]|(?]*autorefs-external[^>]*>")
+
+
+class _ProseTextExtractor(HTMLParser):
+ """Collect text outside //