From f511d3ca7bc522be95156273bb5ddb6ba9dd6893 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 11:07:21 +0000 Subject: [PATCH 01/30] docs: replace MkDocs with Zensical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the v2 docs build from MkDocs Material to Zensical, the Material team's successor (as Starlette and uvicorn have done). Zensical natively re-implements search, glightbox and mkdocstrings, but — unlike MkDocs — runs no arbitrary plugins or hooks. This SDK's docs relied on three of those, so they are reimplemented as standalone build steps around `zensical build`: - API reference: `mkdocs-gen-files` + `mkdocs-literate-nav` become `scripts/docs/gen_ref_pages.py` (writes the `docs/api/` mkdocstrings stubs) and `scripts/docs/build_config.py` (splices the nested API nav into a generated `mkdocs.gen.yml`, since Zensical has no literate-nav). - llms.txt: the former MkDocs hook becomes `scripts/docs/llms_txt.py`, a post-build step over the source tree, with no dependency on build internals. `mkdocs.yml` stays the source config, with the MkDocs-only YAML tags (`!relative`, `!ENV`, `!!python/name:`) translated to the string forms Zensical understands and the emoji functions pointed at `zensical.extensions.emoji`. The generators moved out of `docs/` (Zensical publishes everything under docs_dir) into `scripts/docs/`, and `scripts/build-docs.sh` now picks the toolchain per worktree so the combined v1 (MkDocs) + v2 (Zensical) build stays correct. Adds `scripts/serve-docs.sh` for local preview. The docs dependency group drops mkdocs, mkdocs-material[imaging] and the gen-files/glightbox/literate-nav plugins for `zensical`. mkdocstrings is floored at 1.0.4 / mkdocstrings-python 2.0.5: Zensical's compatibility layer targets that API, and the older line renders API pages with an unregistered-autorefs error. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CTkPwxyKkLTnqZdeiCMCzK --- .github/workflows/deploy-docs.yml | 1 + .github/workflows/shared.yml | 19 +- .gitignore | 6 +- docs/hooks/gen_ref_pages.py | 40 --- docs/hooks/llms_txt.py | 184 -------------- mkdocs.yml | 56 ++--- pyproject.toml | 26 +- scripts/build-docs.sh | 36 ++- scripts/docs/build_config.py | 52 ++++ scripts/docs/gen_ref_pages.py | 88 +++++++ scripts/docs/llms_txt.py | 204 ++++++++++++++++ scripts/serve-docs.sh | 19 ++ tests/docs_src/test_shape.py | 2 +- uv.lock | 393 ++++++++---------------------- 14 files changed, 554 insertions(+), 572 deletions(-) delete mode 100644 docs/hooks/gen_ref_pages.py delete mode 100644 docs/hooks/llms_txt.py create mode 100644 scripts/docs/build_config.py create mode 100644 scripts/docs/gen_ref_pages.py create mode 100644 scripts/docs/llms_txt.py create mode 100755 scripts/serve-docs.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index fb16310757..77bb1e1f51 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -14,6 +14,7 @@ on: - src/mcp/** - src/mcp-types/** - scripts/build-docs.sh + - scripts/docs/** - pyproject.toml - uv.lock - .github/workflows/deploy-docs.yml diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 511d0340d1..b339b540fc 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -139,11 +139,13 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check - # `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`, - # but until this job existed the docs were only ever built post-merge by - # `deploy-docs.yml`, so a broken link, a missing nav target, or a deleted - # `docs_src/` include went green on the PR and broke the next deploy of main. - # This is the check path; `deploy-docs.yml` stays the deploy path. + # `zensical build --strict` fails on broken links / missing nav targets and + # `pymdownx.snippets: check_paths: true` fails on a deleted `docs_src/` + # include; the llms_txt step additionally re-resolves every snippet and link. + # 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 of main. This is the check path; `deploy-docs.yml` stays the deploy + # path. docs: runs-on: ubuntu-latest steps: @@ -171,8 +173,7 @@ jobs: - name: Install dependencies run: uv sync --frozen --all-extras --python 3.10 + # Zensical runs no MkDocs plugins/hooks: generate the API reference and + # the concrete config, build strictly, then generate llms.txt. - name: Build the docs in strict mode - run: uv run --frozen --no-sync mkdocs build --strict - env: - # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. - NO_MKDOCS_2_WARNING: "1" + run: bash scripts/docs/build.sh diff --git a/.gitignore b/.gitignore index 684f8d7b01..8bb91f6e47 100644 --- a/.gitignore +++ b/.gitignore @@ -141,9 +141,13 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# documentation /site /.worktrees/ +# Generated at build time by docs/hooks/ (the API reference tree and the +# concrete Zensical config spliced from mkdocs.yml). +/docs/api/ +/mkdocs.gen.yml # mypy .mypy_cache/ diff --git a/docs/hooks/gen_ref_pages.py b/docs/hooks/gen_ref_pages.py deleted file mode 100644 index 8e1afeee68..0000000000 --- a/docs/hooks/gen_ref_pages.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Generate the code reference pages and navigation.""" - -from pathlib import Path - -import mkdocs_gen_files - -nav = mkdocs_gen_files.Nav() - -root = Path(__file__).parent.parent.parent -src = root / "src" - -# `src/mcp-types` is a distribution directory, not an import package, so each -# package's dotted module path is taken relative to its own parent: deriving it -# from `src/` would emit the unimportable `mcp-types.mcp_types.*`. -for package in (src / "mcp", src / "mcp-types" / "mcp_types"): - base = package.parent - for path in sorted(package.rglob("*.py")): - module_path = path.relative_to(base).with_suffix("") - doc_path = path.relative_to(base).with_suffix(".md") - full_doc_path = Path("api", doc_path) - - parts = tuple(module_path.parts) - - if parts[-1] == "__init__": - parts = parts[:-1] - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1].startswith("_"): - continue - - nav[parts] = doc_path.as_posix() - - with mkdocs_gen_files.open(full_doc_path, "w") as fd: - ident = ".".join(parts) - fd.write(f"::: {ident}") - - mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) - -with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file: - nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py deleted file mode 100644 index c6dea3196a..0000000000 --- a/docs/hooks/llms_txt.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). - -The hook publishes three artifacts into the built site: - -- `llms.txt`: a markdown index of the documentation, one link per page, - grouped by nav section. -- a `.md` rendition of every prose page next to its HTML (e.g. - `servers/tools/index.md`), which is what the llms.txt links point at. -- `llms-full.txt`: every prose page concatenated for single-fetch consumption. - -Page markdown is the source markdown with `--8<--` snippet includes resolved -(so the `docs_src/` code examples appear inline) and relative links rewritten -to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs -with no markdown source, so they are linked as rendered HTML from an Optional -section instead of being embedded. - -Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified -pages, which would silently truncate the generated artifacts. -""" - -from __future__ import annotations - -import posixpath -import re -from dataclasses import dataclass, field -from pathlib import Path - -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.exceptions import PluginError -from mkdocs.structure.files import File, Files -from mkdocs.structure.nav import Navigation, Section -from mkdocs.structure.pages import Page - -# Pages with no markdown source, linked as HTML under "## Optional". -_OPTIONAL_PAGES = [ - ("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"), - ( - "api/mcp_types/index.md", - "mcp-types API reference", - "Auto-generated API reference for the mcp-types package (rendered HTML)", - ), -] - -_SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) -_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') - - -@dataclass -class _State: - page_markdown: dict[str, str] = field(default_factory=dict) - rendition_uris: set[str] = field(default_factory=set) - nav: Navigation | None = None - files: Files | None = None - - -_state = _State() - - -def _site_url(config: MkDocsConfig) -> str: - assert config.site_url is not None - return config.site_url.rstrip("/") + "/" - - -def _md_uri(file: File) -> str: - return re.sub(r"\.html$", ".md", file.dest_uri) - - -def on_config(config: MkDocsConfig) -> None: - # `mkdocs serve` rebuilds reuse the imported module; start each build clean. - _state.page_markdown.clear() - _state.rendition_uris.clear() - _state.nav = _state.files = None - - -def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None: - _state.nav = nav - _state.files = files - _state.rendition_uris.update(page.file.src_uri for page in nav.pages if not page.file.src_uri.startswith("api/")) - - -def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None: - if page.file.src_uri not in _state.rendition_uris: - return None - - # Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml. - repo_root = Path(config.config_file_path).parent - - def include(match: re.Match[str]) -> str: - indent, path = match["indent"], match["path"] - # Mirror the snippets extension's restrict_base_path: reject paths - # that resolve outside the repo root. - resolved_path = (repo_root / path).resolve() - if not resolved_path.is_relative_to(repo_root.resolve()): - raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root") - try: - content = resolved_path.read_text(encoding="utf-8").rstrip("\n") - except OSError as exc: - raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc - # Keep a pointer to the embedded file so readers can find it on disk. - if path.endswith(".py"): - content = f"# {path}\n{content}" - if indent: - content = "\n".join(indent + line if line else line for line in content.split("\n")) - return content - - resolved, substitutions = _SNIPPET_LINE.subn(include, markdown) - if substitutions != sum("--8<--" in line for line in markdown.splitlines()): - raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}") - - site_url = _site_url(config) - src_dir = posixpath.dirname(page.file.src_uri) - - def rewrite(match: re.Match[str]) -> str: - opening, target, anchor, title, closing = match.groups() - if "://" in target: - return match.group(0) - linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target))) - if linked is None: - raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}") - # Pages without a markdown rendition (the api/ stubs) link to their HTML instead. - url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url - return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" - - _state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved) - return None - - -def _section_pages(section: Section) -> list[Page]: - pages: list[Page] = [] - for child in section.children: - if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris: - pages.append(child) - elif isinstance(child, Section): - pages.extend(_section_pages(child)) - return pages - - -def on_post_build(config: MkDocsConfig) -> None: - assert _state.nav is not None and _state.files is not None - missing = _state.rendition_uris - _state.page_markdown.keys() - if missing: - raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}") - - site_dir = Path(config.site_dir) - site_url = _site_url(config) - - top_level = [ - item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris - ] - sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else [] - for item in _state.nav.items: - if isinstance(item, Section): - pages = _section_pages(item) - if pages: - sections.append((item.title, pages)) - - index = [f"# {config.site_name}", "", f"> {config.site_description}", ""] - full: list[str] = [] - for title, pages in sections: - index += [f"## {title}", ""] - for page in pages: - markdown = _state.page_markdown[page.file.src_uri] - (site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8") - - description = page.meta.get("description") - tail = f": {description}" if description else "" - index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}") - - body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown) - if not h1_found: - raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1") - full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""] - index.append("") - - index += ["## Optional", ""] - for src_uri, title, description in _OPTIONAL_PAGES: - linked = _state.files.get_file_from_path(src_uri) - if linked is None: - raise PluginError(f"llms_txt: optional page {src_uri} not found") - index.append(f"- [{title}]({site_url}{linked.url}): {description}") - index.append("") - - (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8") - (site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8") diff --git a/mkdocs.yml b/mkdocs.yml index f40fcb3726..57c77a1e72 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,5 @@ site_name: MCP Python SDK site_description: The official Python SDK for the Model Context Protocol -strict: true repo_name: modelcontextprotocol/python-sdk repo_url: https://github.com/modelcontextprotocol/python-sdk @@ -10,6 +9,9 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ # TODO(Marcelo): Add Anthropic copyright? # copyright: © Model Context Protocol 2025 to present +# The "API Reference" entry is a placeholder: `scripts/docs/gen_ref_pages.py` +# generates the `docs/api/` tree and `scripts/docs/build_config.py` splices the +# real nested nav in before the build. See `scripts/build-docs.sh`. nav: - MCP Python SDK: index.md - "What's new in v2": whats-new.md @@ -122,13 +124,6 @@ theme: extra_css: - extra.css -# https://www.mkdocs.org/user-guide/configuration/#validation -validation: - omitted_files: warn - absolute_links: warn - unrecognized_links: warn - anchors: warn - markdown_extensions: - tables - admonition @@ -138,29 +133,28 @@ markdown_extensions: - pymdownx.caret - pymdownx.critic - pymdownx.mark - - pymdownx.superfences # Code examples live as complete, importable, tested files under `docs_src/` # and are included into pages with `--8<-- "docs_src//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 +164,13 @@ markdown_extensions: custom_checkbox: true - sane_lists # this means you can start a list from any number -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, so the former `gen-files`, +# `literate-nav`, `social` and `llms_txt` hook are handled by the standalone +# scripts under `scripts/docs/` invoked around the build (see build-docs.sh). 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..7a7c788df5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,16 +76,22 @@ 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 fixes relative/scoped cross-references for mkdocstrings-python, + # which the mkdocstrings config in mkdocs.yml relies on. + "zensical>=0.0.48", + # 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", ] codegen = ["datamodel-code-generator==0.57.0"] diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 5a61309acf..9e15dfe400 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,22 @@ 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 generators (scripts/docs/build_config.py) builds with +# Zensical, 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_config.py ]]; then + 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 python scripts/docs/llms_txt.py --site-dir site + else + uv run --frozen --no-sync mkdocs build --site-dir site + fi +} build_branch() { local branch="$1" worktree="$2" dest="$3" @@ -44,10 +63,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_config.py b/scripts/docs/build_config.py new file mode 100644 index 0000000000..2579b3dc25 --- /dev/null +++ b/scripts/docs/build_config.py @@ -0,0 +1,52 @@ +"""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 +:mod:`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 [--site-dir DIR] [--output FILE] +""" + +from __future__ import annotations + +import argparse +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 + + +def build_config(output: Path, site_dir: str | None = None) -> None: + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + + api_nav = gen_ref_pages.generate() + 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") + + if site_dir is not None: + config["site_dir"] = site_dir + + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site-dir", default=None, help="Override the build output directory.") + parser.add_argument("--output", default=str(ROOT / "mkdocs.gen.yml"), help="Where to write the generated config.") + args = parser.parse_args() + build_config(Path(args.output), args.site_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py new file mode 100644 index 0000000000..17e0f90d4e --- /dev/null +++ b/scripts/docs/gen_ref_pages.py @@ -0,0 +1,88 @@ +"""Generate the API reference pages and navigation. + +Zensical does not run MkDocs plugins, so the work that ``mkdocs-gen-files`` and +``mkdocs-literate-nav`` used to do at build time happens here as a plain +pre-build step: this module writes a mkdocstrings stub (``::: ``) for +every public module under ``docs/api/`` and returns the matching nested +navigation, which ``scripts/docs/build_config.py`` splices into the build config. + +Run as a script it just (re)generates ``docs/api/``; imported, :func:`generate` +also returns the nav so the config builder can consume it. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +# A MkDocs/Zensical nav is a list of entries, each either ``{title: url}`` for a +# page or ``{title: [children]}`` for a section (a bare ``url`` string attaches +# a section index page, courtesy of the ``navigation.indexes`` feature). +NavItem = "str | dict[str, NavList]" +NavList = "list[NavItem]" + +ROOT = Path(__file__).parent.parent.parent +API_DIR = ROOT / "docs" / "api" + + +class _Node: + """A module (``url``) and/or a package with child modules (``children``).""" + + def __init__(self) -> None: + self.url: str | None = None + self.children: dict[str, _Node] = {} + + def child(self, name: str) -> _Node: + return self.children.setdefault(name, _Node()) + + def to_nav(self, title: str) -> NavItem: + if not self.children: + assert self.url is not None + return {title: self.url} + items: NavList = [] + if self.url is not None: + items.append(self.url) + for name in sorted(self.children): + items.append(self.children[name].to_nav(name)) + return {title: items} + + +def generate() -> NavList: + """Write ``docs/api/**.md`` stubs and return the API-section navigation.""" + if API_DIR.exists(): + shutil.rmtree(API_DIR) + + src = ROOT / "src" + root = _Node() + + # `src/mcp-types` is a distribution directory, not an import package, so each + # package's dotted module path is taken relative to its own parent: deriving + # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`. + for package in (src / "mcp", src / "mcp-types" / "mcp_types"): + base = package.parent + for path in sorted(package.rglob("*.py")): + module_path = path.relative_to(base).with_suffix("") + doc_path = path.relative_to(base).with_suffix(".md") + + parts = tuple(module_path.parts) + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + full_doc_path = API_DIR / doc_path + full_doc_path.parent.mkdir(parents=True, exist_ok=True) + ident = ".".join(parts) + full_doc_path.write_text(f"::: {ident}\n", encoding="utf-8") + + node = root + for part in parts: + node = node.child(part) + node.url = f"api/{doc_path.as_posix()}" + + return [root.children[name].to_nav(name) for name in sorted(root.children)] + + +if __name__ == "__main__": + generate() diff --git a/scripts/docs/llms_txt.py b/scripts/docs/llms_txt.py new file mode 100644 index 0000000000..5a0673c405 --- /dev/null +++ b/scripts/docs/llms_txt.py @@ -0,0 +1,204 @@ +"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). + +Zensical has no equivalent of MkDocs' build hooks, so this runs as a standalone +post-build step over the source tree (``mkdocs.yml`` + ``docs/``) and writes +three kinds of artifact into the built ``site/``: + +- ``llms.txt``: a markdown index of the documentation, one link per page, + grouped by nav section. +- a ``.md`` rendition of every prose page next to its HTML (e.g. + ``servers/tools/index.md``), which is what the llms.txt links point at. +- ``llms-full.txt``: every prose page concatenated for single-fetch consumption. + +Page markdown is the source markdown with ``--8<--`` snippet includes resolved +(so the ``docs_src/`` code examples appear inline) and relative links rewritten +to absolute URLs. The API reference pages under ``api/`` are mkdocstrings stubs +with no prose source, so they are linked as rendered HTML from an Optional +section instead of being embedded. + +Usage: + python scripts/docs/llms_txt.py --site-dir site +""" + +from __future__ import annotations + +import argparse +import posixpath +import re +from pathlib import Path, PurePosixPath + +import yaml + +ROOT = Path(__file__).parent.parent.parent +DOCS = ROOT / "docs" + +# Pages with no markdown source, linked as HTML under "## Optional". +_OPTIONAL_PAGES = [ + ("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"), + ( + "api/mcp_types/index.md", + "mcp-types API reference", + "Auto-generated API reference for the mcp-types package (rendered HTML)", + ), +] + +_SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) +_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') + + +class _BuildError(Exception): + """A recoverable problem that should fail the docs build with a clear message.""" + + +def _dest_md_uri(src_uri: str) -> str: + """Map a source page (``servers/tools.md``) to its built rendition (``servers/tools/index.md``).""" + path = PurePosixPath(src_uri) + directory = path.parent if path.stem == "index" else path.parent / path.stem + return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md" + + +def _page_url(src_uri: str) -> str: + """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") + + +def _walk_nav(nav: list, prose: dict[str, str | None], sections: list[tuple[str, list[str]]]) -> list[str]: + """Split the nav into a flat list of top-level pages and titled sections. + + Populates ``prose`` (src_uri -> nav title, or ``None`` to fall back to the + page's H1) and ``sections`` ((title, [src_uri]) in nav order), and returns + the top-level page src_uris. API and section-index bare entries are skipped. + """ + top_level: list[str] = [] + for entry in nav: + title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) + if isinstance(value, list): + pages = _section_pages(value, prose) + if pages: + assert title is not None + sections.append((title, pages)) + elif value.endswith(".md") and not value.startswith("api/"): + prose[value] = title + top_level.append(value) + return top_level + + +def _section_pages(items: list, prose: dict[str, str | None]) -> list[str]: + pages: list[str] = [] + for entry in items: + title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) + if isinstance(value, list): + pages.extend(_section_pages(value, prose)) + elif value.endswith(".md") and not value.startswith("api/"): + prose[value] = title + pages.append(value) + return pages + + +def _resolve_snippets(markdown: str, src_uri: str) -> str: + def include(match: re.Match[str]) -> str: + indent, path = match["indent"], match["path"] + # Reject snippet paths that escape the repo root (mirrors the snippets + # extension's restrict_base_path). + resolved = (ROOT / path).resolve() + if not resolved.is_relative_to(ROOT.resolve()): + raise _BuildError(f"llms_txt: snippet path {path!r} in {src_uri} escapes the repo root") + try: + content = resolved.read_text(encoding="utf-8").rstrip("\n") + except OSError as exc: + raise _BuildError(f"llms_txt: cannot read snippet {path!r} in {src_uri}") from exc + if path.endswith(".py"): + content = f"# {path}\n{content}" + if indent: + content = "\n".join(indent + line if line else line for line in content.split("\n")) + return content + + resolved, substitutions = _SNIPPET_LINE.subn(include, markdown) + if substitutions != sum("--8<--" in line for line in markdown.splitlines()): + raise _BuildError(f"llms_txt: unresolved snippet include in {src_uri}") + return resolved + + +def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str: + src_dir = posixpath.dirname(src_uri) + + def rewrite(match: re.Match[str]) -> str: + opening, target, anchor, title, closing = match.groups() + if "://" in target: + return match.group(0) + linked = posixpath.normpath(posixpath.join(src_dir, target)) + if not (DOCS / linked).exists(): + raise _BuildError(f"llms_txt: cannot resolve link target {target!r} in {src_uri}") + # 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) + return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" + + return _MD_LINK.sub(rewrite, markdown) + + +def _title(src_uri: str, nav_title: str | None, body: str) -> str: + if nav_title is not None: + return nav_title + match = re.search(r"^\s*# (.+)$", body, flags=re.MULTILINE) + if match is None: + raise _BuildError(f"llms_txt: page {src_uri} has no nav title and no H1") + return match.group(1).strip() + + +def generate(site_dir: Path) -> None: + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + site_url = config["site_url"].rstrip("/") + "/" + + prose: dict[str, str | None] = {} + sections: list[tuple[str, list[str]]] = [] + top_level = _walk_nav(config["nav"], prose, sections) + ordered: list[tuple[str, list[str]]] = ([("Docs", top_level)] if top_level else []) + sections + + rendered: dict[str, str] = {} + for src_uri in prose: + markdown = (DOCS / src_uri).read_text(encoding="utf-8") + markdown = _resolve_snippets(markdown, src_uri) + rendered[src_uri] = _rewrite_links(markdown, src_uri, site_url, prose) + + index = [f"# {config['site_name']}", "", f"> {config['site_description']}", ""] + full: list[str] = [] + for section_title, pages in ordered: + index += [f"## {section_title}", ""] + for src_uri in pages: + markdown = rendered[src_uri] + md_uri = _dest_md_uri(src_uri) + (site_dir / md_uri).parent.mkdir(parents=True, exist_ok=True) + (site_dir / md_uri).write_text(markdown, encoding="utf-8") + + title = _title(src_uri, prose[src_uri], markdown) + index.append(f"- [{title}]({site_url}{md_uri})") + + body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown) + if not h1_found: + raise _BuildError(f"llms_txt: page {src_uri} does not start with an H1") + full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""] + index.append("") + + index += ["## Optional", ""] + for src_uri, title, description in _OPTIONAL_PAGES: + if not (DOCS / src_uri).exists(): + raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)") + index.append(f"- [{title}]({site_url}{_page_url(src_uri)}): {description}") + index.append("") + + (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8") + (site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site-dir", default=str(ROOT / "site"), help="The built site directory to write into.") + args = parser.parse_args() + try: + generate(Path(args.site_dir)) + except _BuildError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + main() diff --git a/scripts/serve-docs.sh b/scripts/serve-docs.sh new file mode 100755 index 0000000000..7d076b5a4d --- /dev/null +++ b/scripts/serve-docs.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# +# Serve the v2 documentation locally with live reload. +# +# Regenerates the API reference and the concrete Zensical config, then serves +# it. Re-run the script to pick up changes to `src/` (the API reference) or the +# nav; edits to prose pages under `docs/` are picked up by live reload. +# +# Usage: +# scripts/serve-docs.sh [-- ] +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +uv run --frozen python scripts/docs/build_config.py +exec uv run --frozen zensical serve -f mkdocs.gen.yml "$@" diff --git a/tests/docs_src/test_shape.py b/tests/docs_src/test_shape.py index 1636bd825e..b443bd36b2 100644 --- a/tests/docs_src/test_shape.py +++ b/tests/docs_src/test_shape.py @@ -138,7 +138,7 @@ def test_every_example_is_included_by_a_page() -> None: def test_every_included_path_exists() -> None: """Every `docs_src/` path a page includes exists on disk. - `mkdocs build --strict` also enforces this, but only when the docs are + `zensical build --strict` also enforces this, but only when the docs are built; this puts the same guarantee inside the ordinary `pytest` run. """ missing = sorted(filterfalse(_is_real_file, _referenced_examples())) diff --git a/uv.lock b/uv.lock index d574f1b089..81135527bf 100644 --- a/uv.lock +++ b/uv.lock @@ -91,29 +91,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, -] - -[[package]] -name = "backrefs" -version = "5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, - { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, - { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, - { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, -] - [[package]] name = "black" version = "26.3.1" @@ -158,34 +135,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] -[[package]] -name = "cairocffi" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, -] - -[[package]] -name = "cairosvg" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cairocffi" }, - { name = "cssselect2" }, - { name = "defusedxml" }, - { name = "pillow" }, - { name = "tinycss2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, -] - [[package]] name = "certifi" version = "2025.8.3" @@ -526,19 +475,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] -[[package]] -name = "cssselect2" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tinycss2" }, - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, -] - [[package]] name = "datamodel-code-generator" version = "0.57.0" @@ -560,12 +496,12 @@ wheels = [ ] [[package]] -name = "defusedxml" -version = "0.7.1" +name = "deepmerge" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, + { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, ] [[package]] @@ -641,15 +577,12 @@ wheels = [ ] [[package]] -name = "griffe" -version = "1.14.0" +name = "griffelib" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] [[package]] @@ -959,12 +892,10 @@ dev = [ { name = "trio" }, ] docs = [ - { name = "mkdocs" }, - { name = "mkdocs-gen-files" }, - { name = "mkdocs-glightbox" }, - { name = "mkdocs-literate-nav" }, - { name = "mkdocs-material", extra = ["imaging"] }, + { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, + { name = "pyyaml" }, + { name = "zensical" }, ] [package.metadata] @@ -1016,12 +947,10 @@ dev = [ { name = "trio", specifier = ">=0.26.2" }, ] docs = [ - { name = "mkdocs", specifier = ">=1.6.1,<2" }, - { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, - { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, - { name = "mkdocs-literate-nav", specifier = ">=0.6.1" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0,<10" }, - { name = "mkdocstrings-python", specifier = ">=2.0.1" }, + { name = "mkdocstrings", specifier = ">=1.0.4" }, + { name = "mkdocstrings-python", specifier = ">=2.0.5" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "zensical", specifier = ">=0.0.47" }, ] [[package]] @@ -1541,18 +1470,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, ] -[[package]] -name = "mkdocs-gen-files" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/35/f26349f7fa18414eb2e25d75a6fa9c7e3186c36e1d227c0b2d785a7bd5c4/mkdocs_gen_files-0.6.0.tar.gz", hash = "sha256:52022dc14dcc0451e05e54a8f5d5e7760351b6701eff816d1e9739577ec5635e", size = 8642, upload-time = "2025-11-23T12:13:22.124Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/ec/72417415563c60ae01b36f0d497f1f4c803972f447ef4fb7f7746d6e07db/mkdocs_gen_files-0.6.0-py3-none-any.whl", hash = "sha256:815af15f3e2dbfda379629c1b95c02c8e6f232edf2a901186ea3b204ab1135b2", size = 8182, upload-time = "2025-11-23T12:13:20.756Z" }, -] - [[package]] name = "mkdocs-get-deps" version = "0.2.0" @@ -1567,70 +1484,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] -[[package]] -name = "mkdocs-glightbox" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "selectolax" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/72/c03e9d8d2dbe098d7ce5d51309933a1d3aea268965ed097ab16f4b54de15/mkdocs_glightbox-0.5.1.tar.gz", hash = "sha256:7d78a5b045f2479f61b0bbb17742ba701755c56b013e70ac189c9d87a91e80bf", size = 480028, upload-time = "2025-09-04T13:10:29.679Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/cf/e9a0ce9da269746906fdc595c030f6df66793dad1487abd1699af2ba44f1/mkdocs_glightbox-0.5.1-py3-none-any.whl", hash = "sha256:f47af0daff164edf8d36e553338425be3aab6e34b987d9cbbc2ae7819a98cb01", size = 26431, upload-time = "2025-09-04T13:10:27.933Z" }, -] - -[[package]] -name = "mkdocs-literate-nav" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, -] - -[package.optional-dependencies] -imaging = [ - { name = "cairosvg" }, - { name = "pillow" }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, -] - [[package]] name = "mkdocstrings" -version = "0.30.0" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -1640,24 +1496,24 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/0a/7e4776217d4802009c8238c75c5345e23014a4706a8414a62c0498858183/mkdocstrings-0.30.0.tar.gz", hash = "sha256:5d8019b9c31ddacd780b6784ffcdd6f21c408f34c0bd1103b5351d609d5b4444", size = 106597, upload-time = "2025-07-22T23:48:45.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/b4/3c5eac68f31e124a55d255d318c7445840fa1be55e013f507556d6481913/mkdocstrings-0.30.0-py3-none-any.whl", hash = "sha256:ae9e4a0d8c1789697ac776f2e034e2ddd71054ae1cf2c2bb1433ccfd07c226f2", size = 36579, upload-time = "2025-07-22T23:48:44.152Z" }, + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, ] [[package]] name = "mkdocstrings-python" -version = "2.0.1" +version = "2.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, ] [[package]] @@ -1805,15 +1661,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, -] - [[package]] name = "pathspec" version = "1.0.4" @@ -2112,11 +1959,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2135,15 +1982,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "10.16.1" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]] @@ -2579,54 +2426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] -[[package]] -name = "selectolax" -version = "0.3.29" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/b9/b5a23e29d5e54c590eaad18bdbb1ced13b869b111e03d12ee0ae9eecf9b8/selectolax-0.3.29.tar.gz", hash = "sha256:28696fa4581765c705e15d05dfba464334f5f9bcb3eac9f25045f815aec6fbc1", size = 4691626, upload-time = "2025-04-30T15:17:37.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/8f/bf3d58ecc0e187806299324e2ad77646e837ff20400880f6fc0cbd14fb66/selectolax-0.3.29-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:85aeae54f055cf5451828a21fbfecac99b8b5c27ec29fd10725b631593a7c9a3", size = 3643657, upload-time = "2025-04-30T15:15:40.734Z" }, - { url = "https://files.pythonhosted.org/packages/de/b0/6d90a4d0eacb8253d88a9fcbcb8758b667900f45dcdb4a11c5fbd0d31599/selectolax-0.3.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ff48efe4364c8148a553a4105773a0accee9cc25e0f2a40ddac44d18a5a3000", size = 2089380, upload-time = "2025-04-30T15:15:42.928Z" }, - { url = "https://files.pythonhosted.org/packages/f4/21/394b51998ef99f13f98da063fc71b8edf7191bb30aca06bcbc8a55d5a9ad/selectolax-0.3.29-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25cfccfefc41361ab8a07f15a224524a4a8b77dfa7d253b34bbd397e45856734", size = 5505065, upload-time = "2025-04-30T15:15:44.986Z" }, - { url = "https://files.pythonhosted.org/packages/dd/57/e38775b672f910e80742cbf7c3def5c670c1b6f9b05e8587b2fa8dc044c3/selectolax-0.3.29-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5c3523ad5199a4fb9b95b6e24ff9222d3605023ca394b23f7dd910e7536daf", size = 5529205, upload-time = "2025-04-30T15:15:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0f/f6e3030107b486b6a4870f8471a675d435c4c34b8f9de3374652ed53004b/selectolax-0.3.29-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfb803d6bbe0ef3c8847cf5a01167cc428c0d9179946e1c994cc6178b5332d1a", size = 5146713, upload-time = "2025-04-30T15:15:49.332Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8d/b4fd119c216e8615ca6747f8f336643572178241921f33f5ffa4b074dc44/selectolax-0.3.29-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:db734ba4ef44fa3b57ad9374fd7ccfc7815c0ae5cfcbd5ee25fe8587092618d1", size = 5416352, upload-time = "2025-04-30T15:15:50.909Z" }, - { url = "https://files.pythonhosted.org/packages/d7/e7/94e694d14ae44bddc0d9b144647d5adbec0210d8e2c57d72ad9a133d9469/selectolax-0.3.29-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2bfe4327215a20af4197c5b7e3729a9552fb324bb57250dc7e7abfa0f848a463", size = 5140689, upload-time = "2025-04-30T15:15:52.477Z" }, - { url = "https://files.pythonhosted.org/packages/90/62/79ba965daa1f12e5477b2ec08b289f8289dfc705928b08923d9c4b60c867/selectolax-0.3.29-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0a98c3f3d8fffb175456cb06096bc78103ddf6a209bea6392e0e4ea4e25aca71", size = 5481428, upload-time = "2025-04-30T15:15:54.371Z" }, - { url = "https://files.pythonhosted.org/packages/82/3c/46c1f0b739add89d0ef720ad521afaaf31b07a39f781ef9e59c7b5ecef44/selectolax-0.3.29-cp310-cp310-win32.whl", hash = "sha256:394d356ea611a7853c13c910a57c1a80a8356f9c920aa8168b3f8aaa62e433d8", size = 1702100, upload-time = "2025-04-30T15:15:55.833Z" }, - { url = "https://files.pythonhosted.org/packages/75/62/03350ed454fe26aef5580df498d45ace9f26ca6af1640ae681a6af1f5cdf/selectolax-0.3.29-cp310-cp310-win_amd64.whl", hash = "sha256:edd2760699c60dde7d847aebd81f02035f7bddcd0ad3db8e73326dfc84a2dc8f", size = 1807811, upload-time = "2025-04-30T15:15:57.243Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5d/ca72f7adddae4b2b128394a7559739a6a12c156d29b55968cfcfe07fac4d/selectolax-0.3.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6a1cd0518fa7656ea1683c4b2d3b5a98306753f364da9f673517847e1680a3e", size = 3649215, upload-time = "2025-04-30T15:15:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/08/c6/ca984f90b12fb10790cc56c2670f1b5f09884ed2f2012a219094b38cbcb4/selectolax-0.3.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e5354d805dd76b4b38002f58e6ae2e7b429ac311bf3601992a6662d2bc86911", size = 2091848, upload-time = "2025-04-30T15:16:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/98/7f/c999ae6d9bfbaac3e8dea3dbb5ca6bdf61c220828e80a6c339e89f9db777/selectolax-0.3.29-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7073e3bcdc60ebdb5f8777c79b465471ec000ab556134da4e00f037d3321a2ec", size = 5638593, upload-time = "2025-04-30T15:16:03.594Z" }, - { url = "https://files.pythonhosted.org/packages/d6/32/ffd89376a888c24ecaf01fcffc5fe97b82ae03ab163158f51a559f1ebad5/selectolax-0.3.29-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47587db7cef411d22f8224cf2926aacdb326c4c838d386035229f16ccc2d8d26", size = 5668207, upload-time = "2025-04-30T15:16:05.564Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5c/2de0c7b8be75ad52d44706c67946181b972f27641ab4f6a1f27f46d2a603/selectolax-0.3.29-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:21de62b5093b1cb6c5d4cab0bef5f708b9ee1483b640d42be9d955becfcd287a", size = 5276654, upload-time = "2025-04-30T15:16:07.143Z" }, - { url = "https://files.pythonhosted.org/packages/29/29/152bb745b24072d3eecd3b395c756e74763111b9bbd265604f5b96b9a1aa/selectolax-0.3.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:af5cd03298cd75cb0fbf712d6ae4f8aca9c13a226d2821ca82f51cc9b33b032f", size = 5543731, upload-time = "2025-04-30T15:16:09.733Z" }, - { url = "https://files.pythonhosted.org/packages/04/1d/df65baaf16ece393f9f1a7c55f015510634adbb163ce72adcafaddf5cf9c/selectolax-0.3.29-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3f58dca53d2d3dc18dfd2cb9210a5625f32598db24e3f857f5be58f21a8f3b88", size = 5275005, upload-time = "2025-04-30T15:16:11.958Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/e56fd6f9b3087947b812f3862df3265bf5e21396d9673d076e999b1086cf/selectolax-0.3.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a6d8e02c6b9ba951d7b5a5dd2788a1d4bbdedc89782a4de165f1a87c4168ac", size = 5617441, upload-time = "2025-04-30T15:16:14.15Z" }, - { url = "https://files.pythonhosted.org/packages/63/d6/243049029bfc937b9f02faf4a4494e693575046414a475bf28ed9632b768/selectolax-0.3.29-cp311-cp311-win32.whl", hash = "sha256:912a1fc03157ebd066d8f59ae9ca2412ef95c7101a51590327c23071b02c97c7", size = 1701370, upload-time = "2025-04-30T15:16:16.339Z" }, - { url = "https://files.pythonhosted.org/packages/c9/7f/baba8c5ce941c8cbd2dfb0c9f2253ba2d8c2d5d0fddda4f5a87eceb2484f/selectolax-0.3.29-cp311-cp311-win_amd64.whl", hash = "sha256:a3d44a295416b79815d2858ed4ccb71bf3b63087483a5d3705daa837c9dcf44d", size = 1808251, upload-time = "2025-04-30T15:16:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/ca4332eecc19124782f6f0d7cb28c331da2e9d9cf25287ba2b3b6a00cea1/selectolax-0.3.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6d3f373efd1db18ac9b2222de2668aaa366a1f0b560241eab128f3ca68e8add1", size = 3656166, upload-time = "2025-04-30T15:16:19.907Z" }, - { url = "https://files.pythonhosted.org/packages/b8/46/2dcae03a94f80f3e0d339c149de8110b5abe1230668b015fd338d9e71a27/selectolax-0.3.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:97b9971bb37b54ef4440134f22792d15c9ee12d890a526a7fe0b376502240143", size = 2095991, upload-time = "2025-04-30T15:16:21.654Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/95f15396e5f30898227d84a7ec6a39d9a9b34005f0e9f8f38e7fee21ab66/selectolax-0.3.29-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd99ff0f5a6c017c471635d4ee45b61d25f24689331e407147b2cf5e36892480", size = 5844493, upload-time = "2025-04-30T15:16:23.268Z" }, - { url = "https://files.pythonhosted.org/packages/36/25/64c60da9aec81f2992355b0a3ce00ea1ed99e6f5499868016d6972bd4948/selectolax-0.3.29-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8377c317bf1d5fd6ccc56dfb5a0928bbcbea3e800b7af54761cfbbb99dc94cb9", size = 5881062, upload-time = "2025-04-30T15:16:24.891Z" }, - { url = "https://files.pythonhosted.org/packages/b6/81/94105217f91f7c6a98ac3164210cba0c6aa8da91cb85405292a6d70e39c3/selectolax-0.3.29-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5388c56456272b2c241fc1906db9cc993984cafdad936cb5e061e3af0c44144e", size = 5470368, upload-time = "2025-04-30T15:16:26.457Z" }, - { url = "https://files.pythonhosted.org/packages/51/6e/40bc259f13e5d3dd0bb8ddd1d55ef099244db2568ffb82fd9d489984d61a/selectolax-0.3.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9e4690894f406863e25ba49da27e1a6fda9bfc21b0b315c399d3093be080e81", size = 5693476, upload-time = "2025-04-30T15:16:28.386Z" }, - { url = "https://files.pythonhosted.org/packages/58/bd/2668ee1d5471ad88daf83ca484515ba46774fc9c951d6c4c0beffea89952/selectolax-0.3.29-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:deeab93386b6c9a75052515f5b9e7e3dd623c585871c0c2b3126970ff902603b", size = 5449747, upload-time = "2025-04-30T15:16:30.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b5/1c61839ae5af70a8291c643982a99f051b543df90b220b98db1b26bd4899/selectolax-0.3.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6abdd8357f1c105c1add01a9f0373511fa832548b2e2778b00a8ba2a4508d6ed", size = 5786843, upload-time = "2025-04-30T15:16:32.231Z" }, - { url = "https://files.pythonhosted.org/packages/67/08/ca42c100ab90168c123e6b521e38cb7618b697a693fdb77e42dabb0670fd/selectolax-0.3.29-cp312-cp312-win32.whl", hash = "sha256:9c969626b2295702076f50aac91e44c3bba639fa2e1a612bf6ae254bf29b4d57", size = 1697859, upload-time = "2025-04-30T15:16:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/5c/22/9524af51d950cc718bd4406f3bed05acbfcb321a4a308ec85b96ccdaa1ef/selectolax-0.3.29-cp312-cp312-win_amd64.whl", hash = "sha256:e7f4cc1b7ce9691559decfd5db7cc500e71a9f6ccfe76c054f284c184a1d1dc9", size = 1804145, upload-time = "2025-04-30T15:16:35.12Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a7/083a00aa9cb6bef0317baba4269841c366652558d77189275bed2da6aa81/selectolax-0.3.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e3112f05a34bf36d36ecc51520b1d98c4667b54a3f123dffef5072273e89a360", size = 3651407, upload-time = "2025-04-30T15:16:37.282Z" }, - { url = "https://files.pythonhosted.org/packages/7e/cd/6c89ac27961ef5f5e9b40eda0d0653b9c95c93485fb8a554bf093eac1c77/selectolax-0.3.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38462ae369897f71da287f1282079c11f1b878b99a4d1d509d1116ce05226d88", size = 2092649, upload-time = "2025-04-30T15:16:38.817Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/82710124b7b52613fdb9d5c14494a41785eb83e1c93ec7e1d1814c2ce292/selectolax-0.3.29-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdd1e63735f2fb8485fb6b9f4fe30d6c030930f438f46a4a62bd9886ab3c7fd9", size = 5821738, upload-time = "2025-04-30T15:16:40.747Z" }, - { url = "https://files.pythonhosted.org/packages/8b/08/8ceb3eb7fee9743026a4481fccb771f257c82b2c853a1a30271902234eab/selectolax-0.3.29-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea52e0c128e8e89f98ab0ccaabbc853677de5730729a3351da595976131b66e0", size = 5856069, upload-time = "2025-04-30T15:16:42.496Z" }, - { url = "https://files.pythonhosted.org/packages/47/6c/ec2b7aff0f6202e4157415d76bd588108cc518374bf53afa81c122691780/selectolax-0.3.29-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0933659b4250b91317ccd78167e6804389cdaf7ed86c5d034b058a550d23110f", size = 5443255, upload-time = "2025-04-30T15:16:44.083Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/d5fea46ff191d02c2380a779b119ea6799751b79fcddb2bb230b21b38fc5/selectolax-0.3.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0c9005e9089a6b0c6fb6a9f691ddbbb10a3a23ebeff54393980340f3dbcdb99", size = 5637529, upload-time = "2025-04-30T15:16:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/7f876a515f5af31f7b948cf10951be896fe6deeff2b9b713640c8ec82fd3/selectolax-0.3.29-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac940963c52f13cdf5d7266a979744949b660d367ce669efa073b557f6e09a18", size = 5379121, upload-time = "2025-04-30T15:16:47.909Z" }, - { url = "https://files.pythonhosted.org/packages/57/cb/7dc739a484b1a17ccf92a23dfe558ae615c232bd81e78a72049c25d1ff66/selectolax-0.3.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:484274f73839f9a143f4c13ce1b0a0123b5d64be22f967a1dc202a9a78687d67", size = 5727944, upload-time = "2025-04-30T15:16:49.52Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/95da4d2919d99a6090327390b84bc5440133196351e5e04c24cccda06cbb/selectolax-0.3.29-cp313-cp313-win32.whl", hash = "sha256:29e71fbd58b90d2920ef91a940680cb5331710fe397925ce9d10c3f2f086bf27", size = 1697529, upload-time = "2025-04-30T15:16:51.123Z" }, - { url = "https://files.pythonhosted.org/packages/0e/17/5a3951da22a4ad8f959088ddc370c68b28dad03190d91fcd137a52410fb9/selectolax-0.3.29-cp313-cp313-win_amd64.whl", hash = "sha256:e13befacff5f78102aa11465055ecb6d4b35f89663e36f271f2b506bcab14112", size = 1803334, upload-time = "2025-04-30T15:16:53.775Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" @@ -2696,55 +2495,58 @@ dependencies = [ { name = "pydantic" }, ] -[[package]] -name = "tinycss2" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, -] - [[package]] name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -2868,15 +2670,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - [[package]] name = "wrapt" version = "1.17.3" @@ -2946,6 +2739,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "zensical" +version = "0.0.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/99/53171a5211359a95b0d6caad9a1c960b002a4bf82eacdd3cdbde225db25f/zensical-0.0.47.tar.gz", hash = "sha256:324f783b22cd0deed0d0f3b69e28d5380b47238a1ef0f25913b88014af2bade2", size = 3976587, upload-time = "2026-07-05T15:51:25.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/7c/5848aad6a5b566f5a3cebc77ab787793daec622b79295de243da7b8efae6/zensical-0.0.47-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4162fb8b62f38e6d9b75688c1fb87e18a1cffef5eee51d2c18b79334b548e973", size = 12789282, upload-time = "2026-07-05T15:50:45.118Z" }, + { url = "https://files.pythonhosted.org/packages/ce/08/c2a056ae22eba7063985cc89554ab8e54efb11af8e26f6d606e5bd300ae8/zensical-0.0.47-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:cdc2d84f38da809a28402eda5f2b6dbb150e14427f296c5b521e0dfc6a2e8a39", size = 12661193, upload-time = "2026-07-05T15:50:49.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2b/30dc3262e99bc72e9a6529487fbfca58cf4f81cebfac59ef8e8d757d90eb/zensical-0.0.47-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97ed2b21aba5f788fc39d1597d00938d602b4d2724ed599a1dab7548fe4f0025", size = 13044347, upload-time = "2026-07-05T15:50:52.429Z" }, + { url = "https://files.pythonhosted.org/packages/4d/80/296ff21b686cda47c4a3f94c35bf62472291501c34e4f06dfd107cb237da/zensical-0.0.47-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f6b2de477c45284201e92301f997415f45f78493bd20f479ea9a1c86bbbbcca", size = 13006494, upload-time = "2026-07-05T15:50:55.376Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/5aeba02363db89c625cd41c76d2a6e40b07d6a75d869d970ace7accd2bfa/zensical-0.0.47-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ee29ff819372eaab02ca0f14ac82e804332d898c57c56ffd4d89674f3e5ff71", size = 13379253, upload-time = "2026-07-05T15:50:58.743Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dc/e72a1449544aeffa3dbe878c6340bcec98ba4eebabe7a592414064b0ae8d/zensical-0.0.47-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd94937c48a2e42b5b65b32c5075849937f23cebccaf250d249efa27266e0be", size = 13103356, upload-time = "2026-07-05T15:51:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/7fb956915cc59ee9f2ddf36ec5b16e8e858823217611e1d766816a661651/zensical-0.0.47-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4702605b991bece11494a9bb318d5ba7229f00e5adcc9e6010acd58e9719686b", size = 13220897, upload-time = "2026-07-05T15:51:05.919Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/944954d60bf1d61de2a7fa2732d0fb2d5deee64567db6088cdd2b5f76973/zensical-0.0.47-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:77f08ffcc3da9ca2f972330e501927aa7e8e445bfa7b758107edc645acda266e", size = 13280476, upload-time = "2026-07-05T15:51:09.352Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/4aece9b99eb5809b04fc6577fc50e877c998917293dfd58b5b229616b92b/zensical-0.0.47-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:319cf370ecc6d87da69c935c5acd6e1959dd48954766e9f954319bdb0bec5d36", size = 13439392, upload-time = "2026-07-05T15:51:12.564Z" }, + { url = "https://files.pythonhosted.org/packages/41/10/5cbffe680605b4fa7c0d6326f07a6adc86854f8b9d61bb1b29bb482d40eb/zensical-0.0.47-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:59656bf604a8b03eede4ce1a847640bab1129ab86dec2e39e5bd4b808b1802d2", size = 13386534, upload-time = "2026-07-05T15:51:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/d5/06/79c7726b0388da40a96cc01113d872d639da1d8a5f96b5265e46e8472ceb/zensical-0.0.47-cp310-abi3-win32.whl", hash = "sha256:81a13a8bacadedada4847eed4aaa4a3f4ef0e5b78dbb2a2022bc6fb7e7dc9464", size = 12356060, upload-time = "2026-07-05T15:51:19.167Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7a/7daaeadfdf7dc2941c8b931f9c6c83396d1da45634cec1a28f8d85a57708/zensical-0.0.47-cp310-abi3-win_amd64.whl", hash = "sha256:944a309be69b11daa8bba46c61fb74f32a98b637f3e7c11135e7cc2a5eccbe32", size = 12601932, upload-time = "2026-07-05T15:51:22.485Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From 4793412ef714d8d4f01bf96882aa6ba8bdf85da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 11:50:19 +0000 Subject: [PATCH 02/30] docs: fix serve-docs.sh usage text The wrapper forwards `"$@"` straight to `zensical serve`, so extra args are passed directly; the `--` in the usage example would be forwarded literally. Show args as direct forwarding instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CTkPwxyKkLTnqZdeiCMCzK --- scripts/serve-docs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/serve-docs.sh b/scripts/serve-docs.sh index 7d076b5a4d..1418719bcd 100755 --- a/scripts/serve-docs.sh +++ b/scripts/serve-docs.sh @@ -7,7 +7,7 @@ # nav; edits to prose pages under `docs/` are picked up by live reload. # # Usage: -# scripts/serve-docs.sh [-- ] +# scripts/serve-docs.sh [...] # set -euo pipefail From 9e87ecbdd5f0e67b27340ad3da2eb5d38646c06f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:08 +0000 Subject: [PATCH 03/30] docs: single-source the Zensical build recipe in scripts/docs/build.sh The three-step build (materialise config, zensical build --strict, generate llms.txt) was copy-pasted in shared.yml, docs-preview.yml, and build-docs.sh. Move it into scripts/docs/build.sh and call that everywhere. docs-preview.yml now picks the toolchain from the checkout: pull_request_target runs the workflow file from the base branch, so hardcoding the Zensical steps would break previews for older heads and v1.x-targeted PRs (and previews of this branch before it merges). --- .github/workflows/shared.yml | 5 ----- scripts/build-docs.sh | 18 ++++++++---------- scripts/docs/build.sh | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) create mode 100755 scripts/docs/build.sh diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index b339b540fc..f7da7174bf 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -170,10 +170,5 @@ jobs: enable-cache: true version: 0.9.5 - - name: Install dependencies - run: uv sync --frozen --all-extras --python 3.10 - - # Zensical runs no MkDocs plugins/hooks: generate the API reference and - # the concrete config, build strictly, then generate llms.txt. - name: Build the docs in strict mode run: bash scripts/docs/build.sh diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 9e15dfe400..8286786091 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -36,18 +36,17 @@ trap cleanup EXIT # 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 generators (scripts/docs/build_config.py) builds with -# Zensical, 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 +# 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_config.py ]]; then - 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 python scripts/docs/llms_txt.py --site-dir site + if [[ -f scripts/docs/build.sh ]]; then + bash scripts/docs/build.sh else - uv run --frozen --no-sync mkdocs build --site-dir site + uv sync --frozen --group docs + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site fi } @@ -62,7 +61,6 @@ build_branch() { ( cd "$worktree" - uv sync --frozen --group docs rm -rf site build_site mkdir -p "$dest" diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh new file mode 100755 index 0000000000..9a296a84e8 --- /dev/null +++ b/scripts/docs/build.sh @@ -0,0 +1,26 @@ +#!/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 +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 python scripts/docs/llms_txt.py --site-dir site From bb164d14ac6b07623ddafb0de898e5b3fc15271f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:08 +0000 Subject: [PATCH 04/30] docs: fail the docs build when the nav references a missing page Zensical ships a nav entry for a nonexistent page as a broken link with no diagnostic, even under --strict; MkDocs strict aborted the build. Validate the materialised nav in build_config.py so the guarantee survives the migration, and reject an empty generated API nav while at it. --- scripts/docs/build_config.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py index 2579b3dc25..20654f030f 100644 --- a/scripts/docs/build_config.py +++ b/scripts/docs/build_config.py @@ -23,10 +23,30 @@ ROOT = Path(__file__).parent.parent.parent +def _missing_nav_pages(nav: list, docs_dir: Path) -> list[str]: + """Collect nav page references that don't exist under the docs dir. + + Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken + link without any diagnostic, even under --strict — MkDocs aborted the + build. Validating here keeps that guarantee: the concrete config never + leaves this script referencing a page that isn't there. + """ + missing: list[str] = [] + for entry in nav: + value = next(iter(entry.values())) if isinstance(entry, dict) else entry + if isinstance(value, list): + missing.extend(_missing_nav_pages(value, docs_dir)) + elif not (docs_dir / value).is_file(): + missing.append(value) + return missing + + def build_config(output: Path, site_dir: str | None = None) -> 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 @@ -34,6 +54,9 @@ def build_config(output: Path, site_dir: str | None = None) -> None: else: raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + if missing := _missing_nav_pages(config["nav"], ROOT / "docs"): + raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") + if site_dir is not None: config["site_dir"] = site_dir From 4b136821633e3c7659d4b0cba65c285d99d7842a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:08 +0000 Subject: [PATCH 05/30] docs: correct stale comments and the nav type alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The social plugin is not handled by the scripts under scripts/docs/ — it was dropped (Zensical has no social-card support; the cards were gated on an env var no workflow set). The NavItem alias excluded the {title: url} page shape it mostly holds. --- .gitignore | 2 +- mkdocs.yml | 10 ++++++---- scripts/docs/gen_ref_pages.py | 7 +++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 8bb91f6e47..2e788e71d8 100644 --- a/.gitignore +++ b/.gitignore @@ -144,7 +144,7 @@ venv.bak/ # documentation /site /.worktrees/ -# Generated at build time by docs/hooks/ (the API reference tree and the +# Generated at build time by scripts/docs/ (the API reference tree and the # concrete Zensical config spliced from mkdocs.yml). /docs/api/ /mkdocs.gen.yml diff --git a/mkdocs.yml b/mkdocs.yml index 57c77a1e72..451b3bea29 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,7 +11,7 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ # The "API Reference" entry is a placeholder: `scripts/docs/gen_ref_pages.py` # generates the `docs/api/` tree and `scripts/docs/build_config.py` splices the -# real nested nav in before the build. See `scripts/build-docs.sh`. +# real nested nav in before the build. See `scripts/docs/build.sh`. nav: - MCP Python SDK: index.md - "What's new in v2": whats-new.md @@ -165,9 +165,11 @@ markdown_extensions: - sane_lists # this means you can start a list from any number # Zensical natively re-implements `search`, `glightbox` and `mkdocstrings`; it -# does not run arbitrary MkDocs plugins or hooks, so the former `gen-files`, -# `literate-nav`, `social` and `llms_txt` hook are handled by the standalone -# scripts under `scripts/docs/` invoked around the build (see build-docs.sh). +# 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 - glightbox diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 17e0f90d4e..662a1eafbf 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -18,8 +18,7 @@ # A MkDocs/Zensical nav is a list of entries, each either ``{title: url}`` for a # page or ``{title: [children]}`` for a section (a bare ``url`` string attaches # a section index page, courtesy of the ``navigation.indexes`` feature). -NavItem = "str | dict[str, NavList]" -NavList = "list[NavItem]" +NavItem = "str | dict[str, str | list[NavItem]]" ROOT = Path(__file__).parent.parent.parent API_DIR = ROOT / "docs" / "api" @@ -39,7 +38,7 @@ def to_nav(self, title: str) -> NavItem: if not self.children: assert self.url is not None return {title: self.url} - items: NavList = [] + items: list[NavItem] = [] if self.url is not None: items.append(self.url) for name in sorted(self.children): @@ -47,7 +46,7 @@ def to_nav(self, title: str) -> NavItem: return {title: items} -def generate() -> NavList: +def generate() -> list[NavItem]: """Write ``docs/api/**.md`` stubs and return the API-section navigation.""" if API_DIR.exists(): shutil.rmtree(API_DIR) From 13b42dc9dacb2bb58cb5d5294789b618eed2d877 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:01:37 +0000 Subject: [PATCH 06/30] docs: relock for zensical 0.0.48 --- uv.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/uv.lock b/uv.lock index 81135527bf..5dd923d104 100644 --- a/uv.lock +++ b/uv.lock @@ -950,7 +950,7 @@ docs = [ { name = "mkdocstrings", specifier = ">=1.0.4" }, { name = "mkdocstrings-python", specifier = ">=2.0.5" }, { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "zensical", specifier = ">=0.0.47" }, + { name = "zensical", specifier = ">=0.0.48" }, ] [[package]] @@ -2741,7 +2741,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.47" +version = "0.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -2753,20 +2753,20 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/99/53171a5211359a95b0d6caad9a1c960b002a4bf82eacdd3cdbde225db25f/zensical-0.0.47.tar.gz", hash = "sha256:324f783b22cd0deed0d0f3b69e28d5380b47238a1ef0f25913b88014af2bade2", size = 3976587, upload-time = "2026-07-05T15:51:25.488Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/7c/5848aad6a5b566f5a3cebc77ab787793daec622b79295de243da7b8efae6/zensical-0.0.47-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4162fb8b62f38e6d9b75688c1fb87e18a1cffef5eee51d2c18b79334b548e973", size = 12789282, upload-time = "2026-07-05T15:50:45.118Z" }, - { url = "https://files.pythonhosted.org/packages/ce/08/c2a056ae22eba7063985cc89554ab8e54efb11af8e26f6d606e5bd300ae8/zensical-0.0.47-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:cdc2d84f38da809a28402eda5f2b6dbb150e14427f296c5b521e0dfc6a2e8a39", size = 12661193, upload-time = "2026-07-05T15:50:49.138Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2b/30dc3262e99bc72e9a6529487fbfca58cf4f81cebfac59ef8e8d757d90eb/zensical-0.0.47-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97ed2b21aba5f788fc39d1597d00938d602b4d2724ed599a1dab7548fe4f0025", size = 13044347, upload-time = "2026-07-05T15:50:52.429Z" }, - { url = "https://files.pythonhosted.org/packages/4d/80/296ff21b686cda47c4a3f94c35bf62472291501c34e4f06dfd107cb237da/zensical-0.0.47-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f6b2de477c45284201e92301f997415f45f78493bd20f479ea9a1c86bbbbcca", size = 13006494, upload-time = "2026-07-05T15:50:55.376Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4e/5aeba02363db89c625cd41c76d2a6e40b07d6a75d869d970ace7accd2bfa/zensical-0.0.47-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ee29ff819372eaab02ca0f14ac82e804332d898c57c56ffd4d89674f3e5ff71", size = 13379253, upload-time = "2026-07-05T15:50:58.743Z" }, - { url = "https://files.pythonhosted.org/packages/fd/dc/e72a1449544aeffa3dbe878c6340bcec98ba4eebabe7a592414064b0ae8d/zensical-0.0.47-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd94937c48a2e42b5b65b32c5075849937f23cebccaf250d249efa27266e0be", size = 13103356, upload-time = "2026-07-05T15:51:02.247Z" }, - { url = "https://files.pythonhosted.org/packages/48/eb/7fb956915cc59ee9f2ddf36ec5b16e8e858823217611e1d766816a661651/zensical-0.0.47-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4702605b991bece11494a9bb318d5ba7229f00e5adcc9e6010acd58e9719686b", size = 13220897, upload-time = "2026-07-05T15:51:05.919Z" }, - { url = "https://files.pythonhosted.org/packages/57/6b/944954d60bf1d61de2a7fa2732d0fb2d5deee64567db6088cdd2b5f76973/zensical-0.0.47-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:77f08ffcc3da9ca2f972330e501927aa7e8e445bfa7b758107edc645acda266e", size = 13280476, upload-time = "2026-07-05T15:51:09.352Z" }, - { url = "https://files.pythonhosted.org/packages/ce/80/4aece9b99eb5809b04fc6577fc50e877c998917293dfd58b5b229616b92b/zensical-0.0.47-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:319cf370ecc6d87da69c935c5acd6e1959dd48954766e9f954319bdb0bec5d36", size = 13439392, upload-time = "2026-07-05T15:51:12.564Z" }, - { url = "https://files.pythonhosted.org/packages/41/10/5cbffe680605b4fa7c0d6326f07a6adc86854f8b9d61bb1b29bb482d40eb/zensical-0.0.47-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:59656bf604a8b03eede4ce1a847640bab1129ab86dec2e39e5bd4b808b1802d2", size = 13386534, upload-time = "2026-07-05T15:51:15.892Z" }, - { url = "https://files.pythonhosted.org/packages/d5/06/79c7726b0388da40a96cc01113d872d639da1d8a5f96b5265e46e8472ceb/zensical-0.0.47-cp310-abi3-win32.whl", hash = "sha256:81a13a8bacadedada4847eed4aaa4a3f4ef0e5b78dbb2a2022bc6fb7e7dc9464", size = 12356060, upload-time = "2026-07-05T15:51:19.167Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7a/7daaeadfdf7dc2941c8b931f9c6c83396d1da45634cec1a28f8d85a57708/zensical-0.0.47-cp310-abi3-win_amd64.whl", hash = "sha256:944a309be69b11daa8bba46c61fb74f32a98b637f3e7c11135e7cc2a5eccbe32", size = 12601932, upload-time = "2026-07-05T15:51:22.485Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/ad/ed2e7f25470a057890193e193e69c487601b65c8ffe9bec0705586b9a6e1/zensical-0.0.48.tar.gz", hash = "sha256:be4c9bc5d561fa2d1a99a0ff63b3c55f9e2189bc0370de5c34c0f97b4aa1f004", size = 3969690, upload-time = "2026-07-09T10:23:30.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/9f/a65602f189a0906e2ce5d875ed208fc081f3566cfa0b9a90fe2b1a079255/zensical-0.0.48-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:52b1f30bd497d4e903a3d05fe445ab361981f79ae6741200a5f70f71b90c132a", size = 12810589, upload-time = "2026-07-09T10:22:57.687Z" }, + { url = "https://files.pythonhosted.org/packages/6f/25/ea8cd6e71a80b347223ace797ee8bf2ab6ef168ccfc195a8fa2e86a86f44/zensical-0.0.48-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ee5afae826612c91080092ab00d784a814c649a2cde1f483385d40a455eb05f5", size = 12682432, upload-time = "2026-07-09T10:23:00.543Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/bc56594b1d4dbf79f174dadad4d8f6d835750145e54878fe62305146a6c0/zensical-0.0.48-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:528519b86efce523b42065da048b29c1e8f70788af79d6fc3d595923e660614d", size = 13080816, upload-time = "2026-07-09T10:23:03.402Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/c507edfd556b8bd5edb1715ff5c72465f1d44843fd3f6c59f7a838a5381f/zensical-0.0.48-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93ce573c8d366941ed282a0fd78a8c6caa22ec524b85a1b81a050412089a8764", size = 13021162, upload-time = "2026-07-09T10:23:06.266Z" }, + { url = "https://files.pythonhosted.org/packages/df/71/b3f0ba340feede39b064f98170b6db95889ca27fba691815970a90d3d0ec/zensical-0.0.48-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc49f7f0b608cd04ef5f61f977ecb4d9720f36dd069ce29b8cadfb0798d5832c", size = 13402160, upload-time = "2026-07-09T10:23:08.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/be/295605f1a54caccaf37bc8a573237885f95694f32152cf41716dc4db712d/zensical-0.0.48-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4329b6bd5a95196b789b40b076e2bf9d699c38af07e98d417424c99cf06205e", size = 13112552, upload-time = "2026-07-09T10:23:11.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/b227860ef0b693194e89b0d26686d8e87d013746f7681fbbbf330089ea39/zensical-0.0.48-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8b381ae5cd41f2d1a210a1a9f65388f91072205aa165508edfcb53c7c0ecb308", size = 13258229, upload-time = "2026-07-09T10:23:14.352Z" }, + { url = "https://files.pythonhosted.org/packages/b8/75/02b25e0a0c2c493a6129c60d8bebc165c5a8b1123617dc32ec5a94777f80/zensical-0.0.48-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d62075c2470fa4a649fc908ba7dc6190cce55cd670ecc4cbc3c216b9f60aca3f", size = 13295558, upload-time = "2026-07-09T10:23:16.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/47/1a997495965bc54287d4285801304df8e1e55e85625ab47e93eeae3ac60a/zensical-0.0.48-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bc87070a96b6a5c9cf6a65a12f08057e2cfc3865e5b06624f0d69d5692be1843", size = 13457116, upload-time = "2026-07-09T10:23:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/3f/45/c3368e3914fc7718f7db547e86395138a4fef12f64b225d68c4f1ede2544/zensical-0.0.48-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8469781402ac19afbd7bfe002a9adc6b96e4176999ad77e9c83b3298156b5a01", size = 13395887, upload-time = "2026-07-09T10:23:22.229Z" }, + { url = "https://files.pythonhosted.org/packages/79/e7/e7eaa50bcc090775d2a5d7d9633386e91b265edcb57674901f5d40383cf8/zensical-0.0.48-cp310-abi3-win32.whl", hash = "sha256:9ce1218b77cc2f35d190b500e4dbfc68dff8aaeabeb23c8adb90698efcbd3a9c", size = 12362625, upload-time = "2026-07-09T10:23:24.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/ce26b587f17c2f2af28e76c91a5adbf32c8eddd9d452ade4ed9fe2553ca0/zensical-0.0.48-cp310-abi3-win_amd64.whl", hash = "sha256:9d8877fb836178fadd5f5f1c5ee9d818a967d8713f414a7b1af015a846ea38e3", size = 12592263, upload-time = "2026-07-09T10:23:27.339Z" }, ] [[package]] From 3786d39d8413c4792d8dee2463f30ef2f78397df Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:01:38 +0000 Subject: [PATCH 07/30] docs: fix the PERF401 lint error in gen_ref_pages.py --- scripts/docs/gen_ref_pages.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 662a1eafbf..50d89caa30 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -41,8 +41,7 @@ def to_nav(self, title: str) -> NavItem: items: list[NavItem] = [] if self.url is not None: items.append(self.url) - for name in sorted(self.children): - items.append(self.children[name].to_nav(name)) + items.extend(self.children[name].to_nav(name) for name in sorted(self.children)) return {title: items} From 6ef1f843fd6d58552e444782c0c88faef725c8b1 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:01:38 +0000 Subject: [PATCH 08/30] docs: pin the API index rendering for cross-package re-exports mkdocstrings renders a member re-exported across a package boundary as a full duplicate of its canonical documentation whenever the target package happens to be loaded already, and silently omits it when it isn't - which of the two the api/mcp/ index got depended on page rendering order (255 headings under Zensical vs 123 under MkDocs, from the same stub). Pin the semantics in the generated stub instead: every export with a canonical page elsewhere under the API reference becomes a compact link to it, and only exports documented nowhere else (re-exports from private modules) keep their full body via an explicit members list. Packages whose re-exports never cross a package boundary (mcp_types) are deterministic already and keep the plain stub. griffelib moves from a transitive to a declared docs dependency since the generator now imports it directly. --- pyproject.toml | 1 + scripts/docs/gen_ref_pages.py | 99 ++++++++++++++++++++++++++++++++--- uv.lock | 2 + 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a7c788df5..930fda1abf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ docs = [ "mkdocstrings-python>=2.0.5", # scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly. "pyyaml>=6.0.2", + "griffelib>=2.1.0", ] codegen = ["datamodel-code-generator==0.57.0"] diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 50d89caa30..64dad0ecdb 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -15,6 +15,8 @@ import shutil from pathlib import Path +import griffe + # A MkDocs/Zensical nav is a list of entries, each either ``{title: url}`` for a # page or ``{title: [children]}`` for a section (a bare ``url`` string attaches # a section index page, courtesy of the ``navigation.indexes`` feature). @@ -23,6 +25,19 @@ ROOT = Path(__file__).parent.parent.parent API_DIR = ROOT / "docs" / "api" +# `src/mcp-types` is a distribution directory, not an import package, so each +# package's dotted module path is taken relative to its own parent: deriving +# it from `src/` would emit the unimportable `mcp-types.mcp_types.*`. +PACKAGES = (ROOT / "src" / "mcp", ROOT / "src" / "mcp-types" / "mcp_types") + +_KIND_SECTIONS = { + griffe.Kind.MODULE: "Modules", + griffe.Kind.CLASS: "Classes", + griffe.Kind.FUNCTION: "Functions", + griffe.Kind.ATTRIBUTE: "Attributes", + griffe.Kind.TYPE_ALIAS: "Type aliases", +} + class _Node: """A module (``url``) and/or a package with child modules (``children``).""" @@ -45,18 +60,71 @@ def to_nav(self, title: str) -> NavItem: return {title: items} +def _compact_index(package: griffe.Module, documented: set[str]) -> str | None: + """Build a compact index body for a package that re-exports another package's API. + + mkdocstrings renders a member re-exported across a package boundary + (``from other_package import y`` + ``__all__``) as a full duplicate of its + canonical documentation whenever the other package happens to be loaded + already, and silently omits it when it isn't — which of the two a package + index gets depends on page rendering order. Same-package re-exports + (``mcp_types`` re-exporting its private ``._types`` module) always resolve + and are unaffected, so such packages keep the plain ``::: package`` stub + (return ``None``) and their index remains the full, canonical rendering. + + For an affected package, pin the semantics instead of inheriting the + accident: every export whose canonical page exists elsewhere under the API + reference becomes a link to it, and only exports documented nowhere else + (re-exports from private modules) keep their full body here, via an + explicit ``members:`` list. + """ + prefix = f"{package.path}." + exports = {str(export): package.members[str(export)] for export in package.exports or ()} + if not any(member.is_alias and not member.target_path.startswith(prefix) for member in exports.values()): + return None + + inline: list[str] = [] + sections: dict[str, list[str]] = {} + for name in sorted(exports, key=str.lower): + member = exports[name] + # A target only gets an anchor on its canonical page if it is rendered + # there, which the default `show_if_no_docstring: false` limits to + # objects with docstrings. + if member.is_alias and member.target_path.rpartition(".")[0] in documented and member.has_docstrings: + link_target = member.target_path + else: + inline.append(name) + link_target = f"{package.path}.{name}" + entry = f"- [`{name}`][{link_target}]" + target = member.final_target if member.is_alias else member + if docstring := target.docstring: + summary = docstring.value.split("\n", 1)[0] + entry += f" — {summary}" + sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry) + + body = [f"::: {package.path}", " options:"] + if inline: + body += [" members:", *(f" - {name}" for name in inline)] + else: + body += [" members: false"] + body.append("") + for title in _KIND_SECTIONS.values(): + if title in sections: + body += [f"## {title}", "", *sections[title], ""] + return "\n".join(body) + + def generate() -> list[NavItem]: """Write ``docs/api/**.md`` stubs and return the API-section navigation.""" if API_DIR.exists(): shutil.rmtree(API_DIR) - src = ROOT / "src" root = _Node() + stubs: dict[Path, str] = {} + package_index: dict[str, Path] = {} + documented: set[str] = set() - # `src/mcp-types` is a distribution directory, not an import package, so each - # package's dotted module path is taken relative to its own parent: deriving - # it from `src/` would emit the unimportable `mcp-types.mcp_types.*`. - for package in (src / "mcp", src / "mcp-types" / "mcp_types"): + for package in PACKAGES: base = package.parent for path in sorted(package.rglob("*.py")): module_path = path.relative_to(base).with_suffix("") @@ -69,16 +137,31 @@ def generate() -> list[NavItem]: elif parts[-1].startswith("_"): continue - full_doc_path = API_DIR / doc_path - full_doc_path.parent.mkdir(parents=True, exist_ok=True) ident = ".".join(parts) - full_doc_path.write_text(f"::: {ident}\n", encoding="utf-8") + documented.add(ident) + stubs[API_DIR / doc_path] = f"::: {ident}\n" + if len(parts) == 1: + package_index[ident] = API_DIR / doc_path node = root for part in parts: node = node.child(part) node.url = f"api/{doc_path.as_posix()}" + # Load every package before inspecting any of them: aliases only resolve + # once the module they point at is in the loader's collection. + loader = griffe.GriffeLoader(search_paths=[str(package.parent) for package in PACKAGES]) + modules = {ident: loader.load(ident) for ident in package_index} + for ident, doc_path in package_index.items(): + module = modules[ident] + assert isinstance(module, griffe.Module) + if body := _compact_index(module, documented): + stubs[doc_path] = body + + for full_doc_path, stub in stubs.items(): + full_doc_path.parent.mkdir(parents=True, exist_ok=True) + full_doc_path.write_text(stub, encoding="utf-8") + return [root.children[name].to_nav(name) for name in sorted(root.children)] diff --git a/uv.lock b/uv.lock index 5dd923d104..a8b96c3c4c 100644 --- a/uv.lock +++ b/uv.lock @@ -892,6 +892,7 @@ dev = [ { name = "trio" }, ] docs = [ + { name = "griffelib" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, { name = "pyyaml" }, @@ -947,6 +948,7 @@ dev = [ { name = "trio", specifier = ">=0.26.2" }, ] docs = [ + { name = "griffelib", specifier = ">=2.1.0" }, { name = "mkdocstrings", specifier = ">=1.0.4" }, { name = "mkdocstrings-python", specifier = ">=2.0.5" }, { name = "pyyaml", specifier = ">=6.0.2" }, From eb1ffae8a93f82c3473b8eafe882a8621ea65177 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:10 +0000 Subject: [PATCH 09/30] docs: restore watch and drop the stale banner env Zensical natively supports the watch key; dropping it broke serve live-reload for docs_src/ snippet edits. The deploy-docs banner env only applied to mkdocs-material builds, and the v1.x fallback arm already sets it inline. --- .github/workflows/deploy-docs.yml | 3 --- mkdocs.yml | 7 +++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 77bb1e1f51..6da800a727 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -50,9 +50,6 @@ jobs: - name: Build combined docs (v1.x at /, main at /v2/) run: bash scripts/build-docs.sh site - env: - # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. - NO_MKDOCS_2_WARNING: "1" - name: Configure Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 diff --git a/mkdocs.yml b/mkdocs.yml index 451b3bea29..cd3b146aa0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -164,6 +164,13 @@ 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 + # 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 From a05482d579778e80c5696e395d3e786a3432647e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:39 +0000 Subject: [PATCH 10/30] docs: catch the failure modes zensical builds green Unresolvable cross-references render as literal bracket text without a diagnostic, even under --strict, and link validation skips non-markdown targets entirely (a missing image ships silently; MkDocs failed both). Grep the built site for the literal-bracket signature after the build, and validate relative asset targets while llms_txt walks the prose. Also fail when a generated package index is missing from llms.txt's Optional section, so adding a package to gen_ref_pages cannot silently under-publish. --- scripts/docs/build.sh | 11 ++++++ scripts/docs/llms_txt.py | 82 +++++++++++++++++++++++++--------------- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh index 9a296a84e8..99c5f89124 100755 --- a/scripts/docs/build.sh +++ b/scripts/docs/build.sh @@ -23,4 +23,15 @@ cd "$SCRIPT_DIR/../.." uv sync --frozen --group docs uv run --frozen --no-sync python scripts/docs/build_config.py uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict + +# Zensical renders an unresolvable [`name`][identifier] cross-reference as +# literal bracket text and stays green even under --strict (mkdocs-autorefs +# used to warn, and strict mode failed). The generated API index relies on +# such references, so catch the failure mode here. +if grep -rn --include='*.html' -F '[' site/ > /dev/null; then + echo "error: unresolved cross-references rendered as literal text:" >&2 + grep -rn --include='*.html' -Fo -m 1 '[' site/ | head -20 >&2 + exit 1 +fi + uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site diff --git a/scripts/docs/llms_txt.py b/scripts/docs/llms_txt.py index 5a0673c405..686e532d32 100644 --- a/scripts/docs/llms_txt.py +++ b/scripts/docs/llms_txt.py @@ -1,18 +1,18 @@ """Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). Zensical has no equivalent of MkDocs' build hooks, so this runs as a standalone -post-build step over the source tree (``mkdocs.yml`` + ``docs/``) and writes -three kinds of artifact into the built ``site/``: +post-build step over the source tree (`mkdocs.yml` + `docs/`) and writes +three kinds of artifact into the built `site/`: -- ``llms.txt``: a markdown index of the documentation, one link per page, +- `llms.txt`: a markdown index of the documentation, one link per page, grouped by nav section. -- a ``.md`` rendition of every prose page next to its HTML (e.g. - ``servers/tools/index.md``), which is what the llms.txt links point at. -- ``llms-full.txt``: every prose page concatenated for single-fetch consumption. +- a `.md` rendition of every prose page next to its HTML (e.g. + `servers/tools/index.md`), which is what the llms.txt links point at. +- `llms-full.txt`: every prose page concatenated for single-fetch consumption. -Page markdown is the source markdown with ``--8<--`` snippet includes resolved -(so the ``docs_src/`` code examples appear inline) and relative links rewritten -to absolute URLs. The API reference pages under ``api/`` are mkdocstrings stubs +Page markdown is the source markdown with `--8<--` snippet includes resolved +(so the `docs_src/` code examples appear inline) and relative links rewritten +to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs with no prose source, so they are linked as rendered HTML from an Optional section instead of being embedded. @@ -44,6 +44,10 @@ _SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) _MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') +# Relative link/image targets with a non-markdown file extension. Zensical's +# link validation only covers .md targets (a missing image builds green even +# under --strict; MkDocs failed the build), so these are validated here. +_ASSET_LINK = re.compile(r'\]\(([^)\s#]+\.(?!md[)#\s])[a-zA-Z0-9]{1,4})(?:#[^)\s]*)?( +"[^"]*")?\)') class _BuildError(Exception): @@ -51,50 +55,55 @@ class _BuildError(Exception): def _dest_md_uri(src_uri: str) -> str: - """Map a source page (``servers/tools.md``) to its built rendition (``servers/tools/index.md``).""" + """Map a source page (`servers/tools.md`) to its built rendition (`servers/tools/index.md`).""" path = PurePosixPath(src_uri) directory = path.parent if path.stem == "index" else path.parent / path.stem return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md" 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") +def _collect_pages(items: list, prose: dict[str, str | None]) -> list[str]: + """Collect the prose pages under a nav subtree, in nav order. + + Records each page in `prose` (src_uri -> nav title, or `None` to fall + back to the page's H1). This is the single owner of the prose-page rule: + a page entry counts when it ends in .md and is not part of the generated + API reference. + """ + pages: list[str] = [] + for entry in items: + title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) + if isinstance(value, list): + pages.extend(_collect_pages(value, prose)) + elif value.endswith(".md") and not value.startswith("api/"): + prose[value] = title + pages.append(value) + return pages + + def _walk_nav(nav: list, prose: dict[str, str | None], sections: list[tuple[str, list[str]]]) -> list[str]: """Split the nav into a flat list of top-level pages and titled sections. - Populates ``prose`` (src_uri -> nav title, or ``None`` to fall back to the - page's H1) and ``sections`` ((title, [src_uri]) in nav order), and returns - the top-level page src_uris. API and section-index bare entries are skipped. + Populates `sections` ((title, [src_uri]) in nav order) and returns the + top-level page src_uris; page collection itself is `_collect_pages`. """ top_level: list[str] = [] for entry in nav: title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) if isinstance(value, list): - pages = _section_pages(value, prose) + pages = _collect_pages(value, prose) if pages: assert title is not None sections.append((title, pages)) - elif value.endswith(".md") and not value.startswith("api/"): - prose[value] = title - top_level.append(value) + else: + top_level.extend(_collect_pages([entry], prose)) return top_level -def _section_pages(items: list, prose: dict[str, str | None]) -> list[str]: - pages: list[str] = [] - for entry in items: - title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) - if isinstance(value, list): - pages.extend(_section_pages(value, prose)) - elif value.endswith(".md") and not value.startswith("api/"): - prose[value] = title - pages.append(value) - return pages - - def _resolve_snippets(markdown: str, src_uri: str) -> str: def include(match: re.Match[str]) -> str: indent, path = match["indent"], match["path"] @@ -122,6 +131,13 @@ def include(match: re.Match[str]) -> str: def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str: src_dir = posixpath.dirname(src_uri) + for match in _ASSET_LINK.finditer(markdown): + target = match.group(1) + if "://" in target: + continue + if not (DOCS / posixpath.normpath(posixpath.join(src_dir, target))).exists(): + raise _BuildError(f"llms_txt: cannot resolve asset link target {target!r} in {src_uri}") + def rewrite(match: re.Match[str]) -> str: opening, target, anchor, title, closing = match.groups() if "://" in target: @@ -180,6 +196,12 @@ def generate(site_dir: Path) -> None: index.append("") index += ["## Optional", ""] + # Every generated package index must be listed: a package added to + # gen_ref_pages.PACKAGES without an entry here would be published on the + # site but silently missing from llms.txt. + generated = {f"api/{path.name}/index.md" for path in (DOCS / "api").iterdir() if path.is_dir()} + if unlisted := generated - {src_uri for src_uri, _, _ in _OPTIONAL_PAGES}: + raise _BuildError(f"llms_txt: generated package indexes missing from _OPTIONAL_PAGES: {sorted(unlisted)}") for src_uri, title, description in _OPTIONAL_PAGES: if not (DOCS / src_uri).exists(): raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)") From 54641858b7f0076ae501441590f9bbc029b3862f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:39 +0000 Subject: [PATCH 11/30] docs: fix summary truncation and harden the API generator Docstring summaries took the first line, not the first paragraph, so wrapped one-sentence docstrings shipped cut off mid-sentence on the package index. The private-module filter only looked at the path leaf, so a private subpackage would have published its whole subtree; and an export resolving outside the documented packages now fails with a named error instead of a raw griffe traceback. --- scripts/docs/gen_ref_pages.py | 44 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 64dad0ecdb..6430d4a4db 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -1,12 +1,12 @@ """Generate the API reference pages and navigation. -Zensical does not run MkDocs plugins, so the work that ``mkdocs-gen-files`` and -``mkdocs-literate-nav`` used to do at build time happens here as a plain -pre-build step: this module writes a mkdocstrings stub (``::: ``) for -every public module under ``docs/api/`` and returns the matching nested -navigation, which ``scripts/docs/build_config.py`` splices into the build config. +Zensical does not run MkDocs plugins, so the work that `mkdocs-gen-files` and +`mkdocs-literate-nav` used to do at build time happens here as a plain +pre-build step: this module writes a mkdocstrings stub (`::: `) for +every public module under `docs/api/` and returns the matching nested +navigation, which `scripts/docs/build_config.py` splices into the build config. -Run as a script it just (re)generates ``docs/api/``; imported, :func:`generate` +Run as a script it just (re)generates `docs/api/`; imported, `generate` also returns the nav so the config builder can consume it. """ @@ -17,9 +17,9 @@ import griffe -# A MkDocs/Zensical nav is a list of entries, each either ``{title: url}`` for a -# page or ``{title: [children]}`` for a section (a bare ``url`` string attaches -# a section index page, courtesy of the ``navigation.indexes`` feature). +# A MkDocs/Zensical nav is a list of entries, each either `{title: url}` for a +# page or `{title: [children]}` for a section (a bare `url` string attaches +# a section index page, courtesy of the `navigation.indexes` feature). NavItem = "str | dict[str, str | list[NavItem]]" ROOT = Path(__file__).parent.parent.parent @@ -40,7 +40,7 @@ class _Node: - """A module (``url``) and/or a package with child modules (``children``).""" + """A module (`url`) and/or a package with child modules (`children`).""" def __init__(self) -> None: self.url: str | None = None @@ -64,19 +64,19 @@ def _compact_index(package: griffe.Module, documented: set[str]) -> str | None: """Build a compact index body for a package that re-exports another package's API. mkdocstrings renders a member re-exported across a package boundary - (``from other_package import y`` + ``__all__``) as a full duplicate of its + (`from other_package import y` + `__all__`) as a full duplicate of its canonical documentation whenever the other package happens to be loaded already, and silently omits it when it isn't — which of the two a package index gets depends on page rendering order. Same-package re-exports - (``mcp_types`` re-exporting its private ``._types`` module) always resolve - and are unaffected, so such packages keep the plain ``::: package`` stub - (return ``None``) and their index remains the full, canonical rendering. + (`mcp_types` re-exporting its private `._types` module) always resolve + and are unaffected, so such packages keep the plain `::: package` stub + (return `None`) and their index remains the full, canonical rendering. For an affected package, pin the semantics instead of inheriting the accident: every export whose canonical page exists elsewhere under the API reference becomes a link to it, and only exports documented nowhere else (re-exports from private modules) keep their full body here, via an - explicit ``members:`` list. + explicit `members:` list. """ prefix = f"{package.path}." exports = {str(export): package.members[str(export)] for export in package.exports or ()} @@ -96,9 +96,13 @@ def _compact_index(package: griffe.Module, documented: set[str]) -> str | None: inline.append(name) link_target = f"{package.path}.{name}" entry = f"- [`{name}`][{link_target}]" - target = member.final_target if member.is_alias else member + try: + target = member.final_target if member.is_alias else member + except griffe.AliasResolutionError as exc: + msg = f"gen_ref_pages: export {package.path}.{name} resolves outside the documented packages" + raise SystemExit(msg) from exc if docstring := target.docstring: - summary = docstring.value.split("\n", 1)[0] + summary = " ".join(docstring.value.split("\n\n", 1)[0].split("\n")) entry += f" — {summary}" sections.setdefault(_KIND_SECTIONS[target.kind], []).append(entry) @@ -115,7 +119,7 @@ def _compact_index(package: griffe.Module, documented: set[str]) -> str | None: def generate() -> list[NavItem]: - """Write ``docs/api/**.md`` stubs and return the API-section navigation.""" + """Write `docs/api/**.md` stubs and return the API-section navigation.""" if API_DIR.exists(): shutil.rmtree(API_DIR) @@ -134,7 +138,9 @@ def generate() -> list[NavItem]: if parts[-1] == "__init__": parts = parts[:-1] doc_path = doc_path.with_name("index.md") - elif parts[-1].startswith("_"): + # A private component anywhere makes the module private: checking + # only the leaf would publish pages for e.g. mcp._vendor.util. + if any(part.startswith("_") for part in parts): continue ident = ".".join(parts) From 878e21ee3480d34798121b9db2080ae80393b112 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:39 +0000 Subject: [PATCH 12/30] docs: trim dead config knobs and clarify the griffelib dependency build_config's --site-dir/--output flags had no callers and contradicted the fixed site/ output contract; nav validation now skips external URLs, which are valid nav entries. The griffelib comment preempts the reasonable suspicion that the name is a typo for griffe. --- pyproject.toml | 3 +++ scripts/docs/build_config.py | 33 +++++++++++---------------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 930fda1abf..4d9eecf661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,9 @@ docs = [ "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/docs/build_config.py b/scripts/docs/build_config.py index 20654f030f..35c1bd0ed1 100644 --- a/scripts/docs/build_config.py +++ b/scripts/docs/build_config.py @@ -1,18 +1,17 @@ -"""Produce the concrete Zensical build config from ``mkdocs.yml``. +"""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 -:mod:`gen_ref_pages`) and writes ``mkdocs.gen.yml`` with the real API nav -spliced in — that generated file is what ``zensical build``/``serve`` consumes. +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 [--site-dir DIR] [--output FILE] + python scripts/docs/build_config.py """ from __future__ import annotations -import argparse from pathlib import Path # Both scripts live in this directory, which Python puts on sys.path[0] when @@ -36,12 +35,12 @@ def _missing_nav_pages(nav: list, docs_dir: Path) -> list[str]: value = next(iter(entry.values())) if isinstance(entry, dict) else entry if isinstance(value, list): missing.extend(_missing_nav_pages(value, docs_dir)) - elif not (docs_dir / value).is_file(): + elif "://" not in value and not (docs_dir / value).is_file(): missing.append(value) return missing -def build_config(output: Path, site_dir: str | None = None) -> None: +def build_config() -> None: config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) api_nav = gen_ref_pages.generate() @@ -57,19 +56,9 @@ def build_config(output: Path, site_dir: str | None = None) -> None: if missing := _missing_nav_pages(config["nav"], ROOT / "docs"): raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") - if site_dir is not None: - config["site_dir"] = site_dir - + output = ROOT / "mkdocs.gen.yml" output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--site-dir", default=None, help="Override the build output directory.") - parser.add_argument("--output", default=str(ROOT / "mkdocs.gen.yml"), help="Where to write the generated config.") - args = parser.parse_args() - build_config(Path(args.output), args.site_dir) - - if __name__ == "__main__": - main() + build_config() From d629160b51eca03c9098c1e1e6aed45e41d392aa Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:40:45 +0000 Subject: [PATCH 13/30] docs: preload cross-package alias targets in the compact index stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering the stub resolves its cross-package aliases in mkdocstrings' own collection. On a warm incremental rebuild the target package's pages can all be cache hits, so nothing loads it and rendering the changed index crashes with AliasResolutionError — breaking the serve/edit loop for any src change that alters the generated index. Cold builds were unaffected. preload_modules pins the targets into the collection. --- scripts/docs/gen_ref_pages.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 6430d4a4db..a735d7ff86 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -106,7 +106,15 @@ def _compact_index(package: griffe.Module, documented: set[str]) -> str | None: entry += f" — {summary}" 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. + preload = sorted( + {member.target_path.split(".")[0] for member in exports.values() if member.is_alias} - {package.path} + ) body = [f"::: {package.path}", " options:"] + body += [" preload_modules:", *(f" - {module}" for module in preload)] if inline: body += [" members:", *(f" - {name}" for name in inline)] else: From e9278531fbed05664d0d373cf8f58b9872dce8f1 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:29:22 +0000 Subject: [PATCH 14/30] docs: port the sidebar styling to Zensical's nav and restore the slate canvas Zensical's UI keeps Material's md-* class names but ships different stock nav styling (8px-radius pill rows with 7px 16px padding), which broke the sidebar design in two ways: section labels sat indented past their guide line (the label's inner anchor carries the pill padding), and the vertical rhythm doubled. Re-express the same design against the new stock rules, keeping Zensical's pill highlight for the active page. The dark scheme also swaps Zensical's near-black canvas back for the Material slate blue-grey this site shipped with; code blocks keep Zensical's own surfaces. --- docs/extra.css | 61 ++++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/docs/extra.css b/docs/extra.css index 8625b05d52..c9902c7ab6 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -1,26 +1,30 @@ -/* Sidebar hierarchy + density for MkDocs Material 9.7.x. - All rules scoped to the desktop sidebar breakpoint (>= 76.25em), matching - Material's own scoping for navigation.sections, so the mobile drill-down - drawer keeps stock styling. Colors use Material tokens, so the light and - slate schemes both work without extra palette handling. */ +/* Sidebar hierarchy + density for Zensical's UI (Material-compatible md-* + DOM, but different stock spacing: nav links are 8px-radius pills with + 7px 16px padding). All rules scoped to the desktop sidebar breakpoint + (>= 76.25em) so the mobile drill-down drawer keeps stock styling. Colors + use the md-* tokens, so the light and slate schemes both work without + extra palette handling. */ @media screen and (min-width: 76.25em) { - /* Section labels: smaller, uppercase, letter-spaced, muted. Covers both the - clickable index-page headers and the bare API Reference label. */ + /* Section labels: smaller, uppercase, letter-spaced, muted, and flush + with the guide line below them. The label is a container whose inner + anchor carries the stock pill padding; zero both so the label text + starts at the section's left edge. */ .md-sidebar--primary .md-nav__item--section > .md-nav__link { + margin-top: 0; + margin-left: 0; + padding-left: 0; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--md-default-fg-color--light); } + .md-sidebar--primary .md-nav__item--section > .md-nav__link > a { + padding: 2px 0; + } - /* Indent section children and hang a guide line. Material outdents section - children with [dir=ltr] ... margin-left: -0.6rem, which is why they sit - flush under the header; restoring the margin re-exposes the stock 0.6rem - list padding. The logical property ties Material's physical one at - (0,3,0) specificity and wins on source order (extra_css loads last), - while staying direction-agnostic. */ + /* Indent section children slightly and hang a guide line. */ .md-sidebar--primary .md-nav__item--section > .md-nav { margin-inline-start: 0.1rem; border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); @@ -32,22 +36,19 @@ border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } - /* Tighten vertical rhythm (stock: 0.625em link margins, 1.25em sections). - The child combinator keeps this off anchors inside md-nav__container, - which carry their own margin-top: 0 stock rule. */ + /* Tighten vertical rhythm: the stock pill rows (7px 16px padding plus + 6.3px top margins) read too airy for a deep nav; keep the pill shape + and active highlight, just denser. */ .md-sidebar--primary .md-nav__item > .md-nav__link { - margin-top: 0.45em; + padding: 4px 10px; + margin-top: 2px; } .md-sidebar--primary .md-nav__item--section { margin: 1em 0; } - .md-sidebar--primary .md-nav__item--section > .md-nav__link { - margin-top: 0; - } - /* The current page stands out from its siblings. 700 because Material only - loads Inter at 300/400/700; a 600 would silently substitute the 700 face - anyway, but render lighter on the system-font fallback stack. */ + /* The current page stands out from its siblings (on top of the stock + pill highlight). 700 because only Inter 300/400/700 are loaded. */ .md-sidebar--primary .md-nav__link--active { font-weight: 700; } @@ -60,10 +61,16 @@ } } -/* Headings: Material's 300-weight light-gray defaults read washed out; use - the full foreground color and a solid weight instead. 700, not 600: the - Google Fonts request only carries Inter 300/400/700 (see the nav__link - note above). */ +/* Dark scheme: Zensical's slate canvas is near-black (hsla(225,15%,5%)), + harsher than the Material slate this site shipped with; restore that + blue-grey. Code blocks and other surfaces keep Zensical's own tokens. */ +[data-md-color-scheme="slate"] { + --md-default-bg-color: #1e2129; +} + +/* Headings: the 300-weight light-gray defaults read washed out; use the + full foreground color and a solid weight instead. 700, not 600: only + Inter 300/400/700 are loaded (see the nav__link note above). */ .md-typeset h1, .md-typeset h2 { font-weight: 700; From 153d384058307aebc61ed30178eea51f7a237c33 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:29:22 +0000 Subject: [PATCH 15/30] docs: give generated API stubs explicit page titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stubs have no H1 of their own, so their pages fell back to the title Index — which is what navigation.prune shows on collapsed nav rows (every collapsed API subtree rendered as 'Index'), and what browser tabs and search results display. --- scripts/docs/gen_ref_pages.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index a735d7ff86..185ee74b25 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -153,7 +153,10 @@ def generate() -> list[NavItem]: ident = ".".join(parts) documented.add(ident) - stubs[API_DIR / doc_path] = f"::: {ident}\n" + # Explicit titles: the stubs have no H1 of their own, and a + # title-less page falls back to "Index"/the filename — which is + # what pruned nav rows, browser tabs, and search results show. + stubs[API_DIR / doc_path] = f"---\ntitle: {parts[-1]}\n---\n\n::: {ident}\n" if len(parts) == 1: package_index[ident] = API_DIR / doc_path @@ -170,7 +173,7 @@ def generate() -> list[NavItem]: module = modules[ident] assert isinstance(module, griffe.Module) if body := _compact_index(module, documented): - stubs[doc_path] = body + stubs[doc_path] = f"---\ntitle: {ident}\n---\n\n{body}" for full_doc_path, stub in stubs.items(): full_doc_path.parent.mkdir(parents=True, exist_ok=True) From 7d3d1dded1d5e63cc0e6d24aa7d166ab2061e5a4 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:44:46 +0000 Subject: [PATCH 16/30] docs: fix the sidebar rule cascade and print scheme scoping The section-label overrides tied the density rule on specificity and lost on source order, re-indenting labels; and the density padding also hit the md-nav__container wrapper (stock 0), double-indenting group rows that link to an index page. Order the density rules first and densify the container's inner anchor instead of the wrapper. The slate canvas override moves under @media screen like the stock rule it replaces, so dark-mode printing keeps a light canvas; stub titles gain YAML quoting. --- docs/extra.css | 38 +++++++++++++++++++++++------------ scripts/docs/gen_ref_pages.py | 4 ++-- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/docs/extra.css b/docs/extra.css index c9902c7ab6..ca199698d0 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -6,6 +6,27 @@ extra palette handling. */ @media screen and (min-width: 76.25em) { + /* Tighten vertical rhythm: the stock pill rows (7px 16px padding plus + 6.3px top margins) read too airy for a deep nav; keep the pill shape + and active highlight, just denser. Group rows that link to an index + page wrap their anchor in an .md-nav__container div whose stock + padding is 0 — densify the inner anchor, not the wrapper. The + section-label rules below must come AFTER these: they win the + specificity tie on source order. */ + .md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container) { + padding: 4px 10px; + margin-top: 2px; + } + .md-sidebar--primary .md-nav__item > .md-nav__container { + margin-top: 2px; + } + .md-sidebar--primary .md-nav__container > .md-nav__link { + padding: 4px 10px; + } + .md-sidebar--primary .md-nav__item--section { + margin: 1em 0; + } + /* Section labels: smaller, uppercase, letter-spaced, muted, and flush with the guide line below them. The label is a container whose inner anchor carries the stock pill padding; zero both so the label text @@ -36,17 +57,6 @@ border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } - /* Tighten vertical rhythm: the stock pill rows (7px 16px padding plus - 6.3px top margins) read too airy for a deep nav; keep the pill shape - and active highlight, just denser. */ - .md-sidebar--primary .md-nav__item > .md-nav__link { - padding: 4px 10px; - margin-top: 2px; - } - .md-sidebar--primary .md-nav__item--section { - margin: 1em 0; - } - /* The current page stands out from its siblings (on top of the stock pill highlight). 700 because only Inter 300/400/700 are loaded. */ .md-sidebar--primary .md-nav__link--active { @@ -64,8 +74,10 @@ /* Dark scheme: Zensical's slate canvas is near-black (hsla(225,15%,5%)), harsher than the Material slate this site shipped with; restore that blue-grey. Code blocks and other surfaces keep Zensical's own tokens. */ -[data-md-color-scheme="slate"] { - --md-default-bg-color: #1e2129; +@media screen { + [data-md-color-scheme="slate"] { + --md-default-bg-color: #1e2129; + } } /* Headings: the 300-weight light-gray defaults read washed out; use the diff --git a/scripts/docs/gen_ref_pages.py b/scripts/docs/gen_ref_pages.py index 185ee74b25..be69c5f09f 100644 --- a/scripts/docs/gen_ref_pages.py +++ b/scripts/docs/gen_ref_pages.py @@ -156,7 +156,7 @@ def generate() -> list[NavItem]: # Explicit titles: the stubs have no H1 of their own, and a # title-less page falls back to "Index"/the filename — which is # what pruned nav rows, browser tabs, and search results show. - stubs[API_DIR / doc_path] = f"---\ntitle: {parts[-1]}\n---\n\n::: {ident}\n" + stubs[API_DIR / doc_path] = f'---\ntitle: "{parts[-1]}"\n---\n\n::: {ident}\n' if len(parts) == 1: package_index[ident] = API_DIR / doc_path @@ -173,7 +173,7 @@ def generate() -> list[NavItem]: module = modules[ident] assert isinstance(module, griffe.Module) if body := _compact_index(module, documented): - stubs[doc_path] = f"---\ntitle: {ident}\n---\n\n{body}" + stubs[doc_path] = f'---\ntitle: "{ident}"\n---\n\n{body}' for full_doc_path, stub in stubs.items(): full_doc_path.parent.mkdir(parents=True, exist_ok=True) From de9d8fab702965d8b42e34fe376a90c9c169859a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:52:02 +0000 Subject: [PATCH 17/30] docs: align top-level nav pages and the API Reference label flush left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level pages outside any section kept the pill's left padding plus a stock margin, sitting 14px right of the section labels; pull their box left by the pill inset so text lines up and the active pill overhangs the gutter. The API Reference label (the one section without an index page, rendered as a label element rather than a container div) was still caught by the density rule via :not() specificity — exclude section items from the density rules outright instead of relying on source order. --- docs/extra.css | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/extra.css b/docs/extra.css index ca199698d0..f8aeb02aae 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -11,13 +11,14 @@ and active highlight, just denser. Group rows that link to an index page wrap their anchor in an .md-nav__container div whose stock padding is 0 — densify the inner anchor, not the wrapper. The - section-label rules below must come AFTER these: they win the - specificity tie on source order. */ - .md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container) { + section items are excluded outright: their label is styled by the + section rules below, and the :not() classes here would otherwise + out-rank those on specificity. */ + .md-sidebar--primary .md-nav__item:not(.md-nav__item--section) > .md-nav__link:not(.md-nav__container) { padding: 4px 10px; margin-top: 2px; } - .md-sidebar--primary .md-nav__item > .md-nav__container { + .md-sidebar--primary .md-nav__item:not(.md-nav__item--section) > .md-nav__container { margin-top: 2px; } .md-sidebar--primary .md-nav__container > .md-nav__link { @@ -34,7 +35,7 @@ .md-sidebar--primary .md-nav__item--section > .md-nav__link { margin-top: 0; margin-left: 0; - padding-left: 0; + padding: 2px 0; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; @@ -63,6 +64,14 @@ font-weight: 700; } + /* Top-level pages outside any section (the homepage entry, Protocol + versions, Troubleshooting, ...) share the left edge with the section + labels: pull the link box left by its own pill padding, so the text + is flush and the hover/active pill overhangs into the gutter. */ + .md-sidebar--primary .md-nav--primary > .md-nav__list > .md-nav__item:not(.md-nav__item--section) > .md-nav__link:not(.md-nav__container) { + margin-inline-start: -10px; + } + /* The sidebar repeats the site name right above the homepage nav entry; drop the title row on desktop (the mobile drawer still needs it for its drill-down back-navigation, hence the media-query scope). */ From c8449b72c68009f74b0f8b8decf916915bc2ae2b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:09:42 +0000 Subject: [PATCH 18/30] docs: rebuild the sidebar styling as one coordinate system The previous rules chased text alignment with per-element compensations (zeroed paddings, a negative margin), which clipped the active pill flat against the scroll container's paint edge, left label pills hugging their text, and fought the list's stock flex gap with per-link margins. Derive everything from the pill inset instead: every row is a direct .md-nav__link child with the same 10px horizontal padding (inner anchors inside container wrappers zeroed), so all text shares one column and pills always paint with breathing room inside the clip edge; section rules carry typography only, eliminating the specificity coordination; and the flex gap becomes the single density knob (2px, restoring the ~30px row pitch the site shipped with on Material). --- docs/extra.css | 63 +++++++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/docs/extra.css b/docs/extra.css index f8aeb02aae..46a2d94250 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -6,55 +6,48 @@ extra palette handling. */ @media screen and (min-width: 76.25em) { - /* Tighten vertical rhythm: the stock pill rows (7px 16px padding plus - 6.3px top margins) read too airy for a deep nav; keep the pill shape - and active highlight, just denser. Group rows that link to an index - page wrap their anchor in an .md-nav__container div whose stock - padding is 0 — densify the inner anchor, not the wrapper. The - section items are excluded outright: their label is styled by the - section rules below, and the :not() classes here would otherwise - out-rank those on specificity. */ - .md-sidebar--primary .md-nav__item:not(.md-nav__item--section) > .md-nav__link:not(.md-nav__container) { - padding: 4px 10px; - margin-top: 2px; + /* The sidebar is one coordinate system derived from the pill inset: + every row — page links, group rows, section labels — is a direct + .md-nav__link child of its item with the same 10px horizontal padding, + so all text shares one column, and hover/active pills always paint + 10px of breathing room inside the scroll container (never clipped). + Inner anchors inside .md-nav__container wrappers are zeroed; the row + box owns the geometry. Vertical rhythm has a single knob: the nav + list's flex gap (stock 0.2rem reads airy; 2px matches the density the + site shipped with on Material, ~30px row pitch). */ + .md-sidebar--primary .md-nav__list { + gap: 2px; } - .md-sidebar--primary .md-nav__item:not(.md-nav__item--section) > .md-nav__container { - margin-top: 2px; + .md-sidebar--primary .md-nav__item > .md-nav__link { + padding: 3px 10px; + margin: 0; } .md-sidebar--primary .md-nav__container > .md-nav__link { - padding: 4px 10px; + padding: 0; + margin: 0; } + + /* Section labels: typography only — geometry comes from the row rule + above, so no specificity coordination is needed. */ .md-sidebar--primary .md-nav__item--section { - margin: 1em 0; + margin: 0.8em 0; } - - /* Section labels: smaller, uppercase, letter-spaced, muted, and flush - with the guide line below them. The label is a container whose inner - anchor carries the stock pill padding; zero both so the label text - starts at the section's left edge. */ .md-sidebar--primary .md-nav__item--section > .md-nav__link { - margin-top: 0; - margin-left: 0; - padding: 2px 0; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--md-default-fg-color--light); } - .md-sidebar--primary .md-nav__item--section > .md-nav__link > a { - padding: 2px 0; - } - /* Indent section children slightly and hang a guide line. */ + /* Guide lines: 12px from the item box = 2px right of the label text + (which sits at box + 10px pill inset); children indent past them. */ .md-sidebar--primary .md-nav__item--section > .md-nav { - margin-inline-start: 0.1rem; + margin-inline-start: 12px; border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } - - /* Same guide line for collapsible groups inside the API Reference subtree - (section items also carry --nested, so exclude them). */ .md-sidebar--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav { + margin-inline-start: 12px; border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } @@ -64,14 +57,6 @@ font-weight: 700; } - /* Top-level pages outside any section (the homepage entry, Protocol - versions, Troubleshooting, ...) share the left edge with the section - labels: pull the link box left by its own pill padding, so the text - is flush and the hover/active pill overhangs into the gutter. */ - .md-sidebar--primary .md-nav--primary > .md-nav__list > .md-nav__item:not(.md-nav__item--section) > .md-nav__link:not(.md-nav__container) { - margin-inline-start: -10px; - } - /* The sidebar repeats the site name right above the homepage nav entry; drop the title row on desktop (the mobile drawer still needs it for its drill-down back-navigation, hence the media-query scope). */ From 0d3ef499035d51953d078b6dd1009023bc492638 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:18:05 +0000 Subject: [PATCH 19/30] docs: put the pill inset on the elements that paint it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zensical paints nav hover/active pills on .md-nav__link[href] anchors and [for] labels, never on the .md-nav__container wrapper — which is where the previous commit put the padding, leaving container-type section labels with a pill hugging their text. Move the inset onto the paint elements (leaf links, bare labels, and a wrapper's inner anchor) and keep wrappers geometry-neutral like stock. The 10px inset also keeps the pill's corner radius (0.4rem) from crowding the glyphs. --- docs/extra.css | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/extra.css b/docs/extra.css index 46a2d94250..8772b8700e 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -11,18 +11,23 @@ .md-nav__link child of its item with the same 10px horizontal padding, so all text shares one column, and hover/active pills always paint 10px of breathing room inside the scroll container (never clipped). - Inner anchors inside .md-nav__container wrappers are zeroed; the row - box owns the geometry. Vertical rhythm has a single knob: the nav - list's flex gap (stock 0.2rem reads airy; 2px matches the density the - site shipped with on Material, ~30px row pitch). */ + The padding lives on the elements Zensical paints hover/active pills + on (.md-nav__link[href] anchors and [for] labels — leaf links, bare + section labels, and the inner anchor of an .md-nav__container + wrapper); wrappers stay geometry-neutral, as stock. The 10px inset + also stays >= the 0.4rem pill radius, so the corner curve never + crowds the text. Vertical rhythm has a single knob: the nav list's + flex gap (stock 0.2rem reads airy; 2px matches the density the site + shipped with on Material, ~30px row pitch). */ .md-sidebar--primary .md-nav__list { gap: 2px; } - .md-sidebar--primary .md-nav__item > .md-nav__link { + .md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container), + .md-sidebar--primary .md-nav__container > .md-nav__link { padding: 3px 10px; margin: 0; } - .md-sidebar--primary .md-nav__container > .md-nav__link { + .md-sidebar--primary .md-nav__item > .md-nav__container { padding: 0; margin: 0; } From b0dc3d9a3b1f6b11bdb0fc93ae538fcce3dfc543 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:18:21 +0000 Subject: [PATCH 20/30] docs: composite inline code over admonition tints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline-code chip is an absolute dark surface designed for the page canvas; on an admonition's tinted panel it sits as an opaque slab (Zensical's modern theme has the same defect on its own docs — classic Material mitigated it with a hairline ring on title code, which modern dropped). Scope a tone-on-tone translucent chip to code inside admonitions, the pattern Starlight and Docusaurus ship for code inside callouts; prose chips keep the block-matching dark surface. --- docs/extra.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/extra.css b/docs/extra.css index 8772b8700e..fb7e123888 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -79,6 +79,21 @@ } } +/* Inline code inside admonitions: the chip token is an absolute dark + surface designed for the page canvas, so on a tinted admonition panel it + sits as an opaque slab (Zensical's own docs share this bug). Re-tint it + tone-on-tone instead — translucent foreground, composited over whatever + the panel color is — the same pattern Starlight and Docusaurus ship for + code inside callouts. Prose chips keep the block-matching dark surface; + block code inside admonitions keeps its own surface too. The first + declaration is the fallback where color-mix is unsupported. */ +.md-typeset .admonition :not(pre) > code, +.md-typeset details :not(pre) > code { + background-color: var(--md-default-fg-color--lightest); + background-color: color-mix(in srgb, currentcolor 11%, transparent); + color: inherit; +} + /* Headings: the 300-weight light-gray defaults read washed out; use the full foreground color and a solid weight instead. 700, not 600: only Inter 300/400/700 are loaded (see the nav__link note above). */ From 0fc324264598374b28592ade4ba0e982d44469a0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:51:11 +0000 Subject: [PATCH 21/30] docs: replace the llms_txt link regexes with a total classifier Every markdown link target is now classified in one place instead of pattern-matched by shape, so no link can dodge validation by its spelling: scheme-prefixed targets (including mailto:) are external, targets inside fenced blocks or code spans are illustrative, and everything else must resolve to a file under docs/ (containment enforced, directory-style and absolute targets rejected, assets rewritten to absolute URLs). Fences are scanned line-based per CommonMark (closer may be longer than the opener, unclosed fences run to EOF) and code spans cannot cross a blank line, so a stray backtick cannot exempt later paragraphs from validation. Frontmatter is stripped the way the old hook's meta extraction did, restoring title and description frontmatter support in llms.txt. Output is byte-identical for the current docs corpus. --- scripts/docs/llms_txt.py | 165 +++++++++++++++++++++++++++++---------- 1 file changed, 124 insertions(+), 41 deletions(-) diff --git a/scripts/docs/llms_txt.py b/scripts/docs/llms_txt.py index 686e532d32..0ef0ebf799 100644 --- a/scripts/docs/llms_txt.py +++ b/scripts/docs/llms_txt.py @@ -10,11 +10,11 @@ `servers/tools/index.md`), which is what the llms.txt links point at. - `llms-full.txt`: every prose page concatenated for single-fetch consumption. -Page markdown is the source markdown with `--8<--` snippet includes resolved -(so the `docs_src/` code examples appear inline) and relative links rewritten -to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs -with no prose source, so they are linked as rendered HTML from an Optional -section instead of being embedded. +Page markdown is the source markdown with YAML frontmatter stripped, `--8<--` +snippet includes resolved (so the `docs_src/` code examples appear inline) and +relative links rewritten to absolute URLs. The API reference pages under +`api/` are mkdocstrings stubs with no prose source, so they are linked as +rendered HTML from an Optional section instead of being embedded. Usage: python scripts/docs/llms_txt.py --site-dir site @@ -26,6 +26,7 @@ import posixpath import re from pathlib import Path, PurePosixPath +from typing import Any import yaml @@ -43,11 +44,26 @@ ] _SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) -_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') -# Relative link/image targets with a non-markdown file extension. Zensical's -# link validation only covers .md targets (a missing image builds green even -# under --strict; MkDocs failed the build), so these are validated here. -_ASSET_LINK = re.compile(r'\]\(([^)\s#]+\.(?!md[)#\s])[a-zA-Z0-9]{1,4})(?:#[^)\s]*)?( +"[^"]*")?\)') +# Every markdown link/image target: `](target#anchor "title")`. Each target is +# classified in `_rewrite_links` — there is deliberately no shape-based +# pre-filter here, so no link can dodge validation by its spelling. Zensical's +# own link validation only covers .md targets (a missing image or +# directory-style link builds green even under --strict; MkDocs failed the +# build), so everything else is validated here. +_LINK = re.compile(r'(\]\()([^)\s]+?)(#[^)\s]*)?( +"[^"]*")?(\))') +# A scheme-prefixed target (https:, mailto:, tel:, ...) is external — the +# `://` shorthand misses scheme-only URIs like mailto:. +_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") +# Fenced code blocks and inline code spans: their content is inert in the +# rendered HTML, so links inside them are illustrative text, neither validated +# nor rewritten. Fences are matched line-based in `_code_intervals` (closer at +# least as long as the opener, unclosed runs to EOF, per CommonMark) and spans +# only in the text between fences; a span cannot cross a blank line, so a +# stray unpaired backtick cannot swallow the paragraphs (and links) after it. +_FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})") +_CODE_SPAN = re.compile(r"(?s)(?.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", flags=re.MULTILINE | re.DOTALL) class _BuildError(Exception): @@ -66,20 +82,43 @@ def _page_url(src_uri: str) -> str: return _dest_md_uri(src_uri).removesuffix("index.md") +def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: + """Split a leading YAML frontmatter block from a page (mirrors mkdocs.utils.meta). + + Hand-rolled deliberately: mkdocs is only a transitive dependency of this + toolchain, so the pipeline must not import it. The hook this replaced ran + post-frontmatter-extraction, so renditions never contained frontmatter and + `meta` fed the page title and llms.txt description. A leading block that + isn't a YAML mapping is page content, not frontmatter; an empty block is + frontmatter with no meta. + """ + match = _FRONTMATTER.match(text) + if match is None: + return {}, text + try: + meta = yaml.safe_load(match["block"]) + except yaml.YAMLError: + return {}, text + if meta is not None and not isinstance(meta, dict): + return {}, text + return meta or {}, text[match.end() :].lstrip("\n") + + def _collect_pages(items: list, prose: dict[str, str | None]) -> list[str]: """Collect the prose pages under a nav subtree, in nav order. Records each page in `prose` (src_uri -> nav title, or `None` to fall back to the page's H1). This is the single owner of the prose-page rule: - a page entry counts when it ends in .md and is not part of the generated - API reference. + a page entry counts when it is a local .md path (external URLs render as + outbound nav links and are omitted, as the MkDocs pipeline did) and is not + part of the generated API reference. """ pages: list[str] = [] for entry in items: title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) if isinstance(value, list): pages.extend(_collect_pages(value, prose)) - elif value.endswith(".md") and not value.startswith("api/"): + elif not _EXTERNAL.match(value) and value.endswith(".md") and not value.startswith("api/"): prose[value] = title pages.append(value) return pages @@ -128,40 +167,77 @@ def include(match: re.Match[str]) -> str: return resolved +def _code_intervals(markdown: str) -> list[tuple[int, int]]: + """The character spans of fenced code blocks and inline code spans.""" + fences: list[tuple[int, int]] = [] + opener = "" + start = offset = 0 + for line in markdown.splitlines(keepends=True): + if not opener: + if match := _FENCE.match(line): + opener, start = match[1], offset + elif (stripped := line.strip()).startswith(opener) and set(stripped) == {opener[0]}: + fences.append((start, offset + len(line))) + opener = "" + offset += len(line) + if opener: + fences.append((start, len(markdown))) + + intervals = list(fences) + previous_end = 0 + for fence_start, fence_end in [*fences, (len(markdown), len(markdown))]: + segment = markdown[previous_end:fence_start] + intervals += [(previous_end + m.start(), previous_end + m.end()) for m in _CODE_SPAN.finditer(segment)] + previous_end = fence_end + return intervals + + def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str: src_dir = posixpath.dirname(src_uri) - - for match in _ASSET_LINK.finditer(markdown): - target = match.group(1) - if "://" in target: - continue - if not (DOCS / posixpath.normpath(posixpath.join(src_dir, target))).exists(): - raise _BuildError(f"llms_txt: cannot resolve asset link target {target!r} in {src_uri}") + code = _code_intervals(markdown) def rewrite(match: re.Match[str]) -> str: opening, target, anchor, title, closing = match.groups() - if "://" in target: - return match.group(0) + if target.startswith("#") or _EXTERNAL.match(target): + return match.group(0) # in-page anchor or external URL (https:, mailto:, ...) + if any(start <= match.start() < end for start, end in code): + return match.group(0) # illustrative link inside a code block/span + if target.startswith("/"): + raise _BuildError(f"llms_txt: absolute link target {target!r} in {src_uri}: link the .md source instead") linked = posixpath.normpath(posixpath.join(src_dir, target)) - if not (DOCS / linked).exists(): + if linked == ".." or linked.startswith("../"): + raise _BuildError(f"llms_txt: link target {target!r} in {src_uri} escapes docs/") + if (DOCS / linked).is_dir(): + raise _BuildError( + f"llms_txt: directory-style link target {target!r} in {src_uri}: link the page's .md source instead" + ) + if not (DOCS / linked).is_file(): raise _BuildError(f"llms_txt: cannot resolve link target {target!r} in {src_uri}") - # 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) + if linked.endswith(".md"): + # 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) + else: + url = linked # assets are published at their docs-relative path return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" - return _MD_LINK.sub(rewrite, markdown) + return _LINK.sub(rewrite, markdown) -def _title(src_uri: str, nav_title: str | None, body: str) -> str: +def _title(src_uri: str, nav_title: str | None, meta: dict[str, Any], body: str) -> str: if nav_title is not None: return nav_title + if isinstance(meta_title := meta.get("title"), str): + return meta_title match = re.search(r"^\s*# (.+)$", body, flags=re.MULTILINE) if match is None: - raise _BuildError(f"llms_txt: page {src_uri} has no nav title and no H1") + raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1") return match.group(1).strip() def generate(site_dir: Path) -> None: + if not (DOCS / "api").is_dir(): + raise _BuildError("llms_txt: docs/api not found (run gen_ref_pages first)") + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) site_url = config["site_url"].rstrip("/") + "/" @@ -171,8 +247,9 @@ def generate(site_dir: Path) -> None: ordered: list[tuple[str, list[str]]] = ([("Docs", top_level)] if top_level else []) + sections rendered: dict[str, str] = {} + metas: dict[str, dict[str, Any]] = {} for src_uri in prose: - markdown = (DOCS / src_uri).read_text(encoding="utf-8") + metas[src_uri], markdown = _split_frontmatter((DOCS / src_uri).read_text(encoding="utf-8")) markdown = _resolve_snippets(markdown, src_uri) rendered[src_uri] = _rewrite_links(markdown, src_uri, site_url, prose) @@ -186,25 +263,29 @@ def generate(site_dir: Path) -> None: (site_dir / md_uri).parent.mkdir(parents=True, exist_ok=True) (site_dir / md_uri).write_text(markdown, encoding="utf-8") - title = _title(src_uri, prose[src_uri], markdown) - index.append(f"- [{title}]({site_url}{md_uri})") + title = _title(src_uri, prose[src_uri], metas[src_uri], markdown) + description = metas[src_uri].get("description") + tail = f": {description}" if description else "" + index.append(f"- [{title}]({site_url}{md_uri}){tail}") - body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown) - if not h1_found: - raise _BuildError(f"llms_txt: page {src_uri} does not start with an H1") + # Strip a leading H1 when present: `full` re-titles every page. + body = re.sub(r"\A\s*# .+\n", "", markdown) full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""] index.append("") index += ["## Optional", ""] - # Every generated package index must be listed: a package added to - # gen_ref_pages.PACKAGES without an entry here would be published on the - # site but silently missing from llms.txt. + # _OPTIONAL_PAGES must match the generated package indexes exactly: a + # package added to gen_ref_pages.PACKAGES without an entry here would be + # published on the site but silently missing from llms.txt, and a stale + # entry would link a page that no longer exists. generated = {f"api/{path.name}/index.md" for path in (DOCS / "api").iterdir() if path.is_dir()} - if unlisted := generated - {src_uri for src_uri, _, _ in _OPTIONAL_PAGES}: - raise _BuildError(f"llms_txt: generated package indexes missing from _OPTIONAL_PAGES: {sorted(unlisted)}") + listed = {src_uri for src_uri, _, _ in _OPTIONAL_PAGES} + if generated != listed: + raise _BuildError( + f"llms_txt: _OPTIONAL_PAGES out of sync with docs/api:" + f" missing {sorted(generated - listed)}, stale {sorted(listed - generated)}" + ) for src_uri, title, description in _OPTIONAL_PAGES: - if not (DOCS / src_uri).exists(): - raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)") index.append(f"- [{title}]({site_url}{_page_url(src_uri)}): {description}") index.append("") @@ -220,6 +301,8 @@ def main() -> None: generate(Path(args.site_dir)) except _BuildError as exc: raise SystemExit(str(exc)) from exc + except OSError as exc: + raise SystemExit(f"llms_txt: {exc}") from exc if __name__ == "__main__": From a483385a9eb95ea9bd3df55ec7181206a03f2349 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:51:21 +0000 Subject: [PATCH 22/30] docs: validate crossrefs and inventories from the built site Zensical stays green even under --strict when a cross-reference fails to resolve or an objects.inv inventory fails to download. The old guards had holes in both directions: grepping HTML for '[' missed plain-text unresolved refs and false-positived on subscripted types in backticked prose, and nothing at all caught a failed inventory download, which silently degrades thousands of standard-library links to plain text on exactly the cold-build path deploys run on. check_crossrefs.py validates the built site instead, so no log-wording change can disarm it: it extracts prose text outside pre/code (marking skipped code so bracket shapes stay distinguishable) and fails on the literal reconstruction an unresolved reference leaves behind, and it requires every inventory declared in mkdocs.yml to contribute at least one autorefs-external anchor - hand-authored prose links to the same host don't count, and an inventory contributing nothing is dead config. Offline contributors can set DOCS_ALLOW_INVENTORY_FAILURE=1; CI never skips it. build.sh now also builds cold: zensical's incremental cache drops cross-references to cache-hit pages and leaves stale HTML for deleted pages, which made warm local runs fail on phantom breakage. --- .github/workflows/shared.yml | 9 +- scripts/docs/build.sh | 21 +++-- scripts/docs/check_crossrefs.py | 157 ++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 scripts/docs/check_crossrefs.py diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index f7da7174bf..c859bbab1d 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -139,9 +139,12 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check - # `zensical build --strict` fails on broken links / missing nav targets and - # `pymdownx.snippets: check_paths: true` fails on a deleted `docs_src/` - # include; the llms_txt step additionally re-resolves every snippet and link. + # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on + # nav entries without a page and pages without a nav entry, `zensical build + # --strict` fails on broken .md links, `pymdownx.snippets: check_paths: + # true` fails on a deleted `docs_src/` include, and the post-build steps + # fail on unresolved cross-references, inventory download failures, and + # broken non-markdown link targets. # 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 of main. This is the check path; `deploy-docs.yml` stays the deploy diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh index 99c5f89124..8dce3afd4f 100755 --- a/scripts/docs/build.sh +++ b/scripts/docs/build.sh @@ -21,17 +21,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 renders an unresolvable [`name`][identifier] cross-reference as -# literal bracket text and stays green even under --strict (mkdocs-autorefs -# used to warn, and strict mode failed). The generated API index relies on -# such references, so catch the failure mode here. -if grep -rn --include='*.html' -F '[' site/ > /dev/null; then - echo "error: unresolved cross-references rendered as literal text:" >&2 - grep -rn --include='*.html' -Fo -m 1 '[' site/ | head -20 >&2 - exit 1 -fi +# 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/check_crossrefs.py b/scripts/docs/check_crossrefs.py new file mode 100644 index 0000000000..7524eb8974 --- /dev/null +++ b/scripts/docs/check_crossrefs.py @@ -0,0 +1,157 @@ +"""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 
//