docs: make API reference rendering independent of page order (#3107)

This commit is contained in:
Max 2026-07-16 18:29:44 +01:00 • committed by GitHub
commit 497f7afa61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 146 additions and 35 deletions

View file

@ -143,8 +143,8 @@ jobs:
# nav entries without a page and pages without a nav entry, `zensical build # nav entries without a page and pages without a nav entry, `zensical build
# --strict` fails on broken .md links, `pymdownx.snippets: check_paths: # --strict` fails on broken .md links, `pymdownx.snippets: check_paths:
# true` fails on a deleted `docs_src/` include, and the post-build steps # true` fails on a deleted `docs_src/` include, and the post-build steps
# fail on unresolved cross-references, inventory download failures, and # fail on order-dependent API rendering, unresolved cross-references,
# broken non-markdown link targets. # inventory download failures, and broken non-markdown link targets.
# Until this job existed the docs were only ever built post-merge by # Until this job existed the docs were only ever built post-merge by
# `deploy-docs.yml`, so those failures went green on the PR and broke the next # `deploy-docs.yml`, so those failures went green on the PR and broke the next
# deploy of main. This is the check path; `deploy-docs.yml` stays the deploy # deploy of main. This is the check path; `deploy-docs.yml` stays the deploy

View file

@ -188,6 +188,13 @@ plugins:
handlers: handlers:
python: python:
paths: [src, src/mcp-types] paths: [src, src/mcp-types]
# Zensical renders pages in undefined (filesystem-dependent) order
# against one shared griffe collection, so a cross-package re-export
# (`mcp` -> `mcp_types`) resolves only if its target package happens
# to have been collected first. Chasing exported aliases into their
# packages at load time makes resolution order-independent;
# scripts/docs/check_render_order.py enforces that property.
load_external_modules: true
options: options:
relative_crossrefs: true relative_crossrefs: true
members_order: source members_order: source

View file

@ -4,8 +4,9 @@
# #
# Zensical runs no MkDocs plugins or hooks, so the build is three steps: # Zensical runs no MkDocs plugins or hooks, so the build is three steps:
# materialise the API reference pages and the concrete config, build the # materialise the API reference pages and the concrete config, build the
# site strictly, then generate llms.txt and the per-page markdown # site strictly (plus the order-independence and cross-reference checks
# renditions. This script is the single owner of that recipe, dependency # Zensical doesn't do itself), 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 # 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 # 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/. # keys on this file's path and expects the site under site/.
@ -31,6 +32,11 @@ rm -rf .cache site
uv run --frozen --no-sync python scripts/docs/build_config.py uv run --frozen --no-sync python scripts/docs/build_config.py
uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict
# The build above renders pages in one arbitrary (filesystem-dependent)
# order; prove the API reference renders in hostile orders too — see the
# check's docstring for the failure mode this guards.
uv run --frozen --no-sync python scripts/docs/check_render_order.py
# Zensical stays green even under --strict when a cross-reference fails to # Zensical stays green even under --strict when a cross-reference fails to
# resolve (rendered as literal bracket text) or an objects.inv inventory # resolve (rendered as literal bracket text) or an objects.inv inventory
# fails to download (every link through it silently degrades to plain text); # fails to download (every link through it silently degrades to plain text);

View file

@ -0,0 +1,110 @@
"""Fail the docs build when API rendering depends on page processing order.
Zensical discovers pages with an unsorted directory walk and renders them
all through one shared mkdocstrings handler, so the griffe collection that
resolves `::: module` blocks accumulates in filesystem-dependent order, and
nothing in the toolchain checks that the order is safe: a cross-package
re-export that only resolved when its target package had been collected
earlier built fine on one machine and died with `AliasResolutionError` on
another (a GitHub runner-image update reshuffled readdir order and broke
every CI docs build this way). `load_external_modules: true` in `mkdocs.yml`
makes resolution order-independent; this check enforces that property,
because a regular build only ever exercises one arbitrary order.
mkdocstrings applies module loading and per-page options only on the first
collect of a package (later pages find the package already collected), so
each package under `docs/api/` gets the two hostile sides of that
asymmetry, each from a fresh handler with an empty collection:
- its subpages first and its package index last, so pages rendering
cross-package re-exports (the index above all) come after a plain
subpage has already collected the package and nothing they declare
themselves can still affect collection;
- its package index alone, so the page with the most re-exports is itself
the first collect over an empty collection.
Only the package's own pages are rendered: resolving `mcp` re-exports
without ever rendering an `mcp_types` page is exactly the property under
test.
Usage:
python scripts/docs/check_render_order.py [--config mkdocs.gen.yml]
Run after `build_config.py` has produced the config and the `docs/api/`
tree.
"""
from __future__ import annotations
import argparse
import traceback
from pathlib import Path
# Sibling modules, same direct-invocation pattern as build_config.py:
# gen_ref_pages owns the docs/api layout, llms_txt owns the page-URL mapping.
import gen_ref_pages
from llms_txt import page_url
from zensical.compat import mkdocstrings as zensical_mkdocstrings
from zensical.config import parse_config
from zensical.markdown.render import render
API_DIR = gen_ref_pages.API_DIR
DOCS_DIR = API_DIR.parent
def _passes(package: str, pages: list[Path]) -> list[tuple[str, list[Path]]]:
"""The labeled render orders exercising both sides of the first-collect asymmetry."""
index = API_DIR / package / "index.md"
if index not in pages:
return [(f"'{package}'", pages)]
subpages = [page for page in pages if page != index]
if not subpages:
return [(f"'{package}' index-alone", [index])]
return [(f"'{package}' index-last", [*subpages, index]), (f"'{package}' index-alone", [index])]
def _render(page: Path) -> None:
"""Render one page the way Zensical's Rust core drives the Python side."""
rel = page.relative_to(DOCS_DIR).as_posix()
render(page.read_text(encoding="utf-8"), rel, page_url(rel))
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--config", default=str(gen_ref_pages.ROOT / "mkdocs.gen.yml"), help="Built config to render with"
)
args = parser.parse_args()
parse_config(args.config)
packages: dict[str, list[Path]] = {}
for page in sorted(API_DIR.rglob("*.md")):
packages.setdefault(page.relative_to(API_DIR).parts[0], []).append(page)
if not packages:
raise SystemExit(f"check_render_order: no pages under {API_DIR} (run build_config.py first)")
for package in sorted(packages):
for label, order in _passes(package, packages[package]):
# Fresh Handlers -> empty griffe collection. Autorefs anchors
# accumulate across passes, but they play no part in collection
# or alias resolution (check_crossrefs owns link health).
zensical_mkdocstrings.reset()
for position, page in enumerate(order):
try:
_render(page)
# Top-level handler: any exception from any page fails the
# check; the traceback identifies whether the order was at
# fault or something else broke (network, missing file).
except Exception:
traceback.print_exc()
rel = page.relative_to(DOCS_DIR).as_posix()
raise SystemExit(
f"check_render_order: {rel} failed at position {position + 1}/{len(order)} of the"
f" {label} order (traceback above; an AliasResolutionError means API rendering"
" depends on page order — see `load_external_modules` in mkdocs.yml)"
) from None
print(f"check_render_order: {label} order OK ({len(order)} pages)")
if __name__ == "__main__":
main()

