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