View file

@ -64,22 +64,21 @@ def _compact_index(module: griffe.Module, documented: set[str]) -> str | None:
"""Build a compact page body for a module that re-exports from outside its own subtree. """Build a compact page body for a module that re-exports from outside its own subtree.
mkdocstrings renders a re-export whose canonical documentation lives on mkdocstrings renders a re-export whose canonical documentation lives on
another page as a full duplicate of it: deterministically for aliases another page as a full duplicate of it, whether the alias stays within
within one top-level package (`mcp.client.auth` re-exporting from one top-level package (`mcp.client.auth` re-exporting from
`mcp.shared.auth`), and order-dependently across top-level packages `mcp.shared.auth`) or crosses packages (`from mcp_types import y` +
(`from mcp_types import y` + `__all__` renders the duplicate only when `__all__` — `load_external_modules` in mkdocs.yml has the collector chase
the other package happens to be loaded already, and silently omits the exported cross-package aliases when their package is first collected, so
member when it isn't). Modules whose exports all live in their own the target package is loaded regardless of page order). Modules whose
subtree (`mcp_types` re-exporting its private `._types` module, or a exports all live in their own subtree (`mcp_types` re-exporting its
module whose `__all__` lists only its own definitions) are unaffected private `._types` module, or a module whose `__all__` lists only its own
and keep the plain `::: module` stub (return `None`): their page is definitions) are unaffected and keep the plain `::: module` stub (return
itself the canonical rendering. `None`): their page is itself the canonical rendering.
For an affected module, pin the semantics instead of inheriting the For an affected module, replace the duplicates: every export whose
accident: every export whose canonical page exists elsewhere under the API canonical page exists elsewhere under the API reference becomes a link to
reference becomes a link to it, and only exports documented nowhere else it, and only exports documented nowhere else (re-exports from private
(re-exports from private modules) keep their full body here, via an modules) keep their full body here, via an explicit `members:` list.
explicit `members:` list.
""" """
prefix = f"{module.path}." prefix = f"{module.path}."
exports: dict[str, griffe.Object | griffe.Alias] = {} exports: dict[str, griffe.Object | griffe.Alias] = {}
@ -138,18 +137,7 @@ def _compact_index(module: griffe.Module, documented: set[str]) -> str | None:
entry += f" — {summary}" entry += f" — {summary}"
sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry) sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry)
# Rendering the stub resolves the cross-package aliases again, in
# mkdocstrings' own collection. On a warm incremental rebuild the target
# package's pages can all be cache hits, so nothing else loads it and the
# resolution crashes (AliasResolutionError); preloading pins it. The
# module's own root package needs no pin: rendering the stub loads it.
preload = sorted(
{member.target_path.split(".")[0] for member in exports.values() if member.is_alias}
- {module.path.split(".")[0]}
)
body = [f"::: {module.path}", " options:"] body = [f"::: {module.path}", " options:"]
if preload:
body += [" preload_modules:", *(f" - {pkg}" for pkg in preload)]
if inline: if inline:
body += [" members:", *(f" - {name}" for name in inline)] body += [" members:", *(f" - {name}" for name in inline)]
else: else:

View file

@ -93,7 +93,7 @@ def _dest_md_uri(src_uri: str) -> str:
return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md" return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md"
def _page_url(src_uri: str) -> str: def page_url(src_uri: str) -> str:
"""The directory URL of a page relative to the site root (`servers/tools/`, `""` for the home page).""" """The directory URL of a page relative to the site root (`servers/tools/`, `""` for the home page)."""
return _dest_md_uri(src_uri).removesuffix("index.md") return _dest_md_uri(src_uri).removesuffix("index.md")
@ -260,7 +260,7 @@ def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str,
raise _BuildError(f"llms_txt: cannot resolve link target {target!r} in {src_uri}") raise _BuildError(f"llms_txt: cannot resolve link target {target!r} in {src_uri}")
if linked.endswith(".md"): if linked.endswith(".md"):
# Pages without a markdown rendition (the api/ stubs) link to their HTML instead. # Pages without a markdown rendition (the api/ stubs) link to their HTML instead.
url = _dest_md_uri(linked) if linked in prose else _page_url(linked) url = _dest_md_uri(linked) if linked in prose else page_url(linked)
else: else:
url = linked # assets are published at their docs-relative path url = linked # assets are published at their docs-relative path
return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}"
@ -316,7 +316,7 @@ def generate(site_dir: Path) -> None:
# same one `_title` falls back to). # same one `_title` falls back to).
h1 = _prose_h1(markdown) h1 = _prose_h1(markdown)
body = markdown if h1 is None else markdown[: h1.start()] + markdown[h1.end() :] body = markdown if h1 is None else markdown[: h1.start()] + markdown[h1.end() :]
full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""] full += [f"# {title}", "", f"Source: {site_url}{page_url(src_uri)}", "", body.strip(), ""]
index.append("") index.append("")
index += ["## Optional", ""] index += ["## Optional", ""]
@ -332,7 +332,7 @@ def generate(site_dir: Path) -> None:
f" missing {sorted(generated - listed)}, stale {sorted(listed - generated)}" f" missing {sorted(generated - listed)}, stale {sorted(listed - generated)}"
) )
for src_uri, title, description in _OPTIONAL_PAGES: for src_uri, title, description in _OPTIONAL_PAGES:
index.append(f"- [{title}]({site_url}{_page_url(src_uri)}): {description}") index.append(f"- [{title}]({site_url}{page_url(src_uri)}): {description}")
index.append("") index.append("")
(site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8") (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8")