diff --git a/CHANGES b/CHANGES index 9f167783..a08c2fb5 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,35 @@ $ uv add gp-sphinx --prerelease allow +### Bug fixes + +#### `gp-sphinx`: MystLexer highlights `:::{directive}` colon-fences + +Embedded MyST snippets that use the colon-fence directive syntax now +tokenize correctly when rendered through `pygments` (e.g. inside +`.. code-block:: myst`). Previously the fence and body fell through +to plain text. (#33) + +### Documentation + +#### Per-package documentation tree + +Workspace packages now ship per-package directories with Diátaxis +subpages (`tutorial`, `how-to`, `reference`, `explanation`, +`examples`); the sidebar nests subpages under their package. New +`{package-landing}` and `{cluster-toctree}` directives derive +landings and sidebar leaves from a `PackageDocsRecord` schema that +reads both `pyproject.toml` and `package.json` manifests, so JS-only +packages like `@gp-sphinx/furo-tokens` sit alongside the Python +ones. Legacy `/packages/.html` URLs continue to resolve. (#33) + +#### Showcase opt-in directives + +`{live-signature}`, `{package-kitchen-sink}`, `{surface-changelog}`, +and `{package-dependents}` opt in via +`[tool.gp-sphinx.docs].showcase` for richer per-package landings. +(#33) + ## gp-sphinx 0.0.1a16 (2026-05-03) ### What's new diff --git a/docs/_ext/inline_highlight.py b/docs/_ext/inline_highlight.py new file mode 100644 index 00000000..d6b056bc --- /dev/null +++ b/docs/_ext/inline_highlight.py @@ -0,0 +1,240 @@ +"""Inline syntax highlighting for ``nodes.literal`` (single-backtick code). + +Sphinx + MyST send single-backtick inline code to docutils as +:class:`docutils.nodes.literal`, which renders as +```` +with no Pygments invocation. Block-level fences (``rst``/``myst``) +go through Pygments; inline literals don't. This is upstream-default +Sphinx behavior. + +This transform restores parity for the four inline content patterns +the workspace's reference + kitchen-sink pages rely on: + +- **Bare RST roles** like ``:argparse:program:`` / ``:tool:`` — + Pygments' :class:`RstLexer` does not tokenize bare role names + (without a backtick body), so we emit a single ``Name.Attribute`` + span explicitly. +- **RST roles with content** like ``:tool:`list_sessions``` — + :class:`RstLexer` tokenizes these as ``Name.Attribute`` + + ``Name.Variable``. +- **Shell sessions** like ``$ uv run pytest`` — :class:`BashSessionLexer` + tokenizes the ``$`` prompt as ``Generic.Prompt``. +- **Inline RST directive references** like ``.. autodirective::`` — + :class:`RstLexer` tokenizes ``..`` / directive name / ``::``. + +Dimensional invariants +---------------------- +The transform PRESERVES the outer ```` wrapper that Sphinx emits for ``nodes.literal``. +Furo's inline-literal styling targets ``code.literal, .sig-inline`` +in ``furo.css`` (background, border-radius, font-size, padding) — by +keeping ```` as the outer element, the existing box styling +applies unchanged. Pygments token spans inside add foreground color +only (no padding/margin), so line height, line wrapping, and span +width are identical to pre-transform. + +Inspired by :class:`django_docutils.lib.transforms.code.CodeTransform`, +which establishes the precedent of post-parse pattern dispatch over +``nodes.literal``. +""" + +from __future__ import annotations + +import html +import io +import re +import typing as t + +from docutils import nodes +from docutils.transforms import Transform +from pygments.formatters.html import HtmlFormatter +from pygments.lexers.markup import RstLexer +from pygments.lexers.shell import BashSessionLexer +from pygments.token import Token + +if t.TYPE_CHECKING: + from pygments.lexer import Lexer + from sphinx.application import Sphinx + + +# Pattern detectors run in order; first match wins. Anchored at start +# AND end of string (the literal's full text) so prose containing a +# stray ``:foo:`` substring elsewhere in a sentence cannot trigger +# false positives — only inline literals whose ENTIRE content matches +# one of these shapes get highlighted. + +# Bare RST role / option / directive-attribute name like ``:tool:``, +# ``:argparse:program:``, ``:package:``. Word characters and hyphens +# only between the colons. +_BARE_RST_ROLE_RE = re.compile(r"^:[\w-]+(?::[\w-]+)*:$") + +# RST role with a backtick body: ``:tool:`list_sessions```, +# ``:argparse:program:`myapp```. Body content is permissive (any non- +# backtick run) since RstLexer handles whatever's inside. +_RST_ROLE_WITH_CONTENT_RE = re.compile(r"^:[\w-]+(?::[\w-]+)*:`[^`]+`$") + +# Shell session: starts with ``$ ``. Single line — inline literals +# can't span multiple lines, but we replace any embedded ``\n`` with a +# space defensively (django-docutils precedent). +_SHELL_SESSION_RE = re.compile(r"^\$ ") + +# Inline RST directive reference: ``.. ::`` optionally followed +# by an argument. The two leading dots + the name + the closing ``::`` +# are the recognizable shape. +_INLINE_RST_DIRECTIVE_RE = re.compile(r"^\.\.\s+[\w-]+::") + + +class _InlineFormatter(HtmlFormatter[str]): + r"""Pygments HTML formatter tuned for inline output. + + With ``nowrap=True`` the formatter emits raw ```` tokens with + no surrounding ``
`` or ``
``. This override additionally + strips the trailing ``(Token.Text, '\n')`` that every Pygments + lexer appends at end-of-stream — that newline would render as a + phantom whitespace span tight against the closing ```` tag + in inline contexts. Pattern lifted from + :class:`django_docutils.lib.transforms.code.InlineHtmlFormatter`. + """ + + def format_unencoded( + self, + tokensource: t.Iterable[tuple[t.Any, str]], + outfile: t.Any, + ) -> None: + """Trim the trailing newline token before delegating to ``HtmlFormatter``.""" + tokens = list(tokensource) + if tokens and tokens[-1] == (Token.Text, "\n"): + tokens = tokens[:-1] + super().format_unencoded(iter(tokens), outfile) + + +_FORMATTER = _InlineFormatter(nowrap=True) + + +def _highlight(text: str, lexer: Lexer) -> str: + """Return inner pygments span markup for ``text`` tokenized by ``lexer``.""" + out = io.StringIO() + _FORMATTER.format(lexer.get_tokens(text), out) + return out.getvalue().rstrip("\n") + + +def _detect_lexer(text: str) -> Lexer | None: + """Return the pygments lexer that should tokenize ``text``, or ``None``. + + Returns ``None`` when no pattern matches — caller leaves the + ``nodes.literal`` unchanged in that case. + + Examples + -------- + >>> _detect_lexer(":tool:`list_sessions`").__class__.__name__ + 'RstLexer' + >>> _detect_lexer("$ uv run pytest").__class__.__name__ + 'BashSessionLexer' + >>> _detect_lexer(".. autodirective::").__class__.__name__ + 'RstLexer' + >>> _detect_lexer("just plain prose") is None + True + """ + if _SHELL_SESSION_RE.match(text): + return BashSessionLexer() + if _RST_ROLE_WITH_CONTENT_RE.match(text): + return RstLexer() + if _INLINE_RST_DIRECTIVE_RE.match(text): + return RstLexer() + return None + + +def _bare_rst_role_html(text: str) -> str: + """Wrap a bare RST role token in a ``Name.Attribute`` (``na``) span. + + :class:`RstLexer` does not recognize bare ``:role:`` patterns + (without a backtick body) — it tokenizes them as plain ``Token.Text``. + Emit the ``Name.Attribute`` span explicitly so the ``Role`` columns + in package reference tables get the same colored treatment as the + matching ``Example`` columns. + + Examples + -------- + >>> _bare_rst_role_html(":tool:") + ':tool:' + >>> _bare_rst_role_html(":argparse:program:") + ':argparse:program:' + """ + return f'{html.escape(text)}' + + +def _inline_html_for(text: str) -> str | None: + """Return the inner HTML for ``text`` (token spans), or ``None``. + + ``None`` signals "no pattern matched, leave the literal alone." + """ + if _BARE_RST_ROLE_RE.match(text): + return _bare_rst_role_html(text) + lexer = _detect_lexer(text) + if lexer is None: + return None + return _highlight(text, lexer) + + +class InlineHighlightTransform(Transform): + """Apply Pygments highlighting to ``nodes.literal`` matching known shapes. + + Walks every ``nodes.literal`` in the resolved doctree, dispatches by + content pattern (see module docstring), and replaces matched nodes + with ``nodes.raw`` containing ``…spans…``. The outer ```` element + is preserved so Furo's existing inline-literal box styling applies + unchanged — pygments tokens add color only. + + Priority 120 follows the + :class:`django_docutils.lib.transforms.code.CodeTransform` precedent + — runs after docutils' default transforms but before the writer. + """ + + default_priority = 120 + + def apply(self, **kwargs: t.Any) -> None: + """Walk literals, replace matched ones with token-span raw HTML.""" + for node in list(self.document.findall(nodes.literal)): + text = node.astext() + if not text: + continue + # Inline literals can't span lines, but defensive normalize + # — docutils sometimes leaks newlines from soft-wrapped + # source. Match the django-docutils CodeTransform behavior. + normalized = text.strip().replace("\n", " ") + + inner = _inline_html_for(normalized) + if inner is None: + continue + + wrapped = ( + f'{inner}' + ) + replacement = nodes.raw("", wrapped, format="html") + if node.parent is not None: + node.replace_self(replacement) + + +def setup(app: Sphinx) -> dict[str, object]: + """Register :class:`InlineHighlightTransform` with Sphinx. + + Examples + -------- + >>> class _FakeApp: + ... transforms: t.ClassVar[list[type]] = [] + ... def add_transform(self, cls: type) -> None: + ... self.transforms.append(cls) + >>> app = _FakeApp() + >>> meta = setup(app) # type: ignore[arg-type] + >>> InlineHighlightTransform in app.transforms + True + >>> meta["parallel_read_safe"] + True + """ + app.add_transform(InlineHighlightTransform) + return { + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 3209d00f..16bfe3ca 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -61,12 +61,14 @@ import importlib import inspect +import json import logging import os import pathlib import pkgutil import sys import typing as t +from dataclasses import dataclass, field from sphinx.util.docutils import SphinxDirective @@ -83,6 +85,169 @@ logger = logging.getLogger(__name__) +PackageState = t.Literal["shipped-py", "shipped-js", "emerging"] + + +@dataclass(frozen=True) +class DocsOpts: + """Per-package overrides parsed from ``[tool.gp-sphinx.docs]``. + + Attributes + ---------- + omit + Subpages this package opts out of (e.g. tokens packages omit + ``tutorial`` and ``examples``). + extra + Subpages beyond the Diátaxis defaults (e.g. ``errors``, ``cli``, + ``tokens``). + showcase + Optional Sublimity subpages this package opts into (subset of + ``signatures``, ``kitchen-sink``, ``surface-diff``, ``dependents``). + reference_link + When set, the Reference card on the landing redirects to this + docname rather than rendering ``packages//reference``. + + Examples + -------- + >>> DocsOpts().omit + () + >>> DocsOpts(extra=("errors",)).extra + ('errors',) + """ + + omit: tuple[str, ...] = () + extra: tuple[str, ...] = () + showcase: tuple[str, ...] = () + reference_link: str | None = None + + +@dataclass(frozen=True) +class PackageDocsRecord: + """Single source of truth for a workspace package's docs metadata. + + Populated once at workspace discovery; every directive downstream + reads from this record rather than re-parsing manifest files. + + Attributes + ---------- + name + Distribution name (``"sphinx-autodoc-fastmcp"``, + ``"@gp-sphinx/furo-tokens"``). + state + Manifest probe result: ``"shipped-py"`` (has ``pyproject.toml``), + ``"shipped-js"`` (has ``package.json`` only), or ``"emerging"`` + (no manifest yet). + cluster + Sidebar cluster the package belongs to (e.g. ``"autodoc"``). + package_dir + Directory of the package under ``packages/``. + manifest_path + Path to the manifest file (``pyproject.toml`` or + ``package.json``); ``None`` for emerging packages. + src_dir + Path to the package's ``src/`` directory; ``None`` if absent. + module_name + Importable Python module name (only meaningful for shipped-py). + description + One-line synopsis for the landing page. + version + Package version string from the manifest; empty for emerging. + repository_url + GitHub URL. + pypi_url + PyPI project URL; ``None`` for shipped-js and emerging. + npm_url + npm registry URL; ``None`` for shipped-py and emerging. + maturity + Short label (``"Alpha"``, ``"Beta"``, ``"Production/Stable"``, + ``"Unknown"``). + docs_opts + Parsed ``[tool.gp-sphinx.docs]`` overrides (empty if section + absent or manifest is ``package.json``). + + Examples + -------- + >>> records = workspace_package_records() + >>> shipped_py = [r for r in records if r.state == "shipped-py"] + >>> "gp-sphinx" in {r.name for r in shipped_py} + True + """ + + name: str + state: PackageState + cluster: str + package_dir: pathlib.Path + manifest_path: pathlib.Path | None + src_dir: pathlib.Path | None + module_name: str + description: str + version: str + repository_url: str + pypi_url: str | None + npm_url: str | None + maturity: str + docs_opts: DocsOpts = field(default_factory=DocsOpts) + + +_CLUSTER_FOR_NAME: dict[str, str] = { + "gp-sphinx": "theme-coordinator", + "sphinx-gp-theme": "theme-coordinator", + "gp-furo-theme": "theme-coordinator", + "sphinx-serene-theme": "theme-coordinator", + "gp-furo-tokens": "tokens", + "gp-serene-tokens": "tokens", + "@gp-sphinx/furo-tokens": "tokens", + "@gp-sphinx/serene-tokens": "tokens", + "sphinx-fonts": "tokens", + "sphinx-ux-badges": "ux", + "sphinx-ux-autodoc-layout": "ux", + "sphinx-vite-builder": "build-seo", + "sphinx-gp-opengraph": "build-seo", + "sphinx-gp-sitemap": "build-seo", +} + + +def _cluster_for(name: str) -> str: + """Return the sidebar cluster a package belongs to. + + Examples + -------- + >>> _cluster_for("sphinx-autodoc-fastmcp") + 'autodoc' + >>> _cluster_for("gp-sphinx") + 'theme-coordinator' + >>> _cluster_for("sphinx-ux-badges") + 'ux' + """ + if name in _CLUSTER_FOR_NAME: + return _CLUSTER_FOR_NAME[name] + if name.startswith("sphinx-autodoc-"): + return "autodoc" + return "unknown" + + +def _docs_opts_from_pyproject(table: dict[str, t.Any]) -> DocsOpts: + """Parse ``[tool.gp-sphinx.docs]`` overrides from a pyproject TOML dict. + + Examples + -------- + >>> _docs_opts_from_pyproject({}).extra + () + >>> opts = _docs_opts_from_pyproject( + ... {"tool": {"gp-sphinx": {"docs": {"extra": ["errors"]}}}} + ... ) + >>> opts.extra + ('errors',) + """ + section = table.get("tool", {}).get("gp-sphinx", {}).get("docs", {}) + return DocsOpts( + omit=tuple(section.get("omit", [])), + extra=tuple(section.get("extra", [])), + showcase=tuple(section.get("showcase", [])), + reference_link=section.get("reference_link"), + ) + + class SurfaceDict(t.TypedDict): """Collected extension surface rows keyed by registration category.""" @@ -152,6 +317,152 @@ def workspace_packages() -> list[dict[str, str]]: return packages +def workspace_package_records() -> list[PackageDocsRecord]: + """Return every workspace package directory as a :class:`PackageDocsRecord`. + + Probes ``pyproject.toml`` first; falls back to ``package.json`` for + JS-only packages; classifies as ``"emerging"`` when neither manifest + is present. Records are returned sorted by directory name. + + Unlike :func:`workspace_packages` this includes JS-only and emerging + packages and surfaces the parsed ``[tool.gp-sphinx.docs]`` overrides. + + Examples + -------- + >>> records = workspace_package_records() + >>> "gp-sphinx" in {r.name for r in records} + True + >>> states = {r.state for r in records} + >>> states <= {"shipped-py", "shipped-js", "emerging"} + True + """ + packages_dir = workspace_root() / "packages" + records: list[PackageDocsRecord] = [] + for pkg_dir in sorted(packages_dir.iterdir()): + if not pkg_dir.is_dir(): + continue + record = _package_record_from_dir(pkg_dir) + if record is not None: + records.append(record) + return records + + +def _package_record_from_dir(pkg_dir: pathlib.Path) -> PackageDocsRecord | None: + """Build a :class:`PackageDocsRecord` for a single package directory. + + Returns ``None`` for directories that do not look like a workspace + package at all (e.g. an ``__pycache__/`` slipped in). + """ + pyproject_path = pkg_dir / "pyproject.toml" + package_json_path = pkg_dir / "package.json" + src_dir = pkg_dir / "src" + src_module_dir: pathlib.Path | None = None + if src_dir.is_dir(): + src_module_dir = next( + (path for path in src_dir.iterdir() if path.is_dir()), + None, + ) + + if pyproject_path.is_file(): + with pyproject_path.open("rb") as handle: + table = tomllib.load(handle) + project = table.get("project") + if not isinstance(project, dict): + return None + if src_module_dir is None: + return None + name = str(project["name"]) + return PackageDocsRecord( + name=name, + state="shipped-py", + cluster=_cluster_for(name), + package_dir=pkg_dir, + manifest_path=pyproject_path, + src_dir=src_dir, + module_name=src_module_dir.name, + description=str(project.get("description", "")), + version=str(project.get("version", "")), + repository_url=str(project.get("urls", {}).get("Repository", "")), + pypi_url=f"https://pypi.org/project/{name}/", + npm_url=None, + maturity=maturity_from_classifiers( + t.cast("list[str]", project.get("classifiers", [])), + ), + docs_opts=_docs_opts_from_pyproject(table), + ) + + if package_json_path.is_file(): + manifest = json.loads(package_json_path.read_text(encoding="utf-8")) + name = str(manifest.get("name", pkg_dir.name)) + # The npm web UI accepts the literal ``@scope/name`` form for + # scoped packages — percent-encoding the ``/`` (or stripping the + # ``@``) produces a 404. Pass the manifest name straight through. + npm_slug = name if name else pkg_dir.name + return PackageDocsRecord( + name=name, + state="shipped-js", + cluster=_cluster_for(name), + package_dir=pkg_dir, + manifest_path=package_json_path, + src_dir=src_dir if src_dir.is_dir() else None, + module_name="", + description=str(manifest.get("description", "")), + version=str(manifest.get("version", "")), + repository_url=_repository_url_from_package_json(manifest), + pypi_url=None, + npm_url=f"https://www.npmjs.com/package/{npm_slug}", + maturity="Unknown", + ) + + return PackageDocsRecord( + name=pkg_dir.name, + state="emerging", + cluster=_cluster_for(pkg_dir.name), + package_dir=pkg_dir, + manifest_path=None, + src_dir=src_dir if src_dir.is_dir() else None, + module_name="", + description="", + version="", + repository_url="", + pypi_url=None, + npm_url=None, + maturity="Unknown", + ) + + +def _repository_url_from_package_json(manifest: dict[str, t.Any]) -> str: + """Extract a GitHub URL from a ``package.json`` ``repository`` field. + + Accepts either the string form (``"github:owner/repo"``) or the + object form (``{"type": "git", "url": "..."}``). + + Examples + -------- + >>> _repository_url_from_package_json({}) + '' + >>> _repository_url_from_package_json({"repository": "github:git-pull/x"}) + 'https://github.com/git-pull/x' + >>> _repository_url_from_package_json( + ... {"repository": {"url": "git+https://github.com/git-pull/x.git"}} + ... ) + 'https://github.com/git-pull/x' + """ + repo = manifest.get("repository") + if isinstance(repo, str): + if repo.startswith("github:"): + return f"https://github.com/{repo[len('github:') :]}" + return repo + if isinstance(repo, dict): + url = str(repo.get("url", "")) + if url.startswith("git+"): + url = url[len("git+") :] + if url.endswith(".git"): + url = url[: -len(".git")] + return url + return "" + + def maturity_from_classifiers(classifiers: list[str]) -> str: """Return the short maturity label derived from project classifiers. @@ -535,8 +846,8 @@ def package_reference_markdown(package_name: str) -> str: -------- >>> "Copyable config snippet" in package_reference_markdown("sphinx-fonts") True - >>> "pypi.org/project/sphinx-fonts" in package_reference_markdown("sphinx-fonts") - True + >>> "## Package metadata" in package_reference_markdown("sphinx-fonts") + False >>> package_reference_markdown("nonexistent-package") '' """ @@ -569,18 +880,12 @@ def package_reference_markdown(package_name: str) -> str: lines.extend(["]", "```", ""]) - if package["repository"]: - pypi_url = f"https://pypi.org/project/{package_name}/" - lines.extend( - [ - "## Package metadata", - "", - f"- Source on GitHub: [{package_name}]({package['repository']}/tree/main/packages/{package_name})", - f"- PyPI: [{package_name}]({pypi_url})", - f"- Maturity: `{package['maturity']}`", - "", - ], - ) + # NOTE: The "Package metadata" section (GitHub + PyPI + Maturity) + # was dropped here in commit C2 of the per-package docs restructure. + # The same surface is conveyed once per page by the + # gp-sphinx-package-meta directive (docs/_ext/sab_meta.py); rendering + # it again as a paragraph below the conf snippet was duplication. + # The badge row remains the only metadata surface on the landing. if package_name == "gp-sphinx": lines.extend( @@ -615,16 +920,85 @@ def maturity_badge(maturity: str) -> str: return f"{{bdg-secondary-line}}`{maturity}`" -def workspace_package_grid_markdown() -> str: - """Render the package index grid from workspace metadata. +_CLUSTER_HEADINGS: tuple[tuple[str, str, str], ...] = ( + ( + "theme-coordinator", + "Theme & coordinator", + "Shared Sphinx configuration and presentation surface.", + ), + ( + "tokens", + "Tokens", + "Design tokens, fonts, and shared CSS custom properties.", + ), + ( + "autodoc", + "Autodoc extensions", + "Domain-specific autodoc extensions: each adds directives that " + "generate documentation from a particular source-construct family.", + ), + ( + "ux", + "UX components", + "Badge primitives, layout presenters, and other shared " + "rendering helpers consumed by the autodoc family.", + ), + ( + "build-seo", + "Build & SEO", + "PEP 517 backends, build orchestration, and crawl-indexing " + "extensions auto-loaded by gp-sphinx when ``docs_url`` is set.", + ), +) + + +def _grid_card_lines_for_record(record: PackageDocsRecord) -> list[str]: + """Render one ``{grid-item-card}`` block for a workspace record.""" + if record.state == "emerging": + # Emerging packages have no per-package landing yet — link to + # the GitHub directory (or repo root) rather than a 404. + link = record.repository_url or "https://github.com/git-pull/gp-sphinx" + return [ + f":::{{grid-item-card}} {record.name}", + f":link: {link}", + "", + "Coming soon — see GitHub for status.", + "", + ":::", + "", + ] + + return [ + f":::{{grid-item-card}} {record.name}", + f":link: {_grid_link_for_legacy_record(record.name)}", + ":link-type: doc", + "", + record.description, + "", + "+++", + maturity_badge(record.maturity), + ":::", + "", + ] + + +def _grid_link_for_legacy_record(name: str) -> str: + """Return the docname a legacy ``:link:`` entry should target. - Examples - -------- - >>> "grid-item-card" in workspace_package_grid_markdown() - True - >>> "+++" in workspace_package_grid_markdown() - True + Picks ``/index`` when a per-package directory has migrated + (``docs/packages//index.md`` exists); falls back to the + flat ```` docname otherwise. Lets the workspace grid keep + rendering during the per-package migration window without + emitting unknown-document warnings. """ + docs_root = workspace_root() / "docs" / "packages" + if (docs_root / name / "index.md").is_file(): + return f"{name}/index" + return name + + +def _flat_workspace_grid_markdown() -> str: + """Render the legacy single-grid layout (no per-cluster headings).""" lines = [ "::::{grid} 1 1 2 2", ":gutter: 2 2 3 3", @@ -634,7 +1008,7 @@ def workspace_package_grid_markdown() -> str: lines.extend( [ f":::{{grid-item-card}} {package['name']}", - f":link: {package['name']}", + f":link: {_grid_link_for_legacy_record(package['name'])}", ":link-type: doc", "", str(package["description"]), @@ -649,6 +1023,77 @@ def workspace_package_grid_markdown() -> str: return "\n".join(lines) +def _grouped_workspace_grid_markdown() -> str: + """Render the workspace inventory as one ``{grid}`` block per cluster. + + Each cluster gets a heading + framing prose + a grid containing + only the records assigned to that cluster (Shipped + Emerging). + Emerging cards link to the GitHub directory rather than a + landing docname so the build does not 404. + """ + records = workspace_package_records() + by_cluster: dict[str, list[PackageDocsRecord]] = {} + for record in records: + by_cluster.setdefault(record.cluster, []).append(record) + + lines: list[str] = [] + for cluster_id, heading, prose in _CLUSTER_HEADINGS: + members = sorted( + by_cluster.get(cluster_id, []), + key=lambda r: r.name, + ) + if not members: + continue + lines.extend( + [ + f"## {heading}", + "", + prose, + "", + "::::{grid} 1 1 2 2", + ":gutter: 2 2 3 3", + "", + ], + ) + for member in members: + lines.extend(_grid_card_lines_for_record(member)) + lines.append("::::") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def workspace_package_grid_markdown(*, groups: str | None = None) -> str: + """Render the workspace package index grid. + + Parameters + ---------- + groups + ``None`` (default) renders the legacy single grid of every + Python-shipped package — backward compatible with existing + ``{workspace-package-grid}`` invocations. ``"by-cluster"`` + emits one grid per sidebar cluster, with cluster headings, + framing prose, and Emerging packages rendered as + GitHub-linked cards. + + Examples + -------- + >>> "grid-item-card" in workspace_package_grid_markdown() + True + >>> "+++" in workspace_package_grid_markdown() + True + >>> "## Autodoc extensions" in workspace_package_grid_markdown( + ... groups="by-cluster" + ... ) + True + """ + if groups is None: + return _flat_workspace_grid_markdown() + if groups == "by-cluster": + return _grouped_workspace_grid_markdown() + msg = f"unsupported groups argument: {groups!r}" + raise ValueError(msg) + + def _register_extension_objects( app: t.Any, env: t.Any, @@ -678,10 +1123,25 @@ def _register_extension_objects( except (KeyError, AttributeError, ImportError): return - for package in workspace_packages(): - pkg_docname = f"packages/{package['name']}" + found_docs: set[str] = getattr(env, "found_docs", set()) + + for record in workspace_package_records(): + if record.state != "shipped-py": + # Emerging records have no source; shipped-js has no Python + # module to introspect. Either way, nothing to register. + continue - for ext_module_name in extension_modules(package["module_name"]): + # Prefer the per-package reference subpage when the package has + # migrated (its docname is in env.found_docs); fall back to the + # legacy flat page during the migration window so xrefs keep + # resolving for un-migrated packages. + reference_docname = f"packages/{record.name}/reference" + flat_docname = f"packages/{record.name}" + pkg_docname = ( + reference_docname if reference_docname in found_docs else flat_docname + ) + + for ext_module_name in extension_modules(record.module_name): recorder = replay_setup(ext_module_name) if recorder is None: continue @@ -720,6 +1180,369 @@ def _register_extension_objects( ) +def _subpage_target_exists(env: t.Any, target: str) -> bool: + """Return ``True`` if ``target`` resolves to an existing docname. + + Accepts a same-directory subpage name (``"how-to"``) — resolved + relative to the current document — or an absolute docname + (``"packages/sphinx-fonts/index"``). + + Examples + -------- + >>> class _E: + ... found_docs = {"packages/foo/index", "packages/foo/how-to"} + ... docname = "packages/foo/tutorial" + >>> _subpage_target_exists(_E(), "how-to") + True + >>> _subpage_target_exists(_E(), "errors") + False + >>> _subpage_target_exists(_E(), "packages/foo/index") + True + """ + found: set[str] = getattr(env, "found_docs", set()) + if target in found: + return True + current = getattr(env, "docname", "") + if "/" in current: + prefix = current.rsplit("/", 1)[0] + "/" + if (prefix + target) in found: + return True + return False + + +def subpage_exists_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: t.Any, + options: dict[str, t.Any] | None = None, + content: list[str] | None = None, +) -> tuple[list[t.Any], list[t.Any]]: + """Implement the ``{subpage-exists}``` MyST role. + + Renders a Sphinx ``:doc:`` cross-reference when ```` resolves + to an existing docname (sibling-relative or absolute); otherwise + emits plain text so the build does not fail. Used in tutorial / + how-to "Where to next" sections so prose never refers to absent + subpages. + + Examples + -------- + >>> import types + >>> env = types.SimpleNamespace( + ... found_docs={"packages/foo/index", "packages/foo/how-to"}, + ... docname="packages/foo/tutorial", + ... ) + >>> inliner = types.SimpleNamespace( + ... document=types.SimpleNamespace( + ... settings=types.SimpleNamespace(env=env), + ... ), + ... ) + >>> ref_nodes, msgs = subpage_exists_role( + ... "subpage-exists", "how-to", "how-to", 0, inliner, + ... ) + >>> ref_nodes[0]["reftype"], ref_nodes[0]["reftarget"] + ('doc', 'how-to') + >>> msgs + [] + >>> miss_nodes, msgs = subpage_exists_role( + ... "subpage-exists", "errors", "errors", 0, inliner, + ... ) + >>> miss_nodes[0].astext() + 'errors' + >>> msgs + [] + """ + from docutils import nodes as docutils_nodes + from sphinx import addnodes + + text_clean = text.strip() + env = inliner.document.settings.env + + if _subpage_target_exists(env, text_clean): + ref = addnodes.pending_xref( + rawtext, + refdomain="std", + reftype="doc", + reftarget=text_clean, + refexplicit=False, + refwarn=True, + ) + ref += docutils_nodes.inline(rawtext, text_clean, classes=["xref", "doc"]) + return [ref], [] + + return [docutils_nodes.inline(rawtext, text_clean)], [] + + +_DEFAULT_LANDING_SUBPAGES: tuple[str, ...] = ( + "tutorial", + "how-to", + "reference", + "explanation", + "examples", +) + +_OCTICONS: dict[str, str] = { + "tutorial": "rocket", + "how-to": "tools", + "reference": "book", + "explanation": "light-bulb", + "examples": "star", + "errors": "alert", + "cli": "terminal", + "tokens": "paintbrush", + "signatures": "code", + "kitchen-sink": "device-camera", + "surface-diff": "diff", + "dependents": "link", +} + +_TITLES: dict[str, str] = { + "tutorial": "Tutorial", + "how-to": "How to", + "reference": "API Reference", + "explanation": "Explanation", + "examples": "Examples", + "errors": "Errors", + "cli": "CLI", + "tokens": "Tokens", + "signatures": "Signatures", + "kitchen-sink": "Kitchen sink", + "surface-diff": "Surface diff", + "dependents": "Dependents", +} + +_DEFAULT_SUMMARIES: dict[str, str] = { + "tutorial": "Get started in ten minutes.", + "how-to": "Task recipes for common workflows.", + "reference": "Every directive, role, and config value.", + "explanation": "Why the package is shaped this way.", + "examples": "Live demos rendered from real code.", + "errors": "Named failure modes and what to do about them.", + "cli": "Command-line surface and modes.", + "tokens": "Design-token tables and CSS custom properties.", + "signatures": "Runtime-rendered signatures and drift alerts.", + "kitchen-sink": "Every directive exercised on one page.", + "surface-diff": "What changed since the last release.", + "dependents": "Workspace packages that import this one.", +} + + +def _candidate_subpage_paths(record: PackageDocsRecord) -> dict[str, pathlib.Path]: + """Return the on-disk paths the landing checks for each candidate subpage. + + Combines: + + * the Diátaxis defaults (tutorial / how-to / reference / explanation / + examples) + * ``[tool.gp-sphinx.docs].extra`` entries (errors / cli / tokens / …) + * ``[tool.gp-sphinx.docs].showcase`` entries (signatures / + kitchen-sink / surface-diff / dependents) + + The landing renders only those subpage cards whose target file + exists. Currently looks in ``docs/packages//.md``; + a future commit will also probe the co-located + ``packages//docs/`` tree. + """ + docs_root = workspace_root() / "docs" / "packages" / record.name + subpages = ( + list(_DEFAULT_LANDING_SUBPAGES) + + list(record.docs_opts.extra) + + list(record.docs_opts.showcase) + ) + return {sub: docs_root / f"{sub}.md" for sub in subpages} + + +def _package_landing_markdown( + record: PackageDocsRecord, + present_subpages: list[str], +) -> str: + """Render the per-package landing markdown for ``record``. + + The caller is responsible for env.note_dependency() on the candidate + paths; this helper is pure (string in -> string out). + """ + # The stub at docs/packages//index.md carries the anchor + H1 + # so Sphinx determines the page title at parse time (without it the + # parent toctree promotes the page's children to its own level). + # The directive emits everything else: meta badges, synopsis, grid, + # hidden toctree. + meta = f"```{{gp-sphinx-package-meta}} {record.name}\n```" + synopsis = ( + f"> {record.description}" + if record.description + else "> No description provided." + ) + + lines: list[str] = [ + meta, + "", + synopsis, + "", + ] + + if present_subpages: + # Sphinx-design's {grid} directive requires at least one + # {grid-item-card} child — emit the grid only when we have + # subpages to show. Packages with no subpages yet (newly + # added, JS-only awaiting authoring) render meta + synopsis + # without the grid. + lines.extend( + [ + "::::{grid} 1 2 2 3", + ":gutter: 2 2 3 3", + ":class-container: gp-sphinx-package__landing-grid", + "", + ], + ) + for subpage in present_subpages: + icon = _OCTICONS.get(subpage, "link") + title_text = _TITLES.get(subpage, subpage.replace("-", " ").title()) + summary = _DEFAULT_SUMMARIES.get(subpage, "") + lines.extend( + [ + f":::{{grid-item-card}} {{octicon}}`{icon}` {title_text}", + f":link: {subpage}", + ":link-type: doc", + summary, + ":::", + "", + ], + ) + lines.append("::::") + lines.append("") + lines.append("```{toctree}") + lines.append(":hidden:") + lines.append("") + lines.extend(present_subpages) + lines.append("```") + lines.append("") + return "\n".join(lines) + + +class PackageLandingDirective(SphinxDirective): + """Render a synthesized landing page for a workspace package. + + Emits ``gp-sphinx-package-meta`` + synopsis + a conditional grid of + cards over only those Diátaxis subpages whose target markdown + exists on disk + a hidden toctree. + + Calls ``env.note_dependency()`` on every candidate subpage path so + incremental builds rebuild the landing when an author drops a new + ``tutorial.md`` (or removes one) without a clean rebuild. + + Usage in ``docs/packages//index.md``:: + + ```{package-landing} sphinx-autodoc-fastmcp + ``` + """ + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0].strip() + record = next( + (r for r in workspace_package_records() if r.name == package_name), + None, + ) + if record is None: + logger.warning("package-landing: unknown package %r", package_name) + return [] + + candidates = _candidate_subpage_paths(record) + present: list[str] = [] + for subpage, path in candidates.items(): + self.env.note_dependency(str(path)) + if path.is_file(): + present.append(subpage) + + markdown = _package_landing_markdown(record, present) + return self.parse_text_to_nodes(markdown) + + +def _cluster_toctree_markdown( + cluster: str, + *, + caption: str | None, + titlesonly: bool, +) -> str: + """Render a hidden toctree of every Shipped package in ``cluster``. + + Emerging packages are silently skipped at emit time so the + toctree never references a docname Sphinx has not discovered — + this prevents cluster-toctree-on-Emerging crashes (Risk: see + Group B2 commit message). + + Each entry points at ``packages//index`` (the per-package + landing stub). Entries are sorted alphabetically within the + cluster so the sidebar reads predictably. + """ + members = sorted( + record.name + for record in workspace_package_records() + if record.cluster == cluster and record.state in {"shipped-py", "shipped-js"} + ) + if not members: + return "" + + lines: list[str] = ["```{toctree}"] + if caption is not None: + lines.append(f":caption: {caption}") + lines.append(":hidden:") + if titlesonly: + lines.append(":titlesonly:") + lines.append("") + lines.extend(f"packages/{name}/index" for name in members) + lines.append("```") + return "\n".join(lines) + + +class ClusterToctreeDirective(SphinxDirective): + """Render a hidden toctree of every Shipped package in a sidebar cluster. + + Replaces the seven hand-edited toctree blocks in ``docs/index.md`` + with a single source of truth: package classifier plus + ``[tool.gp-sphinx.docs]`` overrides drive both the workspace grid + and the sidebar. + + Usage in ``docs/index.md``:: + + ```{cluster-toctree} autodoc + :caption: Autodoc + :titlesonly: + ``` + + Skips Emerging packages so the build does not reference a missing + docname. + """ + + required_arguments = 1 + has_content = False + option_spec = { # noqa: RUF012 + "caption": lambda v: str(v).strip(), + "titlesonly": lambda v: True if v is None else bool(v), + } + + def run(self) -> list[nodes.Node]: + cluster = self.arguments[0].strip() + caption = self.options.get("caption") + titlesonly = "titlesonly" in self.options + markdown = _cluster_toctree_markdown( + cluster, + caption=caption, + titlesonly=titlesonly, + ) + if not markdown: + logger.warning( + "cluster-toctree: no Shipped packages found in cluster %r", + cluster, + ) + return [] + return self.parse_text_to_nodes(markdown) + + class PackageReferenceDirective(SphinxDirective): """Render a generated package reference block inside a page.""" @@ -731,13 +1554,444 @@ def run(self) -> list[nodes.Node]: return self.parse_text_to_nodes(package_reference_markdown(package_name)) +def _public_callables(module_name: str) -> list[tuple[str, str]]: + """Return ``(qualname, signature)`` pairs for the module's public callables. + + Imports ``module_name`` once, walks ``dir(module)`` for callables + not starting with ``_``, and renders each via :func:`inspect.signature`. + Errors during inspection are logged and skipped so a single drift + doesn't break the build. + """ + try: + module = importlib.import_module(module_name) + except ImportError: + logger.warning("live-signature: could not import %r", module_name) + return [] + + pairs: list[tuple[str, str]] = [] + for name in sorted(dir(module)): + if name.startswith("_"): + continue + obj = getattr(module, name) + if not callable(obj): + continue + if getattr(obj, "__module__", None) != module_name: + continue # re-exports are documented in their owning module + try: + sig = str(inspect.signature(obj)) + except (TypeError, ValueError): + continue + pairs.append((name, sig)) + return pairs + + +def _live_signature_markdown(package_name: str) -> str: + """Render the live-signature subpage content for a workspace package.""" + record = next( + (r for r in workspace_package_records() if r.name == package_name), + None, + ) + if record is None or record.state != "shipped-py": + return "" + + pairs = _public_callables(record.module_name) + if not pairs: + return "" + + # Body-only: the surrounding stub at packages//.md + # provides the page anchor and H1 so Sphinx finds a page title at + # parse time (same constraint the package-landing directive learned + # in E2). Directives that emit H1 via parse_text_to_nodes do NOT + # set the page title — Sphinx's title extraction has already run. + lines = [ + f"Public callables in `{record.module_name}` rendered from the " + "running interpreter at docs-build time. Drift between this " + "block and the prose elsewhere on the page indicates a stale " + "docstring or signature comment.", + "", + ] + for name, sig in pairs: + lines.append(f"### `{name}`") + lines.append("") + lines.append("```python") + lines.append(f"def {name}{sig}:") + lines.append(" ...") + lines.append("```") + lines.append("") + return "\n".join(lines) + + +class LiveSignatureDirective(SphinxDirective): + """Render runtime-introspected signatures for a workspace package's module. + + Imports the package's module and emits a ``### `` section per + public callable showing its live signature. Use on a package's + ``signatures.md`` subpage when the author has opted in via + ``[tool.gp-sphinx.docs].showcase = ["signatures"]``. + + No inline JavaScript is emitted by this directive; the signatures + are rendered server-side at build time. (Risk 7 in the woven + plan — Cloudflare Rocket Loader interaction — does not apply.) + + Usage in ``packages//docs/signatures.md``:: + + ```{live-signature} sphinx-fonts + ``` + """ + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0].strip() + markdown = _live_signature_markdown(package_name) + if not markdown: + logger.warning( + "live-signature: no public callables for %r", + package_name, + ) + return [] + return self.parse_text_to_nodes(markdown) + + +def _kitchen_sink_markdown(package_name: str) -> str: + """Render a kitchen-sink subpage exercising every directive a package registers. + + Reads the package's collected surface (via collect_extension_surface) + and emits one example invocation per directive. Roles get an inline + cross-reference example. The page is a single discoverable place + where every directive and role is exercised, useful for quick + visual regressions and as a reference card for downstream authors. + """ + record = next( + (r for r in workspace_package_records() if r.name == package_name), + None, + ) + if record is None or record.state != "shipped-py": + return "" + + blocks = [ + collect_extension_surface(module) + for module in extension_modules(record.module_name) + ] + directives_seen: list[str] = [] + roles_seen: list[str] = [] + for block in blocks: + directives_seen.extend(item["name"] for item in block["directives"]) + roles_seen.extend(item["name"] for item in block["roles"]) + + if not directives_seen and not roles_seen: + return "" + + # Body-only: stub supplies anchor + H1 so Sphinx finds a page title. + lines = [ + "Every directive and role this package registers, exercised once " + "on the same page — useful as a reference card for downstream " + "authors and as a visual-regression target.", + "", + ] + if directives_seen: + lines.append("## Directives") + lines.append("") + for name in sorted(set(directives_seen)): + lines.append(f"### `{name}`") + lines.append("") + # Use the rst lang tag (pygments alias "rst" -> RstLexer) + # so the .. directive:: invocation syntax tokenizes — + # without it the block ships as
+ # (no token spans, plain text). RstLexer renders ".." as + # Punctuation, the directive name as Operator.Word, and + # "::" as Punctuation, matching the visual treatment the + # parallel package tutorials get via {eval-rst} fences. + lines.append("```rst") + lines.append(f".. {name}::") + lines.append("```") + lines.append("") + if roles_seen: + lines.append("## Roles") + lines.append("") + for name in sorted(set(roles_seen)): + lines.append(f"- `:{name}:` cross-reference") + lines.append("") + return "\n".join(lines) + + +class PackageKitchenSinkDirective(SphinxDirective): + """Render a kitchen-sink page exercising every directive a package registers. + + Renders one example block per directive plus a list of registered + roles. Used in the package's optional ``kitchen-sink.md`` showcase + subpage when the author has opted in via + ``[tool.gp-sphinx.docs].showcase = ["kitchen-sink"]``. + + Pairs with an out-of-band ``tox -e docs-screenshot`` Playwright + job that captures the rendered HTML as a PNG for + ``sphinx-gp-opengraph`` to use as the per-package OG image. + The screenshot step is **not** part of the docs build, so the + "pure function of disk state" CI gate (Risk 3) is not affected. + + Usage in ``packages//docs/kitchen-sink.md``:: + + ```{package-kitchen-sink} sphinx-autodoc-fastmcp + ``` + """ + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0].strip() + markdown = _kitchen_sink_markdown(package_name) + if not markdown: + logger.warning( + "package-kitchen-sink: no surface for %r", + package_name, + ) + return [] + return self.parse_text_to_nodes(markdown) + + +def _surface_snapshot_path(package_name: str) -> pathlib.Path: + """Return the path to a package's stored surface snapshot.""" + return ( + workspace_root() + / "docs" + / "_static" + / "surface-snapshots" + / f"{package_name}.json" + ) + + +def _current_surface_keys(record: PackageDocsRecord) -> set[str]: + """Return the union of registered directives + roles + config-values names. + + A flat ``set[str]`` of ``":"`` keys per registered + surface item is enough to detect adds / removes between releases. + """ + blocks = [ + collect_extension_surface(module) + for module in extension_modules(record.module_name) + ] + keys: set[str] = set() + for block in blocks: + keys.update(f"directive:{item['name']}" for item in block["directives"]) + keys.update(f"role:{item['name']}" for item in block["roles"]) + keys.update(f"config:{item['name']}" for item in block["config_values"]) + return keys + + +def _surface_changelog_markdown(package_name: str) -> str: + """Render the surface-diff subpage comparing live surface vs snapshot. + + Reads ``docs/_static/surface-snapshots/.json`` (a JSON + array of surface keys captured at the previous release tag). + Renders Added / Removed / Unchanged sections. + """ + record = next( + (r for r in workspace_package_records() if r.name == package_name), + None, + ) + if record is None or record.state != "shipped-py": + return "" + + current = _current_surface_keys(record) + snapshot_path = _surface_snapshot_path(package_name) + if snapshot_path.is_file(): + snapshot_keys = set(json.loads(snapshot_path.read_text(encoding="utf-8"))) + else: + snapshot_keys = set() + + added = sorted(current - snapshot_keys) + removed = sorted(snapshot_keys - current) + unchanged = sorted(current & snapshot_keys) + + # Body-only: stub supplies anchor + H1 so Sphinx finds a page title. + lines = [ + "Comparison of the package's currently-registered directives, " + "roles, and config values against the snapshot stored at " + f"`docs/_static/surface-snapshots/{package_name}.json`.", + "", + ] + if not snapshot_path.is_file(): + lines.append( + "**No prior snapshot recorded.** Capture the current surface " + f"by writing it to `docs/_static/surface-snapshots/{package_name}.json` " + "before the next release.", + ) + lines.append("") + if added: + lines.append("## Added") + lines.append("") + lines.extend(f"- `{key}`" for key in added) + lines.append("") + if removed: + lines.append("## Removed") + lines.append("") + lines.extend(f"- `{key}`" for key in removed) + lines.append("") + if unchanged: + lines.append(f"## Unchanged ({len(unchanged)})") + lines.append("") + lines.append("Stable across this release window.") + lines.append("") + return "\n".join(lines) + + +class SurfaceChangelogDirective(SphinxDirective): + """Diff a package's current surface against a snapshotted previous version. + + Reads the JSON snapshot at + ``docs/_static/surface-snapshots/.json`` (typically + captured at the previous release tag) and reports Added / + Removed / Unchanged surface keys. Use on the package's optional + ``surface-diff.md`` showcase subpage. + + Usage in ``packages//docs/surface-diff.md``:: + + ```{surface-changelog} sphinx-autodoc-fastmcp + ``` + """ + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0].strip() + markdown = _surface_changelog_markdown(package_name) + if not markdown: + logger.warning( + "surface-changelog: nothing to diff for %r", + package_name, + ) + return [] + return self.parse_text_to_nodes(markdown) + + +def _package_dependents(target: str) -> list[str]: + """Return workspace packages that depend on ``target`` per pyproject.toml. + + Walks every shipped-py record and reads the ``[project].dependencies`` + array from its manifest, plus ``[tool.uv.sources]`` for workspace + pin entries. Returns the set of dependents sorted alphabetically. + """ + dependents: set[str] = set() + for record in workspace_package_records(): + if record.state != "shipped-py" or record.manifest_path is None: + continue + if record.name == target: + continue + with record.manifest_path.open("rb") as handle: + manifest = tomllib.load(handle) + deps = manifest.get("project", {}).get("dependencies", []) + for dep_spec in deps: + # dep_spec is e.g. "sphinx-ux-badges>=0.0.1" or just "sphinx-ux-badges" + dep_name = ( + str(dep_spec) + .split(">")[0] + .split("=")[0] + .split("<")[0] + .split("!")[0] + .split("~")[0] + .split(";")[0] + .strip() + # PEP 508: extras may appear in []; strip them + .split("[")[0] + .strip() + ) + if dep_name == target: + dependents.add(record.name) + return sorted(dependents) + + +def _package_dependents_markdown(package_name: str) -> str: + """Render the dependents subpage for a package. + + Reverse-intersphinx: which workspace packages import or extend + ``package_name``? Each becomes a Sphinx ``:doc:`` cross-reference + so navigation lands on the dependent's per-package landing. + """ + record = next( + (r for r in workspace_package_records() if r.name == package_name), + None, + ) + if record is None: + return "" + + dependents = _package_dependents(package_name) + # Body-only: stub supplies anchor + H1 so Sphinx finds a page title. + lines = [ + f"Workspace packages that declare a `{package_name}` dependency in " + "their `pyproject.toml` `[project].dependencies` array.", + "", + ] + if not dependents: + lines.append( + "_No workspace package currently depends on this one._", + ) + lines.append("") + else: + # Use absolute docnames (leading slash) so cross-references + # resolve from the source root, not the current page's dir. + lines.extend(f"- {{doc}}`/packages/{dep}/index`" for dep in dependents) + lines.append("") + return "\n".join(lines) + + +class PackageDependentsDirective(SphinxDirective): + """Render reverse-intersphinx: workspace packages that depend on this one. + + Walks every shipped-py package's ``pyproject.toml`` and lists + those whose ``[project].dependencies`` include the named package. + Use on the package's optional ``dependents.md`` showcase subpage. + + Usage in ``packages//docs/dependents.md``:: + + ```{package-dependents} sphinx-ux-badges + ``` + """ + + required_arguments = 1 + has_content = False + + def run(self) -> list[nodes.Node]: + package_name = self.arguments[0].strip() + markdown = _package_dependents_markdown(package_name) + if not markdown: + logger.warning( + "package-dependents: unknown package %r", + package_name, + ) + return [] + return self.parse_text_to_nodes(markdown) + + class WorkspacePackageGridDirective(SphinxDirective): - """Render the packages index grid from workspace package metadata.""" + """Render the workspace package index grid. + + By default emits a single grid of every Python-shipped package + (backward compatible). Pass ``:groups: by-cluster`` to instead + emit one grid per sidebar cluster, with headings, framing prose, + and Emerging packages rendered as GitHub-linked cards. + + Usage in ``docs/packages/index.md``:: + + ```{workspace-package-grid} + ``` + + ```{workspace-package-grid} + :groups: by-cluster + ``` + """ has_content = False + option_spec = { # noqa: RUF012 + "groups": lambda v: str(v).strip(), + } def run(self) -> list[nodes.Node]: - return self.parse_text_to_nodes(workspace_package_grid_markdown()) + groups = self.options.get("groups") + markdown = workspace_package_grid_markdown(groups=groups) + return self.parse_text_to_nodes(markdown) def setup(app: t.Any) -> dict[str, object]: @@ -751,8 +2005,15 @@ def setup(app: t.Any) -> dict[str, object]: True """ ensure_workspace_imports() + app.add_directive("package-landing", PackageLandingDirective) + app.add_directive("cluster-toctree", ClusterToctreeDirective) + app.add_directive("live-signature", LiveSignatureDirective) + app.add_directive("package-kitchen-sink", PackageKitchenSinkDirective) + app.add_directive("surface-changelog", SurfaceChangelogDirective) + app.add_directive("package-dependents", PackageDependentsDirective) app.add_directive("package-reference", PackageReferenceDirective) app.add_directive("workspace-package-grid", WorkspacePackageGridDirective) + app.add_role("subpage-exists", subpage_exists_role) app.connect("env-check-consistency", _register_extension_objects) return { "parallel_read_safe": True, diff --git a/docs/_ext/sab_meta.py b/docs/_ext/sab_meta.py index 521f9421..3e5d2c38 100644 --- a/docs/_ext/sab_meta.py +++ b/docs/_ext/sab_meta.py @@ -54,25 +54,41 @@ def _link_badge(label: str, url: str) -> nodes.reference: def _package_meta_nodes(package_name: str) -> list[nodes.Node]: - """Return inline badge nodes for a workspace package.""" - packages = {p["name"]: p for p in package_reference.workspace_packages()} - pkg = packages.get(package_name) - if pkg is None: + """Return inline badge nodes for a workspace package. + + Reads the dual-source workspace inventory (``workspace_package_records``) + so JS-only packages (``state="shipped-js"``) get an npm badge instead + of a PyPI badge, and Emerging packages get a maturity-only badge row + without a registry link. + """ + record = next( + ( + r + for r in package_reference.workspace_package_records() + if r.name == package_name + ), + None, + ) + if record is None: msg = nodes.inline(text=f"[unknown package: {package_name!r}]") return [msg] - maturity = pkg.get("maturity", "Unknown") - repo = pkg.get("repository", "") - github_url = repo if repo else "https://github.com/git-pull/gp-sphinx" - pypi_url = f"https://pypi.org/project/{package_name}/" + github_url = ( + record.repository_url + if record.repository_url + else "https://github.com/git-pull/gp-sphinx" + ) badge_nodes: list[nodes.Node] = [ - _maturity_badge(maturity), + _maturity_badge(record.maturity), nodes.Text(" "), _link_badge("GitHub", github_url), - nodes.Text(" "), - _link_badge("PyPI", pypi_url), ] + # Append the registry badge appropriate to the package's state. + if record.pypi_url is not None: + badge_nodes.extend([nodes.Text(" "), _link_badge("PyPI", record.pypi_url)]) + elif record.npm_url is not None: + badge_nodes.extend([nodes.Text(" "), _link_badge("npm", record.npm_url)]) return badge_nodes diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 5c0cf0ea..c9d18837 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -521,3 +521,35 @@ article > section > p:first-of-type > .sd-badge.sd-bg-info { text-transform: uppercase; vertical-align: middle; } + +/* Per-package landing layout — rendered by PackageLandingDirective. + * + * The directive emits :class-container: gp-sphinx-package__landing-grid + * (BEM-style with double underscore per the workspace CSS standards in + * CLAUDE.md). docutils normalizes underscores in HTML class attributes + * to hyphens, so the rendered class is gp-sphinx-package-landing-grid + * (single dash). Style both forms so the rule applies to whichever is + * present. + * + * Specificity stays at 0,1,0 per the workspace CSS standards. */ +.gp-sphinx-package__landing-grid, +.gp-sphinx-package-landing-grid { + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +/* The synopsis beneath the meta-badge row is rendered as a MyST + * block-quote (> text) which Furo styles with strong vertical-rule + * formatting. Tighten the margin-bottom below the synopsis so the + * grid above gets visible breathing room — Furo's default leaves the + * blockquote flush against following content. */ +article > section > blockquote:has(+ .gp-sphinx-package-landing-grid), +article > section > blockquote:has(+ .gp-sphinx-package__landing-grid) { + margin-bottom: 1.25rem; +} + +.gp-sphinx-package__hero { + font-size: 1.05rem; + color: var(--color-foreground-secondary); + margin: 0.5rem 0 1rem 0; +} diff --git a/docs/_static/surface-snapshots/.gitkeep b/docs/_static/surface-snapshots/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/architecture.md b/docs/architecture.md index 210ee2f3..f79dbbc8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ The rendering pipeline that every autodoc extension consumes: :gutter: 2 :::{grid-item-card} sphinx-ux-badges -:link: packages/sphinx-ux-badges +:link: packages/sphinx-ux-badges/index :link-type: doc Badge primitives, colour palette, and CSS infrastructure. @@ -26,7 +26,7 @@ All badge colours live in one place (`SAB.*` constants). ::: :::{grid-item-card} sphinx-ux-autodoc-layout -:link: packages/sphinx-ux-autodoc-layout +:link: packages/sphinx-ux-autodoc-layout/index :link-type: doc Structural presenter for `api-*` entry components. @@ -34,7 +34,7 @@ Parameter folding, managed signatures, card regions. ::: :::{grid-item-card} sphinx-autodoc-typehints-gp -:link: packages/sphinx-autodoc-typehints-gp +:link: packages/sphinx-autodoc-typehints-gp/index :link-type: doc Annotation normalization and type rendering. @@ -54,7 +54,7 @@ source-construct family: :gutter: 2 :::{grid-item-card} sphinx-autodoc-api-style -:link: packages/sphinx-autodoc-api-style +:link: packages/sphinx-autodoc-api-style/index :link-type: doc **Subject**: standard Python. @@ -62,7 +62,7 @@ source-construct family: ::: :::{grid-item-card} sphinx-autodoc-argparse -:link: packages/sphinx-autodoc-argparse +:link: packages/sphinx-autodoc-argparse/index :link-type: doc **Subject**: argparse parsers — programs, options, subcommands, positionals. @@ -70,7 +70,7 @@ source-construct family: ::: :::{grid-item-card} sphinx-autodoc-docutils -:link: packages/sphinx-autodoc-docutils +:link: packages/sphinx-autodoc-docutils/index :link-type: doc **Subject**: docutils directives and roles. @@ -78,7 +78,7 @@ source-construct family: ::: :::{grid-item-card} sphinx-autodoc-fastmcp -:link: packages/sphinx-autodoc-fastmcp +:link: packages/sphinx-autodoc-fastmcp/index :link-type: doc **Subject**: FastMCP tools, prompts, resources. @@ -86,7 +86,7 @@ source-construct family: ::: :::{grid-item-card} sphinx-autodoc-pytest-fixtures -:link: packages/sphinx-autodoc-pytest-fixtures +:link: packages/sphinx-autodoc-pytest-fixtures/index :link-type: doc **Subject**: pytest fixtures (extends the `py` domain). @@ -94,7 +94,7 @@ source-construct family: ::: :::{grid-item-card} sphinx-autodoc-sphinx -:link: packages/sphinx-autodoc-sphinx +:link: packages/sphinx-autodoc-sphinx/index :link-type: doc **Subject**: Sphinx config values. @@ -111,10 +111,10 @@ package to their `extensions` list. | Package | Role | |---------|------| -| {doc}`gp-sphinx ` | Coordinator. `merge_sphinx_config()` wires up the full stack. | -| {doc}`sphinx-gp-theme ` | Furo-based theme with CSS variables and SPA navigation. | -| {doc}`gp-furo-theme ` | Tailwind v4 port of upstream Furo for git-pull projects. | -| {doc}`sphinx-fonts ` | IBM Plex via Fontsource — preloaded web fonts. | +| {doc}`gp-sphinx ` | Coordinator. `merge_sphinx_config()` wires up the full stack. | +| {doc}`sphinx-gp-theme ` | Furo-based theme with CSS variables and SPA navigation. | +| {doc}`gp-furo-theme ` | Tailwind v4 port of upstream Furo for git-pull projects. | +| {doc}`sphinx-fonts ` | IBM Plex via Fontsource — preloaded web fonts. | ## Build tooling @@ -130,7 +130,7 @@ and seamless live-reload during authoring. :gutter: 2 :::{grid-item-card} sphinx-vite-builder -:link: packages/sphinx-vite-builder +:link: packages/sphinx-vite-builder/index :link-type: doc [PEP 517](https://peps.python.org/pep-0517/) build backend (or @@ -151,11 +151,11 @@ gp-sphinx coordinator. Every autodoc extension shares the same badge palette, the same componentized HTML output structure, and the same type annotation -pipeline — so [Python APIs](packages/sphinx-autodoc-api-style.md), -[pytest fixtures](packages/sphinx-autodoc-pytest-fixtures.md), -[Sphinx config values](packages/sphinx-autodoc-sphinx.md), -[docutils directives](packages/sphinx-autodoc-docutils.md), and -[FastMCP tools](packages/sphinx-autodoc-fastmcp.md) all look like +pipeline — so [Python APIs](packages/sphinx-autodoc-api-style/index.md), +[pytest fixtures](packages/sphinx-autodoc-pytest-fixtures/index.md), +[Sphinx config values](packages/sphinx-autodoc-sphinx/index.md), +[docutils directives](packages/sphinx-autodoc-docutils/index.md), and +[FastMCP tools](packages/sphinx-autodoc-fastmcp/index.md) all look like they belong together. This is the **one autodoc design system** principle: a change to the shared diff --git a/docs/conf.py b/docs/conf.py index 71235d09..6912f4d8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -76,6 +76,7 @@ docs_url=gp_sphinx.__docs__, source_branch="main", extra_extensions=[ + "inline_highlight", "package_reference", "sab_demo", "sab_meta", @@ -89,7 +90,9 @@ "sphinx_ux_autodoc_layout", ], fastmcp_tool_modules=["fastmcp_demo_tools"], - fastmcp_area_map={"fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp"}, + fastmcp_area_map={ + "fastmcp_demo_tools": "packages/sphinx-autodoc-fastmcp/examples", + }, fastmcp_collector_mode="introspect", api_layout_enabled=True, api_collapsed_threshold=10, diff --git a/docs/index.md b/docs/index.md index 84f6033e..dacefaa8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,54 +98,22 @@ project/index history ``` -```{toctree} -:caption: Domain Packages -:hidden: - -packages/sphinx-autodoc-api-style -packages/sphinx-autodoc-argparse -packages/sphinx-autodoc-docutils -packages/sphinx-autodoc-fastmcp -packages/sphinx-autodoc-pytest-fixtures -packages/sphinx-autodoc-sphinx +```{cluster-toctree} autodoc +:caption: Autodoc ``` -```{toctree} +```{cluster-toctree} ux :caption: UX -:hidden: - -packages/sphinx-fonts -packages/sphinx-ux-autodoc-layout -packages/sphinx-ux-badges -``` - -```{toctree} -:caption: Utils -:hidden: - -packages/sphinx-autodoc-typehints-gp ``` -```{toctree} -:caption: Internal -:hidden: - -packages/gp-sphinx -packages/sphinx-gp-theme -packages/gp-furo-theme +```{cluster-toctree} tokens +:caption: Tokens ``` -```{toctree} -:caption: Build utils -:hidden: - -packages/sphinx-vite-builder +```{cluster-toctree} theme-coordinator +:caption: Theme & coordinator ``` -```{toctree} -:caption: SEO -:hidden: - -packages/sphinx-gp-opengraph -packages/sphinx-gp-sitemap +```{cluster-toctree} build-seo +:caption: Build & SEO ``` diff --git a/docs/packages/@gp-sphinx/furo-tokens/index.md b/docs/packages/@gp-sphinx/furo-tokens/index.md new file mode 100644 index 00000000..75779e5f --- /dev/null +++ b/docs/packages/@gp-sphinx/furo-tokens/index.md @@ -0,0 +1,6 @@ +(@gp-sphinx/furo-tokens)= + +# @gp-sphinx/furo-tokens + +```{package-landing} @gp-sphinx/furo-tokens +``` diff --git a/docs/packages/gp-furo-theme/dependents.md b/docs/packages/gp-furo-theme/dependents.md new file mode 100644 index 00000000..5bdc0e8a --- /dev/null +++ b/docs/packages/gp-furo-theme/dependents.md @@ -0,0 +1,6 @@ +(gp-furo-theme-dependents)= + +# Dependents + +```{package-dependents} gp-furo-theme +``` diff --git a/docs/packages/gp-furo-theme.md b/docs/packages/gp-furo-theme/how-to.md similarity index 88% rename from docs/packages/gp-furo-theme.md rename to docs/packages/gp-furo-theme/how-to.md index d856927e..eca70a9c 100644 --- a/docs/packages/gp-furo-theme.md +++ b/docs/packages/gp-furo-theme/how-to.md @@ -1,17 +1,6 @@ -# gp-furo-theme +(gp-furo-theme-how-to)= -```{gp-sphinx-package-meta} gp-furo-theme -``` - -Tailwind-v4-driven port of @pradyunsg's [Furo] Sphinx theme. Templates, -JavaScript, Python hooks, and theme options are ported verbatim from -upstream [Furo]. The CSS pipeline is **pure Tailwind v4** — no SASS — -authored as plain `.css` files under `web/src/styles/components/` and -compiled by [Vite] with the `@tailwindcss/vite` plugin. - -```console -$ pip install gp-furo-theme -``` +# How to ## Downstream `conf.py` diff --git a/docs/packages/gp-furo-theme/index.md b/docs/packages/gp-furo-theme/index.md new file mode 100644 index 00000000..2c23741e --- /dev/null +++ b/docs/packages/gp-furo-theme/index.md @@ -0,0 +1,6 @@ +(gp-furo-theme)= + +# gp-furo-theme + +```{package-landing} gp-furo-theme +``` diff --git a/docs/packages/gp-furo-theme/signatures.md b/docs/packages/gp-furo-theme/signatures.md new file mode 100644 index 00000000..dfa24497 --- /dev/null +++ b/docs/packages/gp-furo-theme/signatures.md @@ -0,0 +1,6 @@ +(gp-furo-theme-signatures)= + +# Signatures (live) + +```{live-signature} gp-furo-theme +``` diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx/how-to.md similarity index 68% rename from docs/packages/gp-sphinx.md rename to docs/packages/gp-sphinx/how-to.md index 898c872c..2afdad33 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx/how-to.md @@ -1,27 +1,6 @@ -# gp-sphinx +(gp-sphinx-how-to)= -```{gp-sphinx-package-meta} gp-sphinx -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Shared configuration coordinator for Sphinx projects. {py:func}`~gp_sphinx.config.merge_sphinx_config` -builds a complete `conf.py` namespace from the workspace defaults and leaves -per-project overrides in one place. - -```console -$ pip install gp-sphinx -``` - -```console -$ uv add gp-sphinx -``` +# How to ## Downstream `conf.py` @@ -49,7 +28,7 @@ globals().update(conf) - Shared extension defaults, theme defaults, fonts, MyST, napoleon, copybutton, and rediraffe settings. - Auto-computed `issue_url_tpl` and theme source-repository wiring from `source_repository`. -- Auto-computed SEO values when `docs_url` is set: `ogp_site_url`, `ogp_site_name`, `ogp_image` for {doc}`sphinx-gp-opengraph`, plus `site_url` and `sitemap_url_scheme` for {doc}`sphinx-gp-sitemap`. See {ref}`from-docs_url` for the canonical mapping. +- Auto-computed SEO values when `docs_url` is set: `ogp_site_url`, `ogp_site_name`, `ogp_image` for {doc}`/packages/sphinx-gp-opengraph/index`, plus `site_url` and `sitemap_url_scheme` for {doc}`/packages/sphinx-gp-sitemap/index`. See {ref}`from-docs_url` for the canonical mapping. - A `setup(app)` hook that registers `js/spa-nav.js` and removes `tabs.js` after HTML builds. - Support for appending {py:mod}`sphinx:sphinx.ext.linkcode` automatically when `linkcode_resolve` is supplied in `**overrides`. @@ -62,8 +41,8 @@ See {doc}`/configuration` for the complete parameter reference and every shared that calls `merge_sphinx_config()` loads them automatically. Passing `docs_url=` is the only step required for default SEO emission — gp-sphinx fills in the upstream config keys both extensions need. -Per-package details live on the {doc}`sphinx-gp-opengraph` and -{doc}`sphinx-gp-sitemap` pages. +Per-package details live on the {doc}`/packages/sphinx-gp-opengraph/index` and +{doc}`/packages/sphinx-gp-sitemap/index` pages. :::{admonition} Live example This site is built with `gp-sphinx`, using the same integration pattern shown diff --git a/docs/packages/gp-sphinx/index.md b/docs/packages/gp-sphinx/index.md new file mode 100644 index 00000000..c66c4ec2 --- /dev/null +++ b/docs/packages/gp-sphinx/index.md @@ -0,0 +1,6 @@ +(gp-sphinx)= + +# gp-sphinx + +```{package-landing} gp-sphinx +``` diff --git a/docs/packages/index.md b/docs/packages/index.md index 07df90e9..170e8631 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -5,7 +5,7 @@ organized by family. Each package has its own page; the {ref}`grid below ` auto-enumerates the full set as the workspace evolves. -[`gp-sphinx`](gp-sphinx.md) is the umbrella entry point — its +[`gp-sphinx`](gp-sphinx/index.md) is the umbrella entry point — its `merge_sphinx_config()` wires up the full stack for downstream projects in ~10 lines of `conf.py`. Every other package is opt-in and independently installable. @@ -14,42 +14,42 @@ and independently installable. The rendering pipeline every autodoc extension consumes: -- [`sphinx-ux-badges`](sphinx-ux-badges.md) — badge primitives and colour palette -- [`sphinx-ux-autodoc-layout`](sphinx-ux-autodoc-layout.md) — structural presenter for `api-*` entry components -- [`sphinx-autodoc-typehints-gp`](sphinx-autodoc-typehints-gp.md) — annotation normalization and type rendering -- [`sphinx-fonts`](sphinx-fonts.md) — IBM Plex font preloading +- [`sphinx-ux-badges`](sphinx-ux-badges/index.md) — badge primitives and colour palette +- [`sphinx-ux-autodoc-layout`](sphinx-ux-autodoc-layout/index.md) — structural presenter for `api-*` entry components +- [`sphinx-autodoc-typehints-gp`](sphinx-autodoc-typehints-gp/index.md) — annotation normalization and type rendering +- [`sphinx-fonts`](sphinx-fonts/index.md) — IBM Plex font preloading ## Autodoc extensions Domain-specific [autodoc extensions](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) — each adds directives that generate documentation from a particular source-construct family (Python APIs, argparse parsers, pytest fixtures, etc.): -- [`sphinx-autodoc-api-style`](sphinx-autodoc-api-style.md) — Python API rendering style -- [`sphinx-autodoc-argparse`](sphinx-autodoc-argparse.md) — argparse parsers + subcommands -- [`sphinx-autodoc-docutils`](sphinx-autodoc-docutils.md) — docutils directives + nodes -- [`sphinx-autodoc-fastmcp`](sphinx-autodoc-fastmcp.md) — FastMCP tools, prompts, resources -- [`sphinx-autodoc-pytest-fixtures`](sphinx-autodoc-pytest-fixtures.md) — pytest fixtures -- [`sphinx-autodoc-sphinx`](sphinx-autodoc-sphinx.md) — Sphinx config values +- [`sphinx-autodoc-api-style`](sphinx-autodoc-api-style/index.md) — Python API rendering style +- [`sphinx-autodoc-argparse`](sphinx-autodoc-argparse/index.md) — argparse parsers + subcommands +- [`sphinx-autodoc-docutils`](sphinx-autodoc-docutils/index.md) — docutils directives + nodes +- [`sphinx-autodoc-fastmcp`](sphinx-autodoc-fastmcp/index.md) — FastMCP tools, prompts, resources +- [`sphinx-autodoc-pytest-fixtures`](sphinx-autodoc-pytest-fixtures/index.md) — pytest fixtures +- [`sphinx-autodoc-sphinx`](sphinx-autodoc-sphinx/index.md) — Sphinx config values ## Build utils [PEP 517](https://peps.python.org/pep-0517/) backends and orchestration helpers for theme asset pipelines: -- [`sphinx-vite-builder`](sphinx-vite-builder.md) — [PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm +- [`sphinx-vite-builder`](sphinx-vite-builder/index.md) — [PEP 517](https://peps.python.org/pep-0517/) backend + Sphinx extension that runs Vite via pnpm ## Theme and coordinator Shared Sphinx configuration and presentation assets: -- [`gp-sphinx`](gp-sphinx.md) — umbrella coordinator (`merge_sphinx_config()`) -- [`sphinx-gp-theme`](sphinx-gp-theme.md) — Furo child theme with the gp-sphinx default palette -- [`gp-furo-theme`](gp-furo-theme.md) — Tailwind v4 port of upstream Furo for git-pull projects +- [`gp-sphinx`](gp-sphinx/index.md) — umbrella coordinator (`merge_sphinx_config()`) +- [`sphinx-gp-theme`](sphinx-gp-theme/index.md) — Furo child theme with the gp-sphinx default palette +- [`gp-furo-theme`](gp-furo-theme/index.md) — Tailwind v4 port of upstream Furo for git-pull projects ## SEO Meta-tag and crawlability extensions auto-loaded by `gp-sphinx` when `docs_url` is set: -- [`sphinx-gp-opengraph`](sphinx-gp-opengraph.md) — Open Graph + Twitter Card meta tags -- [`sphinx-gp-sitemap`](sphinx-gp-sitemap.md) — `sitemap.xml` for crawl indexing +- [`sphinx-gp-opengraph`](sphinx-gp-opengraph/index.md) — Open Graph + Twitter Card meta tags +- [`sphinx-gp-sitemap`](sphinx-gp-sitemap/index.md) — `sitemap.xml` for crawl indexing ## Design philosophy diff --git a/docs/packages/sphinx-autodoc-api-style.md b/docs/packages/sphinx-autodoc-api-style.md deleted file mode 100644 index 407ed114..00000000 --- a/docs/packages/sphinx-autodoc-api-style.md +++ /dev/null @@ -1,165 +0,0 @@ -(sphinx-autodoc-api-style)= - -# sphinx-autodoc-api-style - -```{gp-sphinx-package-meta} sphinx-autodoc-api-style -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Sphinx extension that adds type and modifier badges to standard Python domain -entries (functions, classes, methods, properties, attributes, data, -exceptions). Mirrors the badge system from -{doc}`sphinx-autodoc-pytest-fixtures` so API pages and fixture pages share a -consistent visual language. - -```console -$ pip install sphinx-autodoc-api-style -``` - -## Features - -- **Type badges** (rightmost): `function`, `class`, `method`, `property`, - `attribute`, `data`, `exception` — each with a distinct color -- **Modifier badges** (left of type): `async`, `classmethod`, `staticmethod`, - `abstract`, `final`, `deprecated` -- **Card containers**: bordered cards with secondary-background headers -- **Dark mode**: full light/dark theming via CSS custom properties -- **Accessibility**: keyboard-focusable badges with tooltip popups -- **Non-invasive**: hooks into `doctree-resolved` without replacing directives - -## Downstream `conf.py` - -Add `sphinx_autodoc_api_style` to your Sphinx extensions. With `gp-sphinx`, -use `extra_extensions`: - -```python -conf = merge_sphinx_config( - project="my-project", - version="1.0.0", - copyright="2026, Your Name", - source_repository="https://github.com/your-org/my-project/", - extra_extensions=["sphinx_autodoc_api_style"], -) -``` - -Or without `merge_sphinx_config`: - -```python -extensions = ["sphinx_autodoc_api_style"] -``` - -`sphinx_autodoc_api_style` automatically registers `sphinx_ux_badges` and -`sphinx_ux_autodoc_layout` via `app.setup_extension()`. You do not need to add -them separately to your `extensions` list. - -## Working usage examples - -No special directives are needed — existing `.. autofunction::`, -`.. autoclass::`, `.. automodule::` directives automatically receive badges. - -Render one function: - -````myst -```{eval-rst} -.. autofunction:: my_project.api.demo_function -``` -```` - -Render one class and its members: - -````myst -```{eval-rst} -.. autoclass:: my_project.api.DemoClass - :members: -``` -```` - -## Live demos - -```{py:module} gp_demo_api -``` - -### Functions - -```{eval-rst} -.. autofunction:: gp_demo_api.demo_function -``` - -```{eval-rst} -.. autofunction:: gp_demo_api.demo_async_function -``` - -```{eval-rst} -.. autofunction:: gp_demo_api.demo_deprecated_function -``` - -### Module data - -```{eval-rst} -.. autodata:: gp_demo_api.DEMO_CONSTANT -``` - -### Exceptions - -```{eval-rst} -.. autoexception:: gp_demo_api.DemoError -``` - -### Classes - -```{eval-rst} -.. autoclass:: gp_demo_api.DemoClass - :members: - :undoc-members: -``` - -### Abstract base classes - -```{eval-rst} -.. autoclass:: gp_demo_api.DemoAbstractBase - :members: -``` - -## Badge reference - -All badge classes are drawn from the shared `sphinx_ux_badges.SAB` palette. -This extension uses: - -| Object type | `SAB` constant | CSS class | -|---|---|---| -| `function` | `SAB.TYPE_FUNCTION` | `gp-sphinx-badge--type-function` | -| `class` | `SAB.TYPE_CLASS` | `gp-sphinx-badge--type-class` | -| `method` | `SAB.TYPE_METHOD` | `gp-sphinx-badge--type-method` | -| `property` | `SAB.TYPE_PROPERTY` | `gp-sphinx-badge--type-property` | -| `attribute` | `SAB.TYPE_ATTRIBUTE` | `gp-sphinx-badge--type-attribute` | -| `data` | `SAB.TYPE_DATA` | `gp-sphinx-badge--type-data` | -| `exception` | `SAB.TYPE_EXCEPTION` | `gp-sphinx-badge--type-exception` | - -| Modifier | `SAB` constant | CSS class | -|---|---|---| -| `async` | `SAB.MOD_ASYNC` | `gp-sphinx-badge--mod-async` | -| `classmethod` | `SAB.MOD_CLASSMETHOD` | `gp-sphinx-badge--mod-classmethod` | -| `staticmethod` | `SAB.MOD_STATICMETHOD` | `gp-sphinx-badge--mod-staticmethod` | -| `abstract` | `SAB.MOD_ABSTRACT` | `gp-sphinx-badge--mod-abstract` | -| `final` | `SAB.MOD_FINAL` | `gp-sphinx-badge--mod-final` | -| `deprecated` | `SAB.STATE_DEPRECATED` | `gp-sphinx-badge--state-deprecated` | - -See {doc}`sphinx-ux-badges` for the full shared palette. - -## CSS prefix - -All badge CSS classes use the `sab-` prefix from {doc}`sphinx-ux-badges`. -Layout card classes (borders, headers, field-list rules) are local to this package -and use `dl.py-*` and `.api-*` selectors. - -```{package-reference} sphinx-autodoc-api-style -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-api-style) · [PyPI](https://pypi.org/project/sphinx-autodoc-api-style/) diff --git a/docs/packages/sphinx-autodoc-api-style/examples.md b/docs/packages/sphinx-autodoc-api-style/examples.md new file mode 100644 index 00000000..02856839 --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style/examples.md @@ -0,0 +1,49 @@ +(sphinx-autodoc-api-style-examples)= + +# Examples + +## Live demos + +```{py:module} gp_demo_api +``` + +### Functions + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_function +``` + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_async_function +``` + +```{eval-rst} +.. autofunction:: gp_demo_api.demo_deprecated_function +``` + +### Module data + +```{eval-rst} +.. autodata:: gp_demo_api.DEMO_CONSTANT +``` + +### Exceptions + +```{eval-rst} +.. autoexception:: gp_demo_api.DemoError +``` + +### Classes + +```{eval-rst} +.. autoclass:: gp_demo_api.DemoClass + :members: + :undoc-members: +``` + +### Abstract base classes + +```{eval-rst} +.. autoclass:: gp_demo_api.DemoAbstractBase + :members: +``` diff --git a/docs/packages/sphinx-autodoc-api-style/how-to.md b/docs/packages/sphinx-autodoc-api-style/how-to.md new file mode 100644 index 00000000..e244dd78 --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style/how-to.md @@ -0,0 +1,50 @@ +(sphinx-autodoc-api-style-how-to)= + +# How to + +## Features + +- **Type badges** (rightmost): `function`, `class`, `method`, `property`, + `attribute`, `data`, `exception` — each with a distinct color +- **Modifier badges** (left of type): `async`, `classmethod`, `staticmethod`, + `abstract`, `final`, `deprecated` +- **Card containers**: bordered cards with secondary-background headers +- **Dark mode**: full light/dark theming via CSS custom properties +- **Accessibility**: keyboard-focusable badges with tooltip popups +- **Non-invasive**: hooks into `doctree-resolved` without replacing directives + +## Downstream `conf.py` + +Add `sphinx_autodoc_api_style` to your Sphinx extensions. With `gp-sphinx`, +use `extra_extensions`: + +```python +conf = merge_sphinx_config( + project="my-project", + version="1.0.0", + copyright="2026, Your Name", + source_repository="https://github.com/your-org/my-project/", + extra_extensions=["sphinx_autodoc_api_style"], +) +``` + +Or without `merge_sphinx_config`: + +```python +extensions = ["sphinx_autodoc_api_style"] +``` + +`sphinx_autodoc_api_style` automatically registers `sphinx_ux_badges` and +`sphinx_ux_autodoc_layout` via `app.setup_extension()`. You do not need to add +them separately to your `extensions` list. + +## CSS prefix + +All badge CSS classes use the `sab-` prefix from {doc}`/packages/sphinx-ux-badges/index`. +Layout card classes (borders, headers, field-list rules) are local to this package +and use `dl.py-*` and `.api-*` selectors. + +```{package-reference} sphinx-autodoc-api-style +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-api-style) · [PyPI](https://pypi.org/project/sphinx-autodoc-api-style/) diff --git a/docs/packages/sphinx-autodoc-api-style/index.md b/docs/packages/sphinx-autodoc-api-style/index.md new file mode 100644 index 00000000..b98f6aae --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-api-style)= + +# sphinx-autodoc-api-style + +```{package-landing} sphinx-autodoc-api-style +``` diff --git a/docs/packages/sphinx-autodoc-api-style/reference.md b/docs/packages/sphinx-autodoc-api-style/reference.md new file mode 100644 index 00000000..194391ba --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style/reference.md @@ -0,0 +1,29 @@ +(sphinx-autodoc-api-style-reference)= + +# API Reference + +## Badge reference + +All badge classes are drawn from the shared `sphinx_ux_badges.SAB` palette. +This extension uses: + +| Object type | `SAB` constant | CSS class | +|---|---|---| +| `function` | `SAB.TYPE_FUNCTION` | `gp-sphinx-badge--type-function` | +| `class` | `SAB.TYPE_CLASS` | `gp-sphinx-badge--type-class` | +| `method` | `SAB.TYPE_METHOD` | `gp-sphinx-badge--type-method` | +| `property` | `SAB.TYPE_PROPERTY` | `gp-sphinx-badge--type-property` | +| `attribute` | `SAB.TYPE_ATTRIBUTE` | `gp-sphinx-badge--type-attribute` | +| `data` | `SAB.TYPE_DATA` | `gp-sphinx-badge--type-data` | +| `exception` | `SAB.TYPE_EXCEPTION` | `gp-sphinx-badge--type-exception` | + +| Modifier | `SAB` constant | CSS class | +|---|---|---| +| `async` | `SAB.MOD_ASYNC` | `gp-sphinx-badge--mod-async` | +| `classmethod` | `SAB.MOD_CLASSMETHOD` | `gp-sphinx-badge--mod-classmethod` | +| `staticmethod` | `SAB.MOD_STATICMETHOD` | `gp-sphinx-badge--mod-staticmethod` | +| `abstract` | `SAB.MOD_ABSTRACT` | `gp-sphinx-badge--mod-abstract` | +| `final` | `SAB.MOD_FINAL` | `gp-sphinx-badge--mod-final` | +| `deprecated` | `SAB.STATE_DEPRECATED` | `gp-sphinx-badge--state-deprecated` | + +See {doc}`/packages/sphinx-ux-badges/index` for the full shared palette. diff --git a/docs/packages/sphinx-autodoc-api-style/tutorial.md b/docs/packages/sphinx-autodoc-api-style/tutorial.md new file mode 100644 index 00000000..74742e4b --- /dev/null +++ b/docs/packages/sphinx-autodoc-api-style/tutorial.md @@ -0,0 +1,25 @@ +(sphinx-autodoc-api-style-tutorial)= + +# Tutorial + +## Working usage examples + +No special directives are needed — existing `.. autofunction::`, +`.. autoclass::`, `.. automodule::` directives automatically receive badges. + +Render one function: + +````myst +```{eval-rst} +.. autofunction:: my_project.api.demo_function +``` +```` + +Render one class and its members: + +````myst +```{eval-rst} +.. autoclass:: my_project.api.DemoClass + :members: +``` +```` diff --git a/docs/packages/sphinx-autodoc-argparse/examples.md b/docs/packages/sphinx-autodoc-argparse/examples.md new file mode 100644 index 00000000..a3027870 --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse/examples.md @@ -0,0 +1,30 @@ +(sphinx-autodoc-argparse-examples)= + +# Examples + +## Live demos + +### Base parser rendering + +```{argparse} +:module: demo_cli +:func: create_parser +:prog: myapp +``` + +### Subcommand rendering + +Drill into a single subcommand with `:path:`: + +```{argparse} +:module: demo_cli +:func: create_parser +:path: mysubcommand +:prog: myapp +``` + +### Inline roles + +The exemplar layer also registers live inline roles for CLI prose: +{cli-command}`myapp`, {cli-option}`--verbose`, {cli-choice}`json`, +{cli-metavar}`DIR`, and {cli-default}`text`. diff --git a/docs/packages/sphinx-autodoc-argparse/how-to.md b/docs/packages/sphinx-autodoc-argparse/how-to.md new file mode 100644 index 00000000..9746622a --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse/how-to.md @@ -0,0 +1,50 @@ +(sphinx-autodoc-argparse-how-to)= + +# How to + +## Registered directives and roles + +### Base `argparse` directive + +```{eval-rst} +.. autodirective:: sphinx_autodoc_argparse.directive.ArgparseDirective + :no-index: +``` + +### Exemplar override + +```{eval-rst} +.. autodirective:: sphinx_autodoc_argparse.exemplar.CleanArgParseDirective +``` + +### CLI role callables + +```{eval-rst} +.. autoroles:: sphinx_autodoc_argparse.roles +``` + +## Downstream usage snippets + +Use native MyST directives in Markdown: + +````myst +```{argparse} +:module: myproject.cli +:func: create_parser +:prog: myproject +``` +```` + +Or reStructuredText: + +```rst +.. argparse:: + :module: myproject.cli + :func: create_parser + :prog: myproject +``` + +```{package-reference} sphinx-autodoc-argparse +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-argparse) · [PyPI](https://pypi.org/project/sphinx-autodoc-argparse/) diff --git a/docs/packages/sphinx-autodoc-argparse/index.md b/docs/packages/sphinx-autodoc-argparse/index.md new file mode 100644 index 00000000..5475eccd --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-argparse)= + +# sphinx-autodoc-argparse + +```{package-landing} sphinx-autodoc-argparse +``` diff --git a/docs/packages/sphinx-autodoc-argparse/kitchen-sink.md b/docs/packages/sphinx-autodoc-argparse/kitchen-sink.md new file mode 100644 index 00000000..9db79ad3 --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse/kitchen-sink.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-argparse-kitchen-sink)= + +# Kitchen sink + +```{package-kitchen-sink} sphinx-autodoc-argparse +``` diff --git a/docs/packages/sphinx-autodoc-argparse.md b/docs/packages/sphinx-autodoc-argparse/reference.md similarity index 50% rename from docs/packages/sphinx-autodoc-argparse.md rename to docs/packages/sphinx-autodoc-argparse/reference.md index 503c6b96..941b4b56 100644 --- a/docs/packages/sphinx-autodoc-argparse.md +++ b/docs/packages/sphinx-autodoc-argparse/reference.md @@ -1,55 +1,6 @@ -# sphinx-autodoc-argparse +(sphinx-autodoc-argparse-reference)= -```{gp-sphinx-package-meta} sphinx-autodoc-argparse -``` - -Modern Sphinx extension for documenting `argparse` CLIs. The base package -registers the `argparse` directive plus renderer config values; the -`sphinx_autodoc_argparse.exemplar` layer adds example extraction, lexers, and CLI -inline roles. - -```console -$ pip install sphinx-autodoc-argparse -``` - -## Working usage examples - -```python -extensions = [ - "sphinx_autodoc_argparse", - "sphinx_autodoc_argparse.exemplar", -] - -argparse_examples_section_title = "Examples" -argparse_reorder_usage_before_examples = True -``` - -## Live demos - -### Base parser rendering - -```{argparse} -:module: demo_cli -:func: create_parser -:prog: myapp -``` - -### Subcommand rendering - -Drill into a single subcommand with `:path:`: - -```{argparse} -:module: demo_cli -:func: create_parser -:path: mysubcommand -:prog: myapp -``` - -### Inline roles - -The exemplar layer also registers live inline roles for CLI prose: -{cli-command}`myapp`, {cli-option}`--verbose`, {cli-choice}`json`, -{cli-metavar}`DIR`, and {cli-default}`text`. +# API Reference ## Cross-reference roles @@ -101,50 +52,3 @@ for program-scoped clarity. ```{eval-rst} .. autoconfigvalues:: sphinx_autodoc_argparse.exemplar ``` - -## Registered directives and roles - -### Base `argparse` directive - -```{eval-rst} -.. autodirective:: sphinx_autodoc_argparse.directive.ArgparseDirective - :no-index: -``` - -### Exemplar override - -```{eval-rst} -.. autodirective:: sphinx_autodoc_argparse.exemplar.CleanArgParseDirective -``` - -### CLI role callables - -```{eval-rst} -.. autoroles:: sphinx_autodoc_argparse.roles -``` - -## Downstream usage snippets - -Use native MyST directives in Markdown: - -````myst -```{argparse} -:module: myproject.cli -:func: create_parser -:prog: myproject -``` -```` - -Or reStructuredText: - -```rst -.. argparse:: - :module: myproject.cli - :func: create_parser - :prog: myproject -``` - -```{package-reference} sphinx-autodoc-argparse -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-argparse) · [PyPI](https://pypi.org/project/sphinx-autodoc-argparse/) diff --git a/docs/packages/sphinx-autodoc-argparse/tutorial.md b/docs/packages/sphinx-autodoc-argparse/tutorial.md new file mode 100644 index 00000000..ade86df7 --- /dev/null +++ b/docs/packages/sphinx-autodoc-argparse/tutorial.md @@ -0,0 +1,15 @@ +(sphinx-autodoc-argparse-tutorial)= + +# Tutorial + +## Working usage examples + +```python +extensions = [ + "sphinx_autodoc_argparse", + "sphinx_autodoc_argparse.exemplar", +] + +argparse_examples_section_title = "Examples" +argparse_reorder_usage_before_examples = True +``` diff --git a/docs/packages/sphinx-autodoc-docutils.md b/docs/packages/sphinx-autodoc-docutils.md deleted file mode 100644 index 81569b48..00000000 --- a/docs/packages/sphinx-autodoc-docutils.md +++ /dev/null @@ -1,113 +0,0 @@ -# sphinx-autodoc-docutils - -```{gp-sphinx-package-meta} sphinx-autodoc-docutils -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Experimental Sphinx extension for documenting docutils directives and role -callables as reference material. The extension does not invent a new domain; -instead it introspects Python modules and renders copyable `rst:directive` and -`rst:role` reference blocks from the live objects. - -Those rendered entries now share the same badge, layout, and type-display -stack as the rest of the autodoc packages even though the package still keeps -its semantic `rst:*` generation path. - -```console -$ pip install sphinx-autodoc-docutils -``` - -## Downstream `conf.py` - -```python -extensions = ["sphinx_autodoc_docutils"] -``` - -`sphinx_autodoc_docutils` automatically registers `sphinx_ux_badges`, -`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. -You do not need to add them separately to your `extensions` list. - -## Working usage examples - -Use a single-object directive when you want one rendered reference entry: - -````myst -```{eval-rst} -.. autodirective:: my_project.docs_ext.MyDirective -``` -```` - -````myst -```{eval-rst} -.. autorole:: my_project.docs_roles.cli_option_role -``` -```` - -Use the bulk directives to render every directive or role a module -registers: - -````myst -```{eval-rst} -.. autodirectives:: my_project.docs_ext -``` -```` - -````myst -```{eval-rst} -.. autoroles:: my_project.docs_roles -``` -```` - -## Live demos - -This page intentionally uses directive and role autodoc to document the -documentation helpers themselves. If that feels a little recursive, that is the -point: roles and directives should be documentable the same way fixtures are. - -### Document one demo directive - -```{eval-rst} -.. autodirective:: docutils_demo.DemoBadgeDirective - :no-index: -``` - -### Document one demo role - -```{eval-rst} -.. autorole:: docutils_demo.demo_badge_role - :no-index: -``` - -### Bulk directives demo - -Renders all directive classes in a module at once: - -```{eval-rst} -.. autodirectives:: docutils_demo - :no-index: -``` - -### Bulk roles demo - -Renders all role callables in a module at once: - -```{eval-rst} -.. autoroles:: docutils_demo - :no-index: -``` - -The extension itself registers directives, not docutils roles or Sphinx config -values. The generated package reference below lists its registered surface from -the live `setup()` calls. - -```{package-reference} sphinx-autodoc-docutils -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-docutils) · [PyPI](https://pypi.org/project/sphinx-autodoc-docutils/) diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md new file mode 100644 index 00000000..c2d9d015 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -0,0 +1,50 @@ +(sphinx-autodoc-docutils-examples)= + +# Examples + +## Live demos + +This page intentionally uses directive and role autodoc to document the +documentation helpers themselves. If that feels a little recursive, that is the +point: roles and directives should be documentable the same way fixtures are. + +### Document one demo directive + +```{eval-rst} +.. autodirective:: docutils_demo.DemoBadgeDirective + :no-index: +``` + +### Document one demo role + +```{eval-rst} +.. autorole:: docutils_demo.demo_badge_role + :no-index: +``` + +### Bulk directives demo + +Renders all directive classes in a module at once: + +```{eval-rst} +.. autodirectives:: docutils_demo + :no-index: +``` + +### Bulk roles demo + +Renders all role callables in a module at once: + +```{eval-rst} +.. autoroles:: docutils_demo + :no-index: +``` + +The extension itself registers directives, not docutils roles or Sphinx config +values. The generated package reference below lists its registered surface from +the live `setup()` calls. + +```{package-reference} sphinx-autodoc-docutils +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-docutils) · [PyPI](https://pypi.org/project/sphinx-autodoc-docutils/) diff --git a/docs/packages/sphinx-autodoc-docutils/how-to.md b/docs/packages/sphinx-autodoc-docutils/how-to.md new file mode 100644 index 00000000..0495481e --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/how-to.md @@ -0,0 +1,13 @@ +(sphinx-autodoc-docutils-how-to)= + +# How to + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_docutils"] +``` + +`sphinx_autodoc_docutils` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. diff --git a/docs/packages/sphinx-autodoc-docutils/index.md b/docs/packages/sphinx-autodoc-docutils/index.md new file mode 100644 index 00000000..307b5164 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-docutils)= + +# sphinx-autodoc-docutils + +```{package-landing} sphinx-autodoc-docutils +``` diff --git a/docs/packages/sphinx-autodoc-docutils/kitchen-sink.md b/docs/packages/sphinx-autodoc-docutils/kitchen-sink.md new file mode 100644 index 00000000..673cf649 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/kitchen-sink.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-docutils-kitchen-sink)= + +# Kitchen sink + +```{package-kitchen-sink} sphinx-autodoc-docutils +``` diff --git a/docs/packages/sphinx-autodoc-docutils/tutorial.md b/docs/packages/sphinx-autodoc-docutils/tutorial.md new file mode 100644 index 00000000..bcbfd320 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/tutorial.md @@ -0,0 +1,34 @@ +(sphinx-autodoc-docutils-tutorial)= + +# Tutorial + +## Working usage examples + +Use a single-object directive when you want one rendered reference entry: + +````myst +```{eval-rst} +.. autodirective:: my_project.docs_ext.MyDirective +``` +```` + +````myst +```{eval-rst} +.. autorole:: my_project.docs_roles.cli_option_role +``` +```` + +Use the bulk directives to render every directive or role a module +registers: + +````myst +```{eval-rst} +.. autodirectives:: my_project.docs_ext +``` +```` + +````myst +```{eval-rst} +.. autoroles:: my_project.docs_roles +``` +```` diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md deleted file mode 100644 index c71e6b6d..00000000 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ /dev/null @@ -1,192 +0,0 @@ -(sphinx-autodoc-fastmcp)= - -# sphinx-autodoc-fastmcp - -```{gp-sphinx-package-meta} sphinx-autodoc-fastmcp -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Sphinx extension for documenting **FastMCP** tools: section cards built from -shared `api-*` layout regions, safety badges, parameter tables, and -cross-reference roles (`:tool:`, `:toolref:`, `:badge:`, etc.). - -The shipped output intentionally keeps the outer `section` wrapper so table of -contents labels and tool references stay stable. Inside that wrapper, shared -layout, badge, and typehint helpers now own the visible card structure. - -```console -$ pip install sphinx-autodoc-fastmcp -``` - -## Downstream `conf.py` - -```python -extensions = ["sphinx_autodoc_fastmcp"] - -fastmcp_tool_modules = [ - "my_project.docs.fastmcp_tools", -] -fastmcp_area_map = { - "fastmcp_tools": "api/tools", -} -fastmcp_collector_mode = "register" - -# Optional: point at a live FastMCP server instance to autodoc its prompts, -# resources, and resource templates. Format is "module.path:attr_name". -# Both an instance and a zero-arg factory callable are accepted. -fastmcp_server_module = "my_project.server:mcp" -``` - -`sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, -`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. -You do not need to add them separately to your `extensions` list. - -## `fastmcp_server_module` - -Pointing the collector at a live FastMCP instance enables autodoc of -**prompts**, **resources**, and **resource templates** — see the four new -directives below. The collector accepts either: - -* A live instance: `"my_project.server:mcp"` (where `mcp = FastMCP(...)`). -* A zero-argument factory: `"my_project.server:make_server"` returning a - `FastMCP` instance. - -If the resolved object is not a `FastMCP` (no `local_provider` attribute), -collection is skipped and a warning is logged. The collector also invokes -the server's `register_all` / `_register_all` hook (if exported) to -ensure components registered lazily appear in the docs; FastMCP's default -`on_duplicate="error"` policy is suppressed for this call. - -## Working usage examples - -Render one tool card: - -````myst -```{eval-rst} -.. fastmcp-tool:: my_project.docs.fastmcp_tools.list_sessions -``` -```` - -Render one tool's parameter table: - -````myst -```{eval-rst} -.. fastmcp-tool-input:: my_project.docs.fastmcp_tools.list_sessions -``` -```` - -Render a summary table grouped by safety tier: - -````myst -```{eval-rst} -.. fastmcp-tool-summary:: -``` -```` - -Add inline cross-references in prose: - -````myst -Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` -for a plain inline reference. -```` - -### Prompts and resources - -After setting `fastmcp_server_module`, four MyST directives become available -for documenting MCP prompts and resources: - -````myst -```{fastmcp-prompt} my_prompt -``` - -```{fastmcp-prompt-input} my_prompt -``` - -```{fastmcp-resource} my_resource -``` - -```{fastmcp-resource-template} my_resource_template -``` -```` - -Resources and resource templates accept either the friendly component name -(`my_resource`) or the literal URI (`mem://my_resource`). When two -distinct resources share a name, autodoc keeps the first registration and -emits a warning — disambiguate by URI. - -### `:ref:` cross-reference IDs - -Section IDs follow `fastmcp-{kind}-{name}` (canonical): - -```text -{ref}`fastmcp-tool-list-sessions` -{ref}`fastmcp-prompt-greet` -{ref}`fastmcp-resource-status` -{ref}`fastmcp-resource-template-events-by-day` -``` - -Tool sections additionally register the bare slug as a back-compat alias -(e.g. `{ref}`list-sessions`` continues to resolve), preserving links -shipped before the kind-prefix introduction. Prompts, resources, and -resource templates use the canonical ID only — no bare alias is created -for them. - -## Live demos - -Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` -for a plain inline reference. - -### Tool cards - -```{eval-rst} -.. fastmcp-tool:: fastmcp_demo_tools.list_sessions - -.. fastmcp-tool:: fastmcp_demo_tools.create_session - -.. fastmcp-tool:: fastmcp_demo_tools.delete_session -``` - -### Parameter table - -```{eval-rst} -.. fastmcp-tool-input:: fastmcp_demo_tools.create_session -``` - -### Tool summary - -```{eval-rst} -.. fastmcp-tool-summary:: -``` - -## Config reference - -Generated from `app.add_config_value()` registrations in -[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py). - -```{eval-rst} -.. autoconfigvalues:: sphinx_autodoc_fastmcp -``` - -## Directive and role reference - -Generated from `app.add_directive()` and `app.add_role()` registrations in -[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py) -via `sphinx-autodoc-docutils`. - -```{eval-rst} -.. autodirectives:: sphinx_autodoc_fastmcp - -.. autoroles:: sphinx_autodoc_fastmcp -``` - -## Package reference - -```{package-reference} sphinx-autodoc-fastmcp -``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/examples.md b/docs/packages/sphinx-autodoc-fastmcp/examples.md new file mode 100644 index 00000000..78d5182e --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/examples.md @@ -0,0 +1,30 @@ +(sphinx-autodoc-fastmcp-examples)= + +# Examples + +## Live demos + +Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` +for a plain inline reference. + +### Tool cards + +```{eval-rst} +.. fastmcp-tool:: fastmcp_demo_tools.list_sessions + +.. fastmcp-tool:: fastmcp_demo_tools.create_session + +.. fastmcp-tool:: fastmcp_demo_tools.delete_session +``` + +### Parameter table + +```{eval-rst} +.. fastmcp-tool-input:: fastmcp_demo_tools.create_session +``` + +### Tool summary + +```{eval-rst} +.. fastmcp-tool-summary:: +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/how-to.md b/docs/packages/sphinx-autodoc-fastmcp/how-to.md new file mode 100644 index 00000000..4d7124f9 --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/how-to.md @@ -0,0 +1,42 @@ +(sphinx-autodoc-fastmcp-how-to)= + +# How to + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_fastmcp"] + +fastmcp_tool_modules = [ + "my_project.docs.fastmcp_tools", +] +fastmcp_area_map = { + "fastmcp_tools": "api/tools", +} +fastmcp_collector_mode = "register" + +# Optional: point at a live FastMCP server instance to autodoc its prompts, +# resources, and resource templates. Format is "module.path:attr_name". +# Both an instance and a zero-arg factory callable are accepted. +fastmcp_server_module = "my_project.server:mcp" +``` + +`sphinx_autodoc_fastmcp` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + +## `fastmcp_server_module` + +Pointing the collector at a live FastMCP instance enables autodoc of +**prompts**, **resources**, and **resource templates** — see the four new +directives below. The collector accepts either: + +* A live instance: `"my_project.server:mcp"` (where `mcp = FastMCP(...)`). +* A zero-argument factory: `"my_project.server:make_server"` returning a + `FastMCP` instance. + +If the resolved object is not a `FastMCP` (no `local_provider` attribute), +collection is skipped and a warning is logged. The collector also invokes +the server's `register_all` / `_register_all` hook (if exported) to +ensure components registered lazily appear in the docs; FastMCP's default +`on_duplicate="error"` policy is suppressed for this call. diff --git a/docs/packages/sphinx-autodoc-fastmcp/index.md b/docs/packages/sphinx-autodoc-fastmcp/index.md new file mode 100644 index 00000000..8c4273cb --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-fastmcp)= + +# sphinx-autodoc-fastmcp + +```{package-landing} sphinx-autodoc-fastmcp +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/kitchen-sink.md b/docs/packages/sphinx-autodoc-fastmcp/kitchen-sink.md new file mode 100644 index 00000000..e0a5a06a --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/kitchen-sink.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-fastmcp-kitchen-sink)= + +# Kitchen sink + +```{package-kitchen-sink} sphinx-autodoc-fastmcp +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/reference.md b/docs/packages/sphinx-autodoc-fastmcp/reference.md new file mode 100644 index 00000000..9254b68c --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/reference.md @@ -0,0 +1,24 @@ +(sphinx-autodoc-fastmcp-reference)= + +# API Reference + +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py). + +```{eval-rst} +.. autoconfigvalues:: sphinx_autodoc_fastmcp +``` + +## Directive and role reference + +Generated from `app.add_directive()` and `app.add_role()` registrations in +[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py) +via `sphinx-autodoc-docutils`. + +```{eval-rst} +.. autodirectives:: sphinx_autodoc_fastmcp + +.. autoroles:: sphinx_autodoc_fastmcp +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/tutorial.md b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md new file mode 100644 index 00000000..3b5e0421 --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp/tutorial.md @@ -0,0 +1,77 @@ +(sphinx-autodoc-fastmcp-tutorial)= + +# Tutorial + +## Working usage examples + +Render one tool card: + +````myst +```{eval-rst} +.. fastmcp-tool:: my_project.docs.fastmcp_tools.list_sessions +``` +```` + +Render one tool's parameter table: + +````myst +```{eval-rst} +.. fastmcp-tool-input:: my_project.docs.fastmcp_tools.list_sessions +``` +```` + +Render a summary table grouped by safety tier: + +````myst +```{eval-rst} +.. fastmcp-tool-summary:: +``` +```` + +Add inline cross-references in prose: + +````myst +Use {tool}`list_sessions` for a linked badge, or {toolref}`delete_session` +for a plain inline reference. +```` + +### Prompts and resources + +After setting `fastmcp_server_module`, four MyST directives become available +for documenting MCP prompts and resources: + +````myst +```{fastmcp-prompt} my_prompt +``` + +```{fastmcp-prompt-input} my_prompt +``` + +```{fastmcp-resource} my_resource +``` + +```{fastmcp-resource-template} my_resource_template +``` +```` + +Resources and resource templates accept either the friendly component name +(`my_resource`) or the literal URI (`mem://my_resource`). When two +distinct resources share a name, autodoc keeps the first registration and +emits a warning — disambiguate by URI. + +### `:ref:` cross-reference IDs + +Section IDs follow `fastmcp-{kind}-{name}` (canonical): + +```text +{ref}`fastmcp-tool-list-sessions` +{ref}`fastmcp-prompt-greet` +{ref}`fastmcp-resource-status` +{ref}`fastmcp-resource-template-events-by-day` +``` + +Tool sections additionally register the bare slug as a back-compat alias +(e.g. `{ref}`list-sessions`` continues to resolve), preserving links +shipped before the kind-prefix introduction. Prompts, resources, and +resource templates use the canonical ID only — no bare alias is created +for them. diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md similarity index 51% rename from docs/packages/sphinx-autodoc-pytest-fixtures.md rename to docs/packages/sphinx-autodoc-pytest-fixtures/examples.md index 3eb26e6d..4d625b2e 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md @@ -1,60 +1,6 @@ -# sphinx-autodoc-pytest-fixtures +(sphinx-autodoc-pytest-fixtures-examples)= -```{gp-sphinx-package-meta} sphinx-autodoc-pytest-fixtures -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Sphinx extension for documenting pytest fixtures as first-class objects. It -registers a Python-domain fixture directive and role, autodoc helpers for bulk -fixture discovery, a higher-level pytest plugin page helper, and the -badge/index UI used throughout the page below. - -Fixture pages now use the shared stack end-to-end: badge output comes from -`sphinx-ux-badges`, visible `api-*` structure comes from -`sphinx-ux-autodoc-layout`, and fixture return types use the shared -`sphinx-autodoc-typehints-gp` rendering helpers. - -```console -$ pip install sphinx-autodoc-pytest-fixtures -``` - -## Downstream `conf.py` - -```python -extensions = ["sphinx_autodoc_pytest_fixtures"] - -pytest_fixture_lint_level = "warning" -pytest_fixture_external_links = { - "db": "https://docs.example.com/testing#db", -} -``` - -`sphinx_autodoc_pytest_fixtures` automatically registers `sphinx_ux_badges`, -`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. -You do not need to add them separately to your `extensions` list. - -## Registered configuration values - -```{eval-rst} -.. autoconfigvalues:: sphinx_autodoc_pytest_fixtures -``` - -## Working usage examples - -Render a standard pytest plugin page: - -````myst -:::{auto-pytest-plugin} my_project.pytest_plugin -:package: my-project -::: -```` +# Examples ## Live demos diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/how-to.md b/docs/packages/sphinx-autodoc-pytest-fixtures/how-to.md new file mode 100644 index 00000000..94dc0c4b --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/how-to.md @@ -0,0 +1,24 @@ +(sphinx-autodoc-pytest-fixtures-how-to)= + +# How to + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_pytest_fixtures"] + +pytest_fixture_lint_level = "warning" +pytest_fixture_external_links = { + "db": "https://docs.example.com/testing#db", +} +``` + +`sphinx_autodoc_pytest_fixtures` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. + +## Registered configuration values + +```{eval-rst} +.. autoconfigvalues:: sphinx_autodoc_pytest_fixtures +``` diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/index.md b/docs/packages/sphinx-autodoc-pytest-fixtures/index.md new file mode 100644 index 00000000..70463bac --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-pytest-fixtures)= + +# sphinx-autodoc-pytest-fixtures + +```{package-landing} sphinx-autodoc-pytest-fixtures +``` diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/kitchen-sink.md b/docs/packages/sphinx-autodoc-pytest-fixtures/kitchen-sink.md new file mode 100644 index 00000000..fac14a99 --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/kitchen-sink.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-pytest-fixtures-kitchen-sink)= + +# Kitchen sink + +```{package-kitchen-sink} sphinx-autodoc-pytest-fixtures +``` diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/tutorial.md b/docs/packages/sphinx-autodoc-pytest-fixtures/tutorial.md new file mode 100644 index 00000000..b4379a6f --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/tutorial.md @@ -0,0 +1,13 @@ +(sphinx-autodoc-pytest-fixtures-tutorial)= + +# Tutorial + +## Working usage examples + +Render a standard pytest plugin page: + +````myst +:::{auto-pytest-plugin} my_project.pytest_plugin +:package: my-project +::: +```` diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md deleted file mode 100644 index ec4999d8..00000000 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ /dev/null @@ -1,94 +0,0 @@ -# sphinx-autodoc-sphinx - -```{gp-sphinx-package-meta} sphinx-autodoc-sphinx -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Experimental Sphinx extension for documenting config values registered by -extension `setup()` hooks. It takes the repetitive part of `conf.py` -reference-writing, records {py:meth}`sphinx:~sphinx.application.Sphinx.add_config_value` calls, and renders them as -live `confval` entries and summary indexes. - -Config entries now share the same badge, layout, and type-rendering stack as -the rest of the autodoc family: badges come from `sphinx-ux-badges`, -entry structure comes from `sphinx-ux-autodoc-layout`, and displayed config types -come from `sphinx-autodoc-typehints-gp`. - -```console -$ pip install sphinx-autodoc-sphinx -``` - -## Downstream `conf.py` - -```python -extensions = ["sphinx_autodoc_sphinx"] -``` - -`sphinx_autodoc_sphinx` automatically registers `sphinx_ux_badges`, -`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. -You do not need to add them separately to your `extensions` list. - -## Working usage examples - -Render one config value: - -````myst -```{eval-rst} -.. autoconfigvalue:: sphinx_fonts.sphinx_font_preload -``` -```` - -Render every config value from an extension module: - -````myst -```{eval-rst} -.. autoconfigvalues:: sphinx_config_demo -``` -```` - -## Live demos - -This page also uses `sphinx-autodoc-docutils` to document the config-doc -directives themselves, so the page demonstrates both config-value output and -directive documentation. - -### Render a single demo config value - -```{eval-rst} -.. autoconfigvalue:: sphinx_config_single_demo.demo_debug - :no-index: -``` - -### Bulk config values demo - -Renders all config values from a module at once: - -```{eval-rst} -.. autoconfigvalues:: sphinx_config_demo -``` - -## Directive reference - -Generated from `app.add_directive()` registrations in -[`sphinx_autodoc_sphinx/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py) -via `sphinx-autodoc-docutils` — a meta-loop where the package that -documents config values uses its sibling package to document its own -directives. - -```{eval-rst} -.. autodirectives:: sphinx_autodoc_sphinx -``` - -## Package reference - -```{package-reference} sphinx-autodoc-sphinx -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx) · [PyPI](https://pypi.org/project/sphinx-autodoc-sphinx/) diff --git a/docs/packages/sphinx-autodoc-sphinx/examples.md b/docs/packages/sphinx-autodoc-sphinx/examples.md new file mode 100644 index 00000000..be1cb940 --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/examples.md @@ -0,0 +1,24 @@ +(sphinx-autodoc-sphinx-examples)= + +# Examples + +## Live demos + +This page also uses `sphinx-autodoc-docutils` to document the config-doc +directives themselves, so the page demonstrates both config-value output and +directive documentation. + +### Render a single demo config value + +```{eval-rst} +.. autoconfigvalue:: sphinx_config_single_demo.demo_debug + :no-index: +``` + +### Bulk config values demo + +Renders all config values from a module at once: + +```{eval-rst} +.. autoconfigvalues:: sphinx_config_demo +``` diff --git a/docs/packages/sphinx-autodoc-sphinx/how-to.md b/docs/packages/sphinx-autodoc-sphinx/how-to.md new file mode 100644 index 00000000..74b4806f --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/how-to.md @@ -0,0 +1,13 @@ +(sphinx-autodoc-sphinx-how-to)= + +# How to + +## Downstream `conf.py` + +```python +extensions = ["sphinx_autodoc_sphinx"] +``` + +`sphinx_autodoc_sphinx` automatically registers `sphinx_ux_badges`, +`sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. +You do not need to add them separately to your `extensions` list. diff --git a/docs/packages/sphinx-autodoc-sphinx/index.md b/docs/packages/sphinx-autodoc-sphinx/index.md new file mode 100644 index 00000000..aa6a7693 --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-sphinx)= + +# sphinx-autodoc-sphinx + +```{package-landing} sphinx-autodoc-sphinx +``` diff --git a/docs/packages/sphinx-autodoc-sphinx/kitchen-sink.md b/docs/packages/sphinx-autodoc-sphinx/kitchen-sink.md new file mode 100644 index 00000000..1a6f820f --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/kitchen-sink.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-sphinx-kitchen-sink)= + +# Kitchen sink + +```{package-kitchen-sink} sphinx-autodoc-sphinx +``` diff --git a/docs/packages/sphinx-autodoc-sphinx/reference.md b/docs/packages/sphinx-autodoc-sphinx/reference.md new file mode 100644 index 00000000..fcd3027c --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/reference.md @@ -0,0 +1,15 @@ +(sphinx-autodoc-sphinx-reference)= + +# API Reference + +## Directive reference + +Generated from `app.add_directive()` registrations in +[`sphinx_autodoc_sphinx/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py) +via `sphinx-autodoc-docutils` — a meta-loop where the package that +documents config values uses its sibling package to document its own +directives. + +```{eval-rst} +.. autodirectives:: sphinx_autodoc_sphinx +``` diff --git a/docs/packages/sphinx-autodoc-sphinx/tutorial.md b/docs/packages/sphinx-autodoc-sphinx/tutorial.md new file mode 100644 index 00000000..4df02f58 --- /dev/null +++ b/docs/packages/sphinx-autodoc-sphinx/tutorial.md @@ -0,0 +1,21 @@ +(sphinx-autodoc-sphinx-tutorial)= + +# Tutorial + +## Working usage examples + +Render one config value: + +````myst +```{eval-rst} +.. autoconfigvalue:: sphinx_fonts.sphinx_font_preload +``` +```` + +Render every config value from an extension module: + +````myst +```{eval-rst} +.. autoconfigvalues:: sphinx_config_demo +``` +```` diff --git a/docs/packages/sphinx-autodoc-typehints-gp/dependents.md b/docs/packages/sphinx-autodoc-typehints-gp/dependents.md new file mode 100644 index 00000000..72edadc9 --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp/dependents.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-typehints-gp-dependents)= + +# Dependents + +```{package-dependents} sphinx-autodoc-typehints-gp +``` diff --git a/docs/packages/sphinx-autodoc-typehints-gp/examples.md b/docs/packages/sphinx-autodoc-typehints-gp/examples.md new file mode 100644 index 00000000..884036b5 --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp/examples.md @@ -0,0 +1,17 @@ +(sphinx-autodoc-typehints-gp-examples)= + +# Examples + +## Live demos + +Type annotations are cross-referenced automatically. The function below uses +`str`, `int`, and `str` — each becomes a clickable `py:class` link in the +rendered output. + +```{eval-rst} +.. autofunction:: api_demo_layout.compact_function + :noindex: +``` + +```{package-reference} sphinx-autodoc-typehints-gp +``` diff --git a/docs/packages/sphinx-autodoc-typehints-gp.md b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md similarity index 69% rename from docs/packages/sphinx-autodoc-typehints-gp.md rename to docs/packages/sphinx-autodoc-typehints-gp/how-to.md index 97539e0f..d93486d3 100644 --- a/docs/packages/sphinx-autodoc-typehints-gp.md +++ b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md @@ -1,24 +1,6 @@ -(sphinx-autodoc-typehints-gp)= +(sphinx-autodoc-typehints-gp-how-to)= -# sphinx-autodoc-typehints-gp - -```{gp-sphinx-package-meta} sphinx-autodoc-typehints-gp -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Single-package replacement for `sphinx-autodoc-typehints` and `sphinx.ext.napoleon` -— resolves annotations statically at build time, no monkey-patching required. - -It is also the shared type-rendering layer for the `sphinx-autodoc-*` family: -annotation normalization, xref-node generation, and late-safe annotation -paragraph helpers all live here. +# How to ## Installation @@ -26,21 +8,6 @@ paragraph helpers all live here. $ pip install sphinx-autodoc-typehints-gp ``` -## Working usage examples - -Add `sphinx_autodoc_typehints_gp` to your `extensions` list in `conf.py`: - -```python -extensions = [ - "sphinx.ext.autodoc", - "sphinx_autodoc_typehints_gp", -] - -# Required: makes autodoc insert type annotations into parameter descriptions. -# Without this, the type cross-referencing pipeline fires but has nothing to attach to. -autodoc_typehints = "description" -``` - ## Pipeline position Two hooks run independently: @@ -114,17 +81,3 @@ detection heuristics. This extension uses `sphinx_stringify_annotation()` to resolve annotations at build time, making it safe with `TYPE_CHECKING` blocks and eliminating text-processing races with Napoleon. - -## Live demos - -Type annotations are cross-referenced automatically. The function below uses -`str`, `int`, and `str` — each becomes a clickable `py:class` link in the -rendered output. - -```{eval-rst} -.. autofunction:: api_demo_layout.compact_function - :noindex: -``` - -```{package-reference} sphinx-autodoc-typehints-gp -``` diff --git a/docs/packages/sphinx-autodoc-typehints-gp/index.md b/docs/packages/sphinx-autodoc-typehints-gp/index.md new file mode 100644 index 00000000..7ef7b2a2 --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp/index.md @@ -0,0 +1,6 @@ +(sphinx-autodoc-typehints-gp)= + +# sphinx-autodoc-typehints-gp + +```{package-landing} sphinx-autodoc-typehints-gp +``` diff --git a/docs/packages/sphinx-autodoc-typehints-gp/tutorial.md b/docs/packages/sphinx-autodoc-typehints-gp/tutorial.md new file mode 100644 index 00000000..5ae8de8b --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp/tutorial.md @@ -0,0 +1,18 @@ +(sphinx-autodoc-typehints-gp-tutorial)= + +# Tutorial + +## Working usage examples + +Add `sphinx_autodoc_typehints_gp` to your `extensions` list in `conf.py`: + +```python +extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints_gp", +] + +# Required: makes autodoc insert type annotations into parameter descriptions. +# Without this, the type cross-referencing pipeline fires but has nothing to attach to. +autodoc_typehints = "description" +``` diff --git a/docs/packages/sphinx-fonts/dependents.md b/docs/packages/sphinx-fonts/dependents.md new file mode 100644 index 00000000..0f641bcd --- /dev/null +++ b/docs/packages/sphinx-fonts/dependents.md @@ -0,0 +1,6 @@ +(sphinx-fonts-dependents)= + +# Dependents + +```{package-dependents} sphinx-fonts +``` diff --git a/docs/packages/sphinx-fonts.md b/docs/packages/sphinx-fonts/how-to.md similarity index 81% rename from docs/packages/sphinx-fonts.md rename to docs/packages/sphinx-fonts/how-to.md index d882282c..1e81ad16 100644 --- a/docs/packages/sphinx-fonts.md +++ b/docs/packages/sphinx-fonts/how-to.md @@ -1,16 +1,6 @@ -# sphinx-fonts +(sphinx-fonts-how-to)= -```{gp-sphinx-package-meta} sphinx-fonts -``` - -Sphinx extension for self-hosted web fonts via Fontsource. It downloads font -assets during the HTML build, caches them locally, copies them into -`_static/fonts/`, and exposes template context values that themes can render as -inline `@font-face` and preload tags. - -```console -$ pip install sphinx-fonts -``` +# How to ## Downstream `conf.py` @@ -55,12 +45,6 @@ template context that downstream themes receive.
``` -## Configuration values - -```{eval-rst} -.. autoconfigvalues:: sphinx_fonts -``` - ## Template context The extension injects these values during `html-page-context`: diff --git a/docs/packages/sphinx-fonts/index.md b/docs/packages/sphinx-fonts/index.md new file mode 100644 index 00000000..451100ed --- /dev/null +++ b/docs/packages/sphinx-fonts/index.md @@ -0,0 +1,6 @@ +(sphinx-fonts)= + +# sphinx-fonts + +```{package-landing} sphinx-fonts +``` diff --git a/docs/packages/sphinx-fonts/reference.md b/docs/packages/sphinx-fonts/reference.md new file mode 100644 index 00000000..ef5677ef --- /dev/null +++ b/docs/packages/sphinx-fonts/reference.md @@ -0,0 +1,9 @@ +(sphinx-fonts-reference)= + +# API Reference + +## Configuration values + +```{eval-rst} +.. autoconfigvalues:: sphinx_fonts +``` diff --git a/docs/packages/sphinx-gp-opengraph/dependents.md b/docs/packages/sphinx-gp-opengraph/dependents.md new file mode 100644 index 00000000..65fc1815 --- /dev/null +++ b/docs/packages/sphinx-gp-opengraph/dependents.md @@ -0,0 +1,6 @@ +(sphinx-gp-opengraph-dependents)= + +# Dependents + +```{package-dependents} sphinx-gp-opengraph +``` diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph/how-to.md similarity index 69% rename from docs/packages/sphinx-gp-opengraph.md rename to docs/packages/sphinx-gp-opengraph/how-to.md index 2a303dbd..eee89027 100644 --- a/docs/packages/sphinx-gp-opengraph.md +++ b/docs/packages/sphinx-gp-opengraph/how-to.md @@ -1,30 +1,6 @@ -(sphinx-gp-opengraph)= +(sphinx-gp-opengraph-how-to)= -# sphinx-gp-opengraph - -```{gp-sphinx-package-meta} sphinx-gp-opengraph -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API and Sphinx config value names -may change without a major version bump. Pin your dependency to a -specific version range in production. -::: - -OpenGraph meta-tag emission for Sphinx. The package registers every -`ogp_*` config value the upstream -[`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph) -exposes and emits the same `` tags, with one deliberate -omission: the matplotlib-based social-card generator is not bundled. -That is why the package has zero non-Sphinx runtime dependencies. - -For install, per-page overrides, Twitter-card markup, and the -verbatim deprecation-warning text, see the package -[README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph#readme). -This page covers integration with gp-sphinx, the emission pipeline, -the trade-offs, and the auto-generated config-value reference. +# How to ## Integration with gp-sphinx @@ -98,19 +74,3 @@ workflow documented in the README. The extension never writes shared state — every emission is self-contained inside the per-page hook — so it is safe under any `sphinx-build -j N` value. - -## Config reference - -Generated from `app.add_config_value()` registrations in -[`sphinx_gp_opengraph/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py). - -```{eval-rst} -.. autoconfigvalues:: sphinx_gp_opengraph -``` - -## Package reference - -```{package-reference} sphinx-gp-opengraph -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph) · [PyPI](https://pypi.org/project/sphinx-gp-opengraph/) diff --git a/docs/packages/sphinx-gp-opengraph/index.md b/docs/packages/sphinx-gp-opengraph/index.md new file mode 100644 index 00000000..db09a6df --- /dev/null +++ b/docs/packages/sphinx-gp-opengraph/index.md @@ -0,0 +1,6 @@ +(sphinx-gp-opengraph)= + +# sphinx-gp-opengraph + +```{package-landing} sphinx-gp-opengraph +``` diff --git a/docs/packages/sphinx-gp-opengraph/reference.md b/docs/packages/sphinx-gp-opengraph/reference.md new file mode 100644 index 00000000..e7b1d139 --- /dev/null +++ b/docs/packages/sphinx-gp-opengraph/reference.md @@ -0,0 +1,12 @@ +(sphinx-gp-opengraph-reference)= + +# API Reference + +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_gp_opengraph/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py). + +```{eval-rst} +.. autoconfigvalues:: sphinx_gp_opengraph +``` diff --git a/docs/packages/sphinx-gp-opengraph/signatures.md b/docs/packages/sphinx-gp-opengraph/signatures.md new file mode 100644 index 00000000..683be0ae --- /dev/null +++ b/docs/packages/sphinx-gp-opengraph/signatures.md @@ -0,0 +1,6 @@ +(sphinx-gp-opengraph-signatures)= + +# Signatures (live) + +```{live-signature} sphinx-gp-opengraph +``` diff --git a/docs/packages/sphinx-gp-sitemap/dependents.md b/docs/packages/sphinx-gp-sitemap/dependents.md new file mode 100644 index 00000000..524b191f --- /dev/null +++ b/docs/packages/sphinx-gp-sitemap/dependents.md @@ -0,0 +1,6 @@ +(sphinx-gp-sitemap-dependents)= + +# Dependents + +```{package-dependents} sphinx-gp-sitemap +``` diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap/how-to.md similarity index 75% rename from docs/packages/sphinx-gp-sitemap.md rename to docs/packages/sphinx-gp-sitemap/how-to.md index 452b26a8..f883e4c4 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap/how-to.md @@ -1,32 +1,6 @@ -(sphinx-gp-sitemap)= +(sphinx-gp-sitemap-how-to)= -# sphinx-gp-sitemap - -```{gp-sphinx-package-meta} sphinx-gp-sitemap -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API and Sphinx config value names -may change without a major version bump. Pin your dependency to a -specific version range in production. -::: - -Sitemap generator for Sphinx. The package registers every `sitemap_*` -config value the upstream -[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) exposes -and emits the same `sitemap.xml` shape (urlset, hreflang alternates, -optional ``), updated to Sphinx 8.1+ idioms. The hard -dependency on `sphinx-last-updated-by-git` is downgraded to a -soft on-demand load that activates only under -`sitemap_show_lastmod = True`. - -For install, builder support, locale rules, and the lastmod / -migration story, see the package -[README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap#readme). -This page covers integration with gp-sphinx, the emission pipeline, -the trade-offs, and the auto-generated config-value reference. +# How to ## Integration with gp-sphinx @@ -123,19 +97,3 @@ some custom builders skip it. The `setup()` body wraps the `contextlib.suppress(ExtensionError)` so the extension is robust against either layout. The bare `except BaseException` upstream uses is replaced by the narrow `ExtensionError` catch. - -## Config reference - -Generated from `app.add_config_value()` registrations in -[`sphinx_gp_sitemap/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py). - -```{eval-rst} -.. autoconfigvalues:: sphinx_gp_sitemap -``` - -## Package reference - -```{package-reference} sphinx-gp-sitemap -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap) · [PyPI](https://pypi.org/project/sphinx-gp-sitemap/) diff --git a/docs/packages/sphinx-gp-sitemap/index.md b/docs/packages/sphinx-gp-sitemap/index.md new file mode 100644 index 00000000..4865b1c4 --- /dev/null +++ b/docs/packages/sphinx-gp-sitemap/index.md @@ -0,0 +1,6 @@ +(sphinx-gp-sitemap)= + +# sphinx-gp-sitemap + +```{package-landing} sphinx-gp-sitemap +``` diff --git a/docs/packages/sphinx-gp-sitemap/reference.md b/docs/packages/sphinx-gp-sitemap/reference.md new file mode 100644 index 00000000..be2dbd3a --- /dev/null +++ b/docs/packages/sphinx-gp-sitemap/reference.md @@ -0,0 +1,12 @@ +(sphinx-gp-sitemap-reference)= + +# API Reference + +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_gp_sitemap/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py). + +```{eval-rst} +.. autoconfigvalues:: sphinx_gp_sitemap +``` diff --git a/docs/packages/sphinx-gp-theme/dependents.md b/docs/packages/sphinx-gp-theme/dependents.md new file mode 100644 index 00000000..9a59c9ef --- /dev/null +++ b/docs/packages/sphinx-gp-theme/dependents.md @@ -0,0 +1,6 @@ +(sphinx-gp-theme-dependents)= + +# Dependents + +```{package-dependents} sphinx-gp-theme +``` diff --git a/docs/packages/sphinx-gp-theme.md b/docs/packages/sphinx-gp-theme/how-to.md similarity index 90% rename from docs/packages/sphinx-gp-theme.md rename to docs/packages/sphinx-gp-theme/how-to.md index 67b016a2..308a039e 100644 --- a/docs/packages/sphinx-gp-theme.md +++ b/docs/packages/sphinx-gp-theme/how-to.md @@ -1,15 +1,6 @@ -# sphinx-gp-theme +(sphinx-gp-theme-how-to)= -```{gp-sphinx-package-meta} sphinx-gp-theme -``` - -Furo child theme for git-pull documentation sites. It keeps Furo’s responsive -layout and dark mode, then layers in shared sidebars, typography, source-link -controls, metadata toggles, and SPA-style navigation. - -```console -$ pip install sphinx-gp-theme -``` +# How to ## Downstream `conf.py` diff --git a/docs/packages/sphinx-gp-theme/index.md b/docs/packages/sphinx-gp-theme/index.md new file mode 100644 index 00000000..87ce4543 --- /dev/null +++ b/docs/packages/sphinx-gp-theme/index.md @@ -0,0 +1,6 @@ +(sphinx-gp-theme)= + +# sphinx-gp-theme + +```{package-landing} sphinx-gp-theme +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout.md b/docs/packages/sphinx-ux-autodoc-layout.md deleted file mode 100644 index 413df64c..00000000 --- a/docs/packages/sphinx-ux-autodoc-layout.md +++ /dev/null @@ -1,167 +0,0 @@ -(sphinx-ux-autodoc-layout)= - -# sphinx-ux-autodoc-layout - -```{gp-sphinx-package-meta} sphinx-ux-autodoc-layout -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Wraps contiguous `desc_content` runs into semantic `api_region` nodes -and rebuilds Python autodoc entries into stable `api-*` components. -Large field-list parameter sections still use native `
/`, -while inline signature expansion uses a custom disclosure that reveals -Sphinx's native multiline parameter-list rendering. - -It is now the shared presenter for the whole autodoc family. `desc`-backed -entries use it directly, and section-card consumers reuse the same inner shell -through the public `build_api_card_entry()` helper. - -```console -$ pip install sphinx-ux-autodoc-layout -``` - -## Pipeline position - -Hooks `doctree-resolved` at priority **600**, after `sphinx-autodoc-api-style` -at 500. Consumes the `api_slot` nodes that producer packages inject into -`desc_signature` during earlier transforms, and composes them into the final -`gp-sphinx-api-layout-right` subcomponent (badges, source link, permalink). - -The extension also overrides Sphinx's built-in `desc_signature` HTML visitor -(`app.add_node(addnodes.desc_signature, override=True, ...)`). This is a -deliberate platform decision: taking ownership of signature rendering allows -the `gp-sphinx-api-link` permalink to be placed inside the managed layout rather than -appended by Sphinx's default handler. - -| Event | Hook | Priority | -|-------|------|----------| -| `doctree-resolved` | `on_doctree_resolved` | 600 (after api-style at 500) | -| `object-description-transform` | — | not used | - -## Downstream `conf.py` - -With `gp-sphinx`: - -```python -conf = merge_sphinx_config( - project="my-project", - version="1.0.0", - copyright="2026, Your Name", - source_repository="https://github.com/your-org/my-project/", - extra_extensions=["sphinx_ux_autodoc_layout"], - api_layout_enabled=True, - api_collapsed_threshold=10, -) -``` - -Or without `merge_sphinx_config`: - -```python -extensions = ["sphinx.ext.autodoc", "sphinx_ux_autodoc_layout"] -api_layout_enabled = True -``` - -## Working usage examples - -Render one compact function: - -````myst -```{eval-rst} -.. autofunction:: my_project.api.compact_function -``` -```` - -Render a class with grouped content regions and member entries: - -````myst -```{eval-rst} -.. autoclass:: my_project.api.LayoutDemo - :members: -``` -```` - -## Live demos - -```{py:module} api_demo_layout -``` - -### Class with members (regions + fold) - -```{eval-rst} -.. autoclass:: api_demo_layout.LayoutDemo - :members: -``` - -The class above renders with: - -- **narrative** region (class docstring) -- **fields** region with fold (13 parameters > threshold of 10) -- **members** region (connect, execute, close methods) - -### Small function (no fold) - -```{eval-rst} -.. autofunction:: api_demo_layout.compact_function -``` - -## Configuration - -Generated from `app.add_config_value()` registrations in -[`sphinx_ux_autodoc_layout/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py). - -```{eval-rst} -.. autoconfigvalues:: sphinx_ux_autodoc_layout -``` - -## Shared helper surface - -- `build_api_card_entry()` builds the shared inner `api-*` shell for - section-card consumers such as FastMCP. -- `build_api_summary_section()` wraps summary and index tables in the shared - `gp-sphinx-api-summary` region. - -## CSS classes - -| Class | Element | Purpose | -|-------|---------|---------| -| `gp-sphinx-api-container` | `
` | Managed autodoc shell | -| `gp-sphinx-api-header` | `
` | Signature row shell | -| `gp-sphinx-api-content` | `
` | Description/content shell | -| `gp-sphinx-api-layout` | `
` | Header split between left and right | -| `gp-sphinx-api-layout-left` | `
` | Signature text, custom disclosure, permalink | -| `gp-sphinx-api-layout-right` | `
` | Badge container and source link | -| `gp-sphinx-api-signature` | `
` | Compact signature row | -| `gp-sphinx-api-link` | `` | Managed permalink in the left layout | -| `gp-sphinx-api-badge-container` | `` | Wrapper for badge group output | -| `gp-sphinx-api-source-link` | `` | Wrapper for the `[source]` link | -| `gp-sphinx-api-description` | `
` | Wraps paragraphs, notes, examples | -| `gp-sphinx-api-parameters` | `
` | Wraps field lists (Parameters, Returns) | -| `gp-sphinx-api-footer` | `
` | Wraps nested method/attribute entries | -| `gp-sphinx-api-region` | `
` | Compatibility alias on content sections | -| `gp-sphinx-api-region--narrative` | `
` | Compatibility alias on narrative sections | -| `gp-sphinx-api-region--fields` | `
` | Compatibility alias on parameter sections | -| `gp-sphinx-api-region--members` | `
` | Compatibility alias on footer/member sections | -| `gp-sphinx-api-fold` | `
` | Disclosure wrapper for large sections | -| `gp-sphinx-api-fold-summary` | `` | Click target showing field count | - -## API reference - -```{eval-rst} -.. autofunction:: sphinx_ux_autodoc_layout.build_api_card_entry - -.. autofunction:: sphinx_ux_autodoc_layout.build_api_summary_section - -.. autofunction:: sphinx_ux_autodoc_layout.build_api_table_section - -.. autofunction:: sphinx_ux_autodoc_layout.build_api_facts_section -``` - -```{package-reference} sphinx-ux-autodoc-layout -``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/dependents.md b/docs/packages/sphinx-ux-autodoc-layout/dependents.md new file mode 100644 index 00000000..cba0c387 --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/dependents.md @@ -0,0 +1,6 @@ +(sphinx-ux-autodoc-layout-dependents)= + +# Dependents + +```{package-dependents} sphinx-ux-autodoc-layout +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/examples.md b/docs/packages/sphinx-ux-autodoc-layout/examples.md new file mode 100644 index 00000000..ada7be5f --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/examples.md @@ -0,0 +1,27 @@ +(sphinx-ux-autodoc-layout-examples)= + +# Examples + +## Live demos + +```{py:module} api_demo_layout +``` + +### Class with members (regions + fold) + +```{eval-rst} +.. autoclass:: api_demo_layout.LayoutDemo + :members: +``` + +The class above renders with: + +- **narrative** region (class docstring) +- **fields** region with fold (13 parameters > threshold of 10) +- **members** region (connect, execute, close methods) + +### Small function (no fold) + +```{eval-rst} +.. autofunction:: api_demo_layout.compact_function +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/how-to.md b/docs/packages/sphinx-ux-autodoc-layout/how-to.md new file mode 100644 index 00000000..82f1be6b --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/how-to.md @@ -0,0 +1,60 @@ +(sphinx-ux-autodoc-layout-how-to)= + +# How to + +## Pipeline position + +Hooks `doctree-resolved` at priority **600**, after `sphinx-autodoc-api-style` +at 500. Consumes the `api_slot` nodes that producer packages inject into +`desc_signature` during earlier transforms, and composes them into the final +`gp-sphinx-api-layout-right` subcomponent (badges, source link, permalink). + +The extension also overrides Sphinx's built-in `desc_signature` HTML visitor +(`app.add_node(addnodes.desc_signature, override=True, ...)`). This is a +deliberate platform decision: taking ownership of signature rendering allows +the `gp-sphinx-api-link` permalink to be placed inside the managed layout rather than +appended by Sphinx's default handler. + +| Event | Hook | Priority | +|-------|------|----------| +| `doctree-resolved` | `on_doctree_resolved` | 600 (after api-style at 500) | +| `object-description-transform` | — | not used | + +## Downstream `conf.py` + +With `gp-sphinx`: + +```python +conf = merge_sphinx_config( + project="my-project", + version="1.0.0", + copyright="2026, Your Name", + source_repository="https://github.com/your-org/my-project/", + extra_extensions=["sphinx_ux_autodoc_layout"], + api_layout_enabled=True, + api_collapsed_threshold=10, +) +``` + +Or without `merge_sphinx_config`: + +```python +extensions = ["sphinx.ext.autodoc", "sphinx_ux_autodoc_layout"] +api_layout_enabled = True +``` + +## Configuration + +Generated from `app.add_config_value()` registrations in +[`sphinx_ux_autodoc_layout/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py). + +```{eval-rst} +.. autoconfigvalues:: sphinx_ux_autodoc_layout +``` + +## Shared helper surface + +- `build_api_card_entry()` builds the shared inner `api-*` shell for + section-card consumers such as FastMCP. +- `build_api_summary_section()` wraps summary and index tables in the shared + `gp-sphinx-api-summary` region. diff --git a/docs/packages/sphinx-ux-autodoc-layout/index.md b/docs/packages/sphinx-ux-autodoc-layout/index.md new file mode 100644 index 00000000..dafb6d01 --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/index.md @@ -0,0 +1,6 @@ +(sphinx-ux-autodoc-layout)= + +# sphinx-ux-autodoc-layout + +```{package-landing} sphinx-ux-autodoc-layout +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/reference.md b/docs/packages/sphinx-ux-autodoc-layout/reference.md new file mode 100644 index 00000000..31cf4946 --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/reference.md @@ -0,0 +1,42 @@ +(sphinx-ux-autodoc-layout-reference)= + +# API Reference + +## CSS classes + +| Class | Element | Purpose | +|-------|---------|---------| +| `gp-sphinx-api-container` | `
` | Managed autodoc shell | +| `gp-sphinx-api-header` | `
` | Signature row shell | +| `gp-sphinx-api-content` | `
` | Description/content shell | +| `gp-sphinx-api-layout` | `
` | Header split between left and right | +| `gp-sphinx-api-layout-left` | `
` | Signature text, custom disclosure, permalink | +| `gp-sphinx-api-layout-right` | `
` | Badge container and source link | +| `gp-sphinx-api-signature` | `
` | Compact signature row | +| `gp-sphinx-api-link` | `` | Managed permalink in the left layout | +| `gp-sphinx-api-badge-container` | `` | Wrapper for badge group output | +| `gp-sphinx-api-source-link` | `` | Wrapper for the `[source]` link | +| `gp-sphinx-api-description` | `
` | Wraps paragraphs, notes, examples | +| `gp-sphinx-api-parameters` | `
` | Wraps field lists (Parameters, Returns) | +| `gp-sphinx-api-footer` | `
` | Wraps nested method/attribute entries | +| `gp-sphinx-api-region` | `
` | Compatibility alias on content sections | +| `gp-sphinx-api-region--narrative` | `
` | Compatibility alias on narrative sections | +| `gp-sphinx-api-region--fields` | `
` | Compatibility alias on parameter sections | +| `gp-sphinx-api-region--members` | `
` | Compatibility alias on footer/member sections | +| `gp-sphinx-api-fold` | `
` | Disclosure wrapper for large sections | +| `gp-sphinx-api-fold-summary` | `` | Click target showing field count | + +## API reference + +```{eval-rst} +.. autofunction:: sphinx_ux_autodoc_layout.build_api_card_entry + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_summary_section + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_table_section + +.. autofunction:: sphinx_ux_autodoc_layout.build_api_facts_section +``` + +```{package-reference} sphinx-ux-autodoc-layout +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/tutorial.md b/docs/packages/sphinx-ux-autodoc-layout/tutorial.md new file mode 100644 index 00000000..b990299f --- /dev/null +++ b/docs/packages/sphinx-ux-autodoc-layout/tutorial.md @@ -0,0 +1,22 @@ +(sphinx-ux-autodoc-layout-tutorial)= + +# Tutorial + +## Working usage examples + +Render one compact function: + +````myst +```{eval-rst} +.. autofunction:: my_project.api.compact_function +``` +```` + +Render a class with grouped content regions and member entries: + +````myst +```{eval-rst} +.. autoclass:: my_project.api.LayoutDemo + :members: +``` +```` diff --git a/docs/packages/sphinx-ux-badges/dependents.md b/docs/packages/sphinx-ux-badges/dependents.md new file mode 100644 index 00000000..3d3e0506 --- /dev/null +++ b/docs/packages/sphinx-ux-badges/dependents.md @@ -0,0 +1,6 @@ +(sphinx-ux-badges-dependents)= + +# Dependents + +```{package-dependents} sphinx-ux-badges +``` diff --git a/docs/packages/sphinx-ux-badges/examples.md b/docs/packages/sphinx-ux-badges/examples.md new file mode 100644 index 00000000..f084f419 --- /dev/null +++ b/docs/packages/sphinx-ux-badges/examples.md @@ -0,0 +1,11 @@ +(sphinx-ux-badges-examples)= + +# Examples + +## Live demos + +Every variant rendered by the real `build_badge` / `build_badge_group` / +`build_toolbar` API: + +```{gp-sphinx-badge-demo} +``` diff --git a/docs/packages/sphinx-ux-badges/explanation.md b/docs/packages/sphinx-ux-badges/explanation.md new file mode 100644 index 00000000..5e88b391 --- /dev/null +++ b/docs/packages/sphinx-ux-badges/explanation.md @@ -0,0 +1,27 @@ +(sphinx-ux-badges-explanation)= + +# Explanation + +## Downstream extensions + +All colour variants are provided by the shared palette above. Downstream +extensions reference `SAB.*` constants instead of maintaining their own +`sab-*` / `spf-*` / `sas-*` / `sadoc-*` colour classes. + +```{list-table} +:header-rows: 1 +:widths: 35 65 + +* - Extension + - Badge types used +* - {doc}`/packages/sphinx-autodoc-fastmcp/index` + - Safety tiers (readonly / mutating / destructive), MCP tool type (`smf-*` — FastMCP-specific colours not in shared palette) +* - {doc}`/packages/sphinx-autodoc-api-style/index` + - `SAB.TYPE_FUNCTION`, `SAB.TYPE_CLASS`, `SAB.TYPE_METHOD`, modifiers, `SAB.STATE_DEPRECATED` +* - {doc}`/packages/sphinx-autodoc-pytest-fixtures/index` + - `SAB.TYPE_FIXTURE`, `SAB.SCOPE_*`, `SAB.STATE_FACTORY`, `SAB.STATE_OVERRIDE`, `SAB.STATE_AUTOUSE` +* - {doc}`/packages/sphinx-autodoc-sphinx/index` + - `SAB.TYPE_CONFIG`, `SAB.MOD_REBUILD` +* - {doc}`/packages/sphinx-autodoc-docutils/index` + - `SAB.TYPE_DIRECTIVE`, `SAB.TYPE_ROLE`, `SAB.TYPE_OPTION` +``` diff --git a/docs/packages/sphinx-ux-badges/index.md b/docs/packages/sphinx-ux-badges/index.md new file mode 100644 index 00000000..8a49328c --- /dev/null +++ b/docs/packages/sphinx-ux-badges/index.md @@ -0,0 +1,6 @@ +(sphinx-ux-badges)= + +# sphinx-ux-badges + +```{package-landing} sphinx-ux-badges +``` diff --git a/docs/packages/sphinx-ux-badges.md b/docs/packages/sphinx-ux-badges/reference.md similarity index 73% rename from docs/packages/sphinx-ux-badges.md rename to docs/packages/sphinx-ux-badges/reference.md index 73996aba..9cf3f9f6 100644 --- a/docs/packages/sphinx-ux-badges.md +++ b/docs/packages/sphinx-ux-badges/reference.md @@ -1,78 +1,6 @@ -(sphinx-ux-badges)= +(sphinx-ux-badges-reference)= -# sphinx-ux-badges - -```{gp-sphinx-package-meta} sphinx-ux-badges -``` - -:::{admonition} Alpha -:class: warning - -Rendered output is stable. The Python API, CSS class names, and Sphinx -config value names may change without a major version bump. Pin your -dependency to a specific version range in production. -::: - -Shared badge node, HTML visitors, and CSS infrastructure for Sphinx autodoc -extensions. Provides a single `BadgeNode` and builder API that -{doc}`sphinx-autodoc-api-style`, {doc}`sphinx-autodoc-pytest-fixtures`, and -{doc}`sphinx-autodoc-fastmcp` share instead of reimplementing badges -independently. - -```console -$ pip install sphinx-ux-badges -``` - -## Live demos - -Every variant rendered by the real `build_badge` / `build_badge_group` / -`build_toolbar` API: - -```{gp-sphinx-badge-demo} -``` - -## Working usage examples - -`setup()` registers the extension with Sphinx: - -1. {py:meth}`~sphinx.application.Sphinx.add_node` registers `BadgeNode` with - HTML visitors (`visit_badge_html` / `depart_badge_html`). -2. {py:meth}`~sphinx.application.Sphinx.add_css_file` injects the shared - `sphinx_ux_badges.css` stylesheet. -3. Downstream extensions call - {py:meth}`~sphinx.application.Sphinx.setup_extension` to load the badge - layer: - -```python -def setup(app: Sphinx) -> dict[str, Any]: - app.setup_extension("sphinx_ux_badges") -``` - -`BadgeNode` subclasses {py:class}`docutils.nodes.inline`, so unregistered -builders (text, LaTeX, man) fall back to `visit_inline` via Sphinx's -MRO-based dispatch — no special handling needed. - -Build a grouped toolbar in your own directive or transform: - -```python -from sphinx_ux_badges import build_badge, build_badge_group, build_toolbar - -badge_group = build_badge_group( - [ - build_badge( - "readonly", - tooltip="Read-only operation", - classes=["gp-sphinx-fastmcp__safety-readonly"], - ), - build_badge( - "tool", - tooltip="FastMCP tool entry", - classes=["gp-sphinx-fastmcp__type-tool"], - ), - ], -) -toolbar = build_toolbar(badge_group, classes=["my-extension-toolbar"]) -``` +# API Reference ## Colour palette @@ -361,34 +289,3 @@ contextual sizing when present (higher specificity than context rules). - `0.58rem` - `.toc-tree .gp-sphinx-badge` (compact, with emoji icons) ``` - -## Downstream extensions - -All colour variants are provided by the shared palette above. Downstream -extensions reference `SAB.*` constants instead of maintaining their own -`sab-*` / `spf-*` / `sas-*` / `sadoc-*` colour classes. - -```{list-table} -:header-rows: 1 -:widths: 35 65 - -* - Extension - - Badge types used -* - {doc}`sphinx-autodoc-fastmcp` - - Safety tiers (readonly / mutating / destructive), MCP tool type (`smf-*` — FastMCP-specific colours not in shared palette) -* - {doc}`sphinx-autodoc-api-style` - - `SAB.TYPE_FUNCTION`, `SAB.TYPE_CLASS`, `SAB.TYPE_METHOD`, modifiers, `SAB.STATE_DEPRECATED` -* - {doc}`sphinx-autodoc-pytest-fixtures` - - `SAB.TYPE_FIXTURE`, `SAB.SCOPE_*`, `SAB.STATE_FACTORY`, `SAB.STATE_OVERRIDE`, `SAB.STATE_AUTOUSE` -* - {doc}`sphinx-autodoc-sphinx` - - `SAB.TYPE_CONFIG`, `SAB.MOD_REBUILD` -* - {doc}`sphinx-autodoc-docutils` - - `SAB.TYPE_DIRECTIVE`, `SAB.TYPE_ROLE`, `SAB.TYPE_OPTION` -``` - -## Package reference - -```{package-reference} sphinx-ux-badges -``` - -[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-ux-badges) · [PyPI](https://pypi.org/project/sphinx-ux-badges/) diff --git a/docs/packages/sphinx-ux-badges/tutorial.md b/docs/packages/sphinx-ux-badges/tutorial.md new file mode 100644 index 00000000..19d50c2f --- /dev/null +++ b/docs/packages/sphinx-ux-badges/tutorial.md @@ -0,0 +1,46 @@ +(sphinx-ux-badges-tutorial)= + +# Tutorial + +## Working usage examples + +`setup()` registers the extension with Sphinx: + +1. {py:meth}`~sphinx.application.Sphinx.add_node` registers `BadgeNode` with + HTML visitors (`visit_badge_html` / `depart_badge_html`). +2. {py:meth}`~sphinx.application.Sphinx.add_css_file` injects the shared + `sphinx_ux_badges.css` stylesheet. +3. Downstream extensions call + {py:meth}`~sphinx.application.Sphinx.setup_extension` to load the badge + layer: + +```python +def setup(app: Sphinx) -> dict[str, Any]: + app.setup_extension("sphinx_ux_badges") +``` + +`BadgeNode` subclasses {py:class}`docutils.nodes.inline`, so unregistered +builders (text, LaTeX, man) fall back to `visit_inline` via Sphinx's +MRO-based dispatch — no special handling needed. + +Build a grouped toolbar in your own directive or transform: + +```python +from sphinx_ux_badges import build_badge, build_badge_group, build_toolbar + +badge_group = build_badge_group( + [ + build_badge( + "readonly", + tooltip="Read-only operation", + classes=["gp-sphinx-fastmcp__safety-readonly"], + ), + build_badge( + "tool", + tooltip="FastMCP tool entry", + classes=["gp-sphinx-fastmcp__type-tool"], + ), + ], +) +toolbar = build_toolbar(badge_group, classes=["my-extension-toolbar"]) +``` diff --git a/docs/packages/sphinx-vite-builder.md b/docs/packages/sphinx-vite-builder/how-to.md similarity index 80% rename from docs/packages/sphinx-vite-builder.md rename to docs/packages/sphinx-vite-builder/how-to.md index baf41578..e3a26e41 100644 --- a/docs/packages/sphinx-vite-builder.md +++ b/docs/packages/sphinx-vite-builder/how-to.md @@ -1,19 +1,6 @@ -# sphinx-vite-builder +(sphinx-vite-builder-how-to)= -```{gp-sphinx-package-meta} sphinx-vite-builder -``` - -A [PEP 517](https://peps.python.org/pep-0517/) build backend and Sphinx extension that orchestrates -[Vite](https://vitejs.dev/) builds via [pnpm](https://pnpm.io/) for any -Sphinx-theme package whose static assets (CSS / JS) are produced by a -JavaScript toolchain. The same pattern that -[maturin](https://github.com/PyO3/maturin) uses for Rust+Python and -that [sphinx-theme-builder](https://github.com/pradyunsg/sphinx-theme-builder) -uses for webpack, applied to vite + pnpm. - -```console -$ pip install sphinx-vite-builder -``` +# How to ## Two heads, one core diff --git a/docs/packages/sphinx-vite-builder/index.md b/docs/packages/sphinx-vite-builder/index.md new file mode 100644 index 00000000..27d3490b --- /dev/null +++ b/docs/packages/sphinx-vite-builder/index.md @@ -0,0 +1,6 @@ +(sphinx-vite-builder)= + +# sphinx-vite-builder + +```{package-landing} sphinx-vite-builder +``` diff --git a/docs/whats-new.md b/docs/whats-new.md index 7428ab6e..65ee17ba 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -10,17 +10,17 @@ stack works. See the {doc}`gallery` for a visual showcase. Two new foundational packages form the core of the rendering pipeline: -- {doc}`sphinx-ux-autodoc-layout ` — componentized +- {doc}`sphinx-ux-autodoc-layout ` — componentized autodoc output with semantic regions, parameter folding, managed signatures, and card containers. -- {doc}`sphinx-autodoc-typehints-gp ` — single-package +- {doc}`sphinx-autodoc-typehints-gp ` — single-package replacement for `sphinx-autodoc-typehints` and `sphinx.ext.napoleon`. Resolves annotations statically at build time with no monkey-patching. ## Unified badge system All badge colours have been consolidated into -{doc}`sphinx-ux-badges `. Every +{doc}`sphinx-ux-badges `. Every downstream package references `SAB.*` constants instead of maintaining its own colour classes — one palette, thirty-plus colour variants, full light/dark theming. @@ -28,19 +28,19 @@ light/dark theming. ## Shared layout stack The autodoc extensions -({doc}`api-style `, -{doc}`argparse `, -{doc}`docutils `, -{doc}`fastmcp `, -{doc}`pytest-fixtures `, -{doc}`sphinx `) +({doc}`api-style `, +{doc}`argparse `, +{doc}`docutils `, +{doc}`fastmcp `, +{doc}`pytest-fixtures `, +{doc}`sphinx `) now all share the same layout, badge, and typehint infrastructure. A change in the foundational layout package propagates instantly and consistently. ## argparse Sphinx domain -{doc}`sphinx-autodoc-argparse ` now +{doc}`sphinx-autodoc-argparse ` now ships a real Sphinx `Domain` subclass. Programs, options, subcommands, and positional arguments are individually addressable via `:argparse:program:`, `:argparse:option:`, `:argparse:subcommand:`, and @@ -79,7 +79,7 @@ builds. This makes tests faster, more precise, and easier to debug. ## sphinx-vite-builder: Vite + pnpm orchestration end-to-end -{doc}`sphinx-vite-builder ` consolidates +{doc}`sphinx-vite-builder ` consolidates the workspace's Vite story into a single package with three orthogonal activation paths sharing one async-subprocess core: diff --git a/packages/gp-furo-theme/pyproject.toml b/packages/gp-furo-theme/pyproject.toml index 709d0239..83924627 100644 --- a/packages/gp-furo-theme/pyproject.toml +++ b/packages/gp-furo-theme/pyproject.toml @@ -80,3 +80,12 @@ exclude = ["web/"] # https://hatch.pypa.io/latest/config/build/#artifacts [tool.hatch.build] artifacts = ["src/gp_furo_theme/theme/gp-furo/static/"] + +[tool.gp-sphinx.docs] +# Substantive public Python API (nine callables: theme path, +# pygments helpers, etc.) plus one workspace dependent. Showcase +# both: the signatures page surfaces type drift between source and +# docstring (rendered by {live-signature}), the dependents page +# links to consumers (rendered by {package-dependents}). The keys +# below name the subpage filenames, not the directive names. +showcase = ["signatures", "dependents"] diff --git a/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py b/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py index b992b22a..842037b1 100644 --- a/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py +++ b/packages/gp-sphinx/src/gp_sphinx/myst_lexer.py @@ -30,7 +30,7 @@ import typing as t from pygments.lexers.markup import MarkdownLexer, RstLexer -from pygments.token import String, Whitespace +from pygments.token import Name, String, Text, Whitespace if t.TYPE_CHECKING: import re @@ -60,7 +60,15 @@ class MystLexer(MarkdownLexer): >>> tokens = [(str(tok), v) for tok, v in lexer.get_tokens("Hello")] >>> any(v == "Hello" for _, v in tokens) True - """ + + Triple-colon fences (MyST ``colon_fence`` extension) tokenize the + opening, option keys, and closing markers so source samples + documenting MyST directives render with proper highlighting: + + >>> tokens = tokenize_myst(":::{note}\\nhi\\n:::\\n") + >>> any(":::{note}" == v for _, v in tokens) + True + """ # noqa: D301 - backslashes are in doctest code, not escape sequences name = "MyST Markdown" aliases: t.ClassVar[list[str]] = ["myst", "myst-md"] @@ -124,6 +132,97 @@ def _handle_eval_rst( yield match.start("closing"), String.Backtick, match.group("closing") + def _handle_colon_fence( + self, + match: re.Match[str], + ) -> t.Iterator[tuple[int, _TokenType, str]]: + """Lex a ``:::{}`` colon-fenced block (MyST ``colon_fence``). + + Emits the opening ``:::{name}`` line and the closing ``:::`` line + as ``String.Backtick`` (matching how :meth:`_handle_eval_rst` + renders fence boundaries). Within the body, lines matching the + ``:: `` MyST option syntax tokenize the key as + ``Name.Tag`` (RST-style option-list convention) and the value as + ``Text``; remaining body content is ``Text``. + + Parameters + ---------- + match : re.Match[str] + Regex match with named groups ``opening``, ``newline``, + ``body``, and ``closing``. + + Yields + ------ + tuple[int, _TokenType, str] + ``(offset, token_type, value)`` triples whose offsets are + relative to the start of the full document, suitable for + ``get_tokens_unprocessed``. + + Notes + ----- + Handles exactly three colons (``:::``). Four-or-more-colon + variants (``::::{name}``) are a known MyST feature but are not + currently in scope; if they appear later, capture the opening + colon count and require the closing count to match via a + backreference. + + Body content is emitted as plain ``Text`` (plus ``Name.Tag`` for + option keys). MyST directive bodies vary by directive — e.g. + ``:::{grid}`` carries sphinx-design markup, ``:::{tab-set}`` + carries inline content — so a single inner-language delegation + is not appropriate. Specialized inner highlighting can be added + per directive in a follow-up. + + Examples + -------- + >>> tokens = tokenize_myst( + ... ":::{auto-pytest-plugin} my_project.pp\\n" + ... ":package: my-project\\n" + ... ":::\\n" + ... ) + >>> any(":::{auto-pytest-plugin}" in v for _, v in tokens) + True + >>> any("Name.Tag" in tok and v == ":package:" for tok, v in tokens) + True + """ # noqa: D301 - backslashes are in doctest code, not escape sequences + import re as _re + + yield match.start("opening"), String.Backtick, match.group("opening") + yield match.start("newline"), Whitespace, match.group("newline") + + body = match.group("body") + body_offset = match.start("body") + # Walk the body line-by-line. Lines matching ``::[ ]`` + # tokenize the key as Name.Tag; everything else is Text. + # Pattern: leading ``:``, identifier, trailing ``:``, then + # optional whitespace + value through end of line. + option_re = _re.compile(r"^(:[\w\-]+:)([^\n]*)(\n)", _re.MULTILINE) + cursor = 0 + for option_match in option_re.finditer(body): + # Emit any text between the previous match end and this + # match's start as plain Text. + if option_match.start() > cursor: + preceding = body[cursor : option_match.start()] + yield body_offset + cursor, Text, preceding + yield ( + body_offset + option_match.start(1), + Name.Tag, + option_match.group(1), + ) + value = option_match.group(2) + if value: + yield body_offset + option_match.start(2), Text, value + yield ( + body_offset + option_match.start(3), + Whitespace, + option_match.group(3), + ) + cursor = option_match.end() + if cursor < len(body): + yield body_offset + cursor, Text, body[cursor:] + + yield match.start("closing"), String.Backtick, match.group("closing") + # tokens must be declared AFTER _handle_eval_rst because the class # body is executed sequentially and the dict literal references # _handle_eval_rst by name. @@ -150,6 +249,27 @@ def _handle_eval_rst( ), _handle_eval_rst, ), + # Triple-colon fence (MyST colon_fence extension): + # :::{}[ ] ... ::: + # + # The MarkdownLexer parent has no rule for these so they + # fall through to plain Token.Text without this handler. + # Handles exactly three colons; four-or-more variants are + # out of scope (see _handle_colon_fence Notes). + ( + ( + # group opening: ::: fence + braced directive name + + # optional info string (e.g. :::{tab-set} centered) + r"(?P^:::\{[\w\-]+\}[^\n]*)" + r"(?P\n)" + # group body: directive content, non-greedy to stop + # at the first closing fence + r"(?P(?:.|\n)*?)" + # group closing: bare ::: at start of line + r"(?P^:::[ \t]*$\n?)" + ), + _handle_colon_fence, + ), # All MarkdownLexer root rules follow unchanged, providing # highlighting for normal fenced code blocks, inline code, # headings, etc. diff --git a/packages/sphinx-autodoc-argparse/pyproject.toml b/packages/sphinx-autodoc-argparse/pyproject.toml index b5130257..189f8a31 100644 --- a/packages/sphinx-autodoc-argparse/pyproject.toml +++ b/packages/sphinx-autodoc-argparse/pyproject.toml @@ -42,3 +42,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_argparse"] + +[tool.gp-sphinx.docs] +# argparse extension registers the {argparse} directive plus four +# config values; the kitchen-sink page exercises the directive once +# so a reader can see what an invocation looks like end-to-end. +showcase = ["kitchen-sink"] diff --git a/packages/sphinx-autodoc-docutils/pyproject.toml b/packages/sphinx-autodoc-docutils/pyproject.toml index c009eae4..df6c10f4 100644 --- a/packages/sphinx-autodoc-docutils/pyproject.toml +++ b/packages/sphinx-autodoc-docutils/pyproject.toml @@ -41,3 +41,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_docutils"] + +[tool.gp-sphinx.docs] +# Four directives ({autodirective}, {autodirectives}, {autorole}, +# {autoroles}); kitchen-sink shows the invocation shape for each. +showcase = ["kitchen-sink"] diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 3c91d3bc..1ab2404a 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -41,3 +41,10 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_fastmcp"] + +[tool.gp-sphinx.docs] +# fastmcp registers the richest surface in the autodoc family — +# seven directives plus eight roles. The kitchen-sink page exercises +# every one on a single page so contributors can see at a glance +# what the extension exposes. +showcase = ["kitchen-sink"] diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index 16804c83..8237cf35 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -44,3 +44,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_pytest_fixtures"] + +[tool.gp-sphinx.docs] +# Four directives plus two roles for fixture discovery and rendering; +# kitchen-sink page exercises every one in a single visual surface. +showcase = ["kitchen-sink"] diff --git a/packages/sphinx-autodoc-sphinx/pyproject.toml b/packages/sphinx-autodoc-sphinx/pyproject.toml index 609a0a53..287dffcd 100644 --- a/packages/sphinx-autodoc-sphinx/pyproject.toml +++ b/packages/sphinx-autodoc-sphinx/pyproject.toml @@ -41,3 +41,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_sphinx"] + +[tool.gp-sphinx.docs] +# Two directives ({autoconfigvalue}, {autoconfigvalues}); kitchen-sink +# exercises both inline so a reader sees the invocation pair at a glance. +showcase = ["kitchen-sink"] diff --git a/packages/sphinx-autodoc-typehints-gp/pyproject.toml b/packages/sphinx-autodoc-typehints-gp/pyproject.toml index a875558d..e98241c5 100644 --- a/packages/sphinx-autodoc-typehints-gp/pyproject.toml +++ b/packages/sphinx-autodoc-typehints-gp/pyproject.toml @@ -34,3 +34,9 @@ dependencies = [ [tool.hatch.build.targets.wheel] packages = ["src/sphinx_autodoc_typehints_gp"] + +[tool.gp-sphinx.docs] +# Foundational typehint normalization library — five workspace +# packages depend on it. The dependents subpage surfaces that +# fan-out from the package's own landing. +showcase = ["dependents"] diff --git a/packages/sphinx-fonts/pyproject.toml b/packages/sphinx-fonts/pyproject.toml index b61ecb4e..8b030f62 100644 --- a/packages/sphinx-fonts/pyproject.toml +++ b/packages/sphinx-fonts/pyproject.toml @@ -38,3 +38,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_fonts"] + +[tool.gp-sphinx.docs] +# gp-sphinx loads sphinx-fonts from DEFAULT_EXTENSIONS; the +# Dependents page makes that consumer relationship explicit. +showcase = ["dependents"] diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml index 8f8059ce..dbce80f7 100644 --- a/packages/sphinx-gp-opengraph/pyproject.toml +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -38,3 +38,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_gp_opengraph"] + +[tool.gp-sphinx.docs] +# Three public callables (get_tags, html_page_context, setup) and +# one workspace dependent (gp-sphinx auto-loads when docs_url is set). +# Showcase the runtime signatures and the dependent surface. +showcase = ["signatures", "dependents"] diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml index 395d019d..d7070fa2 100644 --- a/packages/sphinx-gp-sitemap/pyproject.toml +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -38,3 +38,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_gp_sitemap"] + +[tool.gp-sphinx.docs] +# gp-sphinx auto-loads sphinx-gp-sitemap when docs_url is set; +# surfacing that consumer relationship from the package landing. +showcase = ["dependents"] diff --git a/packages/sphinx-gp-theme/pyproject.toml b/packages/sphinx-gp-theme/pyproject.toml index 81a191d5..ca5f5fcd 100644 --- a/packages/sphinx-gp-theme/pyproject.toml +++ b/packages/sphinx-gp-theme/pyproject.toml @@ -45,3 +45,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_gp_theme"] + +[tool.gp-sphinx.docs] +# Theme consumed by gp-sphinx workspace projects; surfaces the +# dependent relationship from the theme's own landing. +showcase = ["dependents"] diff --git a/packages/sphinx-ux-autodoc-layout/pyproject.toml b/packages/sphinx-ux-autodoc-layout/pyproject.toml index 1bfa2d4f..f1f8cc04 100644 --- a/packages/sphinx-ux-autodoc-layout/pyproject.toml +++ b/packages/sphinx-ux-autodoc-layout/pyproject.toml @@ -32,3 +32,9 @@ dependencies = ["sphinx>=8.1"] [tool.hatch.build.targets.wheel] packages = ["src/sphinx_ux_autodoc_layout"] + +[tool.gp-sphinx.docs] +# Layout presenter consumed by every autodoc-* extension; five +# workspace packages depend on it. Surfacing those dependents from +# this package's own landing helps a reader navigate downstream. +showcase = ["dependents"] diff --git a/packages/sphinx-ux-badges/pyproject.toml b/packages/sphinx-ux-badges/pyproject.toml index e70ddb18..b344a066 100644 --- a/packages/sphinx-ux-badges/pyproject.toml +++ b/packages/sphinx-ux-badges/pyproject.toml @@ -38,3 +38,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sphinx_ux_badges"] + +[tool.gp-sphinx.docs] +# Five workspace packages depend on sphinx-ux-badges (every autodoc-* +# extension consumes the SAB palette); the dependents subpage makes +# that fan-out discoverable from the package's own landing. +showcase = ["dependents"] diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..9bcec3e1 --- /dev/null +++ b/tests/docs/__init__.py @@ -0,0 +1 @@ +"""Tests for ``docs/_ext/`` directives, roles, and helpers.""" diff --git a/tests/docs/__snapshots__/objects-inv-baseline.txt b/tests/docs/__snapshots__/objects-inv-baseline.txt new file mode 100644 index 00000000..cc710349 --- /dev/null +++ b/tests/docs/__snapshots__/objects-inv-baseline.txt @@ -0,0 +1,484 @@ +argparse:option myapp --config +argparse:option myapp --verbose +argparse:option myapp -v +argparse:option myapp myothersubcommand --host +argparse:option myapp myothersubcommand --port +argparse:option myapp mysubcommand --clean +argparse:option myapp mysubcommand --format +argparse:option myapp mysubcommand --output +argparse:option myapp mysubcommand -o +argparse:program myapp +argparse:program myapp myothersubcommand +argparse:program myapp mysubcommand +argparse:subcommand myapp myothersubcommand +argparse:subcommand myapp mysubcommand +py:attribute gp_demo_api.DemoAbstractBase._abc_impl +py:attribute gp_demo_api.DemoClass.demo_attr +py:attribute sphinx_ux_badges.BadgeSpec.classes +py:attribute sphinx_ux_badges.BadgeSpec.fill +py:attribute sphinx_ux_badges.BadgeSpec.icon +py:attribute sphinx_ux_badges.BadgeSpec.size +py:attribute sphinx_ux_badges.BadgeSpec.style +py:attribute sphinx_ux_badges.BadgeSpec.tabindex +py:attribute sphinx_ux_badges.BadgeSpec.text +py:attribute sphinx_ux_badges.BadgeSpec.tooltip +py:attribute sphinx_ux_badges._css.SAB.BADGE +py:attribute sphinx_ux_badges._css.SAB.BADGE_FIXTURE +py:attribute sphinx_ux_badges._css.SAB.BADGE_GROUP +py:attribute sphinx_ux_badges._css.SAB.BADGE_KIND +py:attribute sphinx_ux_badges._css.SAB.BADGE_LABEL +py:attribute sphinx_ux_badges._css.SAB.BADGE_MOD +py:attribute sphinx_ux_badges._css.SAB.BADGE_SCOPE +py:attribute sphinx_ux_badges._css.SAB.BADGE_STATE +py:attribute sphinx_ux_badges._css.SAB.BADGE_TYPE +py:attribute sphinx_ux_badges._css.SAB.DENSE +py:attribute sphinx_ux_badges._css.SAB.FILLED +py:attribute sphinx_ux_badges._css.SAB.ICON_ONLY +py:attribute sphinx_ux_badges._css.SAB.ICON_RIGHT +py:attribute sphinx_ux_badges._css.SAB.INLINE_ICON +py:attribute sphinx_ux_badges._css.SAB.LG +py:attribute sphinx_ux_badges._css.SAB.MD +py:attribute sphinx_ux_badges._css.SAB.META_ALPHA +py:attribute sphinx_ux_badges._css.SAB.META_BETA +py:attribute sphinx_ux_badges._css.SAB.META_LINK +py:attribute sphinx_ux_badges._css.SAB.MOD_ABSTRACT +py:attribute sphinx_ux_badges._css.SAB.MOD_ASYNC +py:attribute sphinx_ux_badges._css.SAB.MOD_CLASSMETHOD +py:attribute sphinx_ux_badges._css.SAB.MOD_FINAL +py:attribute sphinx_ux_badges._css.SAB.MOD_REBUILD +py:attribute sphinx_ux_badges._css.SAB.MOD_STATICMETHOD +py:attribute sphinx_ux_badges._css.SAB.NO_UNDERLINE +py:attribute sphinx_ux_badges._css.SAB.OUTLINE +py:attribute sphinx_ux_badges._css.SAB.PREFIX +py:attribute sphinx_ux_badges._css.SAB.SCOPE_CLASS +py:attribute sphinx_ux_badges._css.SAB.SCOPE_MODULE +py:attribute sphinx_ux_badges._css.SAB.SCOPE_SESSION +py:attribute sphinx_ux_badges._css.SAB.SM +py:attribute sphinx_ux_badges._css.SAB.STATE_AUTOUSE +py:attribute sphinx_ux_badges._css.SAB.STATE_DEPRECATED +py:attribute sphinx_ux_badges._css.SAB.STATE_FACTORY +py:attribute sphinx_ux_badges._css.SAB.STATE_OVERRIDE +py:attribute sphinx_ux_badges._css.SAB.TOOLBAR +py:attribute sphinx_ux_badges._css.SAB.TYPE_ATTRIBUTE +py:attribute sphinx_ux_badges._css.SAB.TYPE_CLASS +py:attribute sphinx_ux_badges._css.SAB.TYPE_CONFIG +py:attribute sphinx_ux_badges._css.SAB.TYPE_DATA +py:attribute sphinx_ux_badges._css.SAB.TYPE_DIRECTIVE +py:attribute sphinx_ux_badges._css.SAB.TYPE_EXCEPTION +py:attribute sphinx_ux_badges._css.SAB.TYPE_FIXTURE +py:attribute sphinx_ux_badges._css.SAB.TYPE_FUNCTION +py:attribute sphinx_ux_badges._css.SAB.TYPE_METHOD +py:attribute sphinx_ux_badges._css.SAB.TYPE_MODULE +py:attribute sphinx_ux_badges._css.SAB.TYPE_OPTION +py:attribute sphinx_ux_badges._css.SAB.TYPE_PROPERTY +py:attribute sphinx_ux_badges._css.SAB.TYPE_ROLE +py:attribute sphinx_ux_badges._css.SAB.TYPE_TYPEALIAS +py:attribute sphinx_ux_badges._css.SAB.UNDERLINE_DOTTED +py:attribute sphinx_ux_badges._css.SAB.UNDERLINE_SOLID +py:attribute sphinx_ux_badges._css.SAB.XL +py:attribute sphinx_ux_badges._css.SAB.XS +py:attribute sphinx_ux_badges._css.SAB.XXS +py:class api_demo_layout.LayoutDemo +py:class gp_demo_api.DemoAbstractBase +py:class gp_demo_api.DemoClass +py:class gp_sphinx.myst_lexer.MystLexer +py:class sphinx_autodoc_argparse.cli_usage_lexer.CLIUsageLexer +py:class sphinx_autodoc_argparse.directive.ArgparseDirective +py:class sphinx_autodoc_argparse.exemplar.CleanArgParseDirective +py:class sphinx_autodoc_argparse.lexer.ArgparseHelpLexer +py:class sphinx_autodoc_argparse.lexer.ArgparseLexer +py:class sphinx_autodoc_argparse.lexer.ArgparseUsageLexer +py:class sphinx_autodoc_docutils._directives.AutoDirective +py:class sphinx_autodoc_docutils._directives.AutoDirectives +py:class sphinx_autodoc_docutils._directives.AutoRole +py:class sphinx_autodoc_docutils._directives.AutoRoles +py:class sphinx_autodoc_fastmcp._directives.FastMCPPromptDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPPromptInputDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPResourceDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPResourceTemplateDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPToolDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPToolInputDirective +py:class sphinx_autodoc_fastmcp._directives.FastMCPToolSummaryDirective +py:class sphinx_autodoc_pytest_fixtures._directives.AutoPytestPluginDirective +py:class sphinx_autodoc_pytest_fixtures._directives.AutofixturesDirective +py:class sphinx_autodoc_pytest_fixtures._directives.PyFixtureDirective +py:class sphinx_autodoc_sphinx._directives.AutoconfigvalueDirective +py:class sphinx_autodoc_sphinx._directives.AutoconfigvaluesDirective +py:class sphinx_ux_badges.BadgeNode +py:class sphinx_ux_badges.BadgeSpec +py:class sphinx_ux_badges._builders.BadgeSpec +py:class sphinx_ux_badges._css.SAB +py:class sphinx_ux_badges._nodes.BadgeNode +py:data gp_demo_api.DEMO_CONSTANT +py:exception gp_demo_api.DemoError +py:fixture spf_demo_fixtures.demo_autouse +py:fixture spf_demo_fixtures.demo_class +py:fixture spf_demo_fixtures.demo_deprecated +py:fixture spf_demo_fixtures.demo_factory +py:fixture spf_demo_fixtures.demo_module +py:fixture spf_demo_fixtures.demo_override_hook +py:fixture spf_demo_fixtures.demo_plain +py:fixture spf_demo_fixtures.demo_session +py:fixture spf_demo_fixtures.demo_session_autouse +py:fixture spf_demo_fixtures.demo_session_factory +py:function api_demo_layout.compact_function +py:function gp_demo_api.demo_async_function +py:function gp_demo_api.demo_deprecated_function +py:function gp_demo_api.demo_function +py:function gp_sphinx.config.deep_merge +py:function gp_sphinx.config.make_linkcode_resolve +py:function gp_sphinx.config.merge_sphinx_config +py:function gp_sphinx.config.setup +py:function sphinx.domains.python.PyXRefRole +py:function sphinx_autodoc_argparse.roles.cli_choice_role +py:function sphinx_autodoc_argparse.roles.cli_command_role +py:function sphinx_autodoc_argparse.roles.cli_default_role +py:function sphinx_autodoc_argparse.roles.cli_metavar_role +py:function sphinx_autodoc_argparse.roles.cli_option_role +py:function sphinx_autodoc_fastmcp._roles._tool_role +py:function sphinx_autodoc_fastmcp._roles._toolref_role +py:function sphinx_autodoc_fastmcp._roles.role_fn +py:function sphinx_autodoc_fastmcp._transforms.badge_role +py:function sphinx_ux_autodoc_layout.build_api_card_entry +py:function sphinx_ux_autodoc_layout.build_api_facts_section +py:function sphinx_ux_autodoc_layout.build_api_summary_section +py:function sphinx_ux_autodoc_layout.build_api_table_section +py:function sphinx_ux_badges.build_badge +py:function sphinx_ux_badges.build_badge_from_spec +py:function sphinx_ux_badges.build_badge_group +py:function sphinx_ux_badges.build_toolbar +py:function sphinx_ux_badges.setup +py:method api_demo_layout.LayoutDemo.__init__ +py:method api_demo_layout.LayoutDemo.close +py:method api_demo_layout.LayoutDemo.connect +py:method api_demo_layout.LayoutDemo.execute +py:method gp_demo_api.DemoAbstractBase.async_abstract +py:method gp_demo_api.DemoAbstractBase.must_implement +py:method gp_demo_api.DemoClass.__init__ +py:method gp_demo_api.DemoClass.async_method +py:method gp_demo_api.DemoClass.deprecated_method +py:method gp_demo_api.DemoClass.from_int +py:method gp_demo_api.DemoClass.regular_method +py:method gp_demo_api.DemoClass.utility +py:method sphinx_ux_badges.BadgeNode.__init__ +py:method sphinx_ux_badges.BadgeNode.__new__ +py:method sphinx_ux_badges.BadgeSpec.__init__ +py:method sphinx_ux_badges._css.SAB.obj_type +py:method sphinx_ux_badges._css.SAB.scope +py:module api_demo_layout +py:module gp_demo_api +py:module spf_demo_fixtures +py:property gp_demo_api.DemoClass.computed +rst:directive autoconfigvalue +rst:directive autoconfigvalues +rst:directive cleanargparse +rst:directive fastmcp-prompt +rst:directive fastmcp-prompt-input +rst:directive fastmcp-resource +rst:directive fastmcp-resource-template +rst:directive fastmcp-tool +rst:directive fastmcp-tool-input +rst:directive fastmcp-tool-summary +rst:directive:option autoconfigvalue:no-index +rst:directive:option autoconfigvalues:no-index +rst:directive:option cleanargparse:func +rst:directive:option cleanargparse:mock-modules +rst:directive:option cleanargparse:module +rst:directive:option cleanargparse:no-choices +rst:directive:option cleanargparse:no-defaults +rst:directive:option cleanargparse:no-description +rst:directive:option cleanargparse:no-epilog +rst:directive:option cleanargparse:no-types +rst:directive:option cleanargparse:nodefault +rst:directive:option cleanargparse:nodescription +rst:directive:option cleanargparse:noepilog +rst:directive:option cleanargparse:nosubcommands +rst:directive:option cleanargparse:path +rst:directive:option cleanargparse:prog +rst:directive:option fastmcp-tool:no-index +rst:role badge +rst:role cli-choice +rst:role cli-command +rst:role cli-default +rst:role cli-metavar +rst:role cli-option +rst:role tool +rst:role toolicon +rst:role tooliconil +rst:role tooliconir +rst:role tooliconl +rst:role tooliconr +rst:role toolref +std:cmdoption myapp-myothersubcommand.--host +std:cmdoption myapp-myothersubcommand.--port +std:cmdoption myapp-mysubcommand.--clean +std:cmdoption myapp-mysubcommand.--format +std:cmdoption myapp-mysubcommand.--output +std:cmdoption myapp-mysubcommand.-o +std:cmdoption myapp.--config +std:cmdoption myapp.--verbose +std:cmdoption myapp.-v +std:confval api_collapsed_threshold +std:confval api_fold_parameters +std:confval api_layout_enabled +std:confval api_signature_show_annotations +std:confval argparse_examples_base_term +std:confval argparse_examples_code_classes +std:confval argparse_examples_code_language +std:confval argparse_examples_command_prefix +std:confval argparse_examples_section_title +std:confval argparse_examples_term_suffix +std:confval argparse_group_title_prefix +std:confval argparse_reorder_usage_before_examples +std:confval argparse_show_choices +std:confval argparse_show_defaults +std:confval argparse_show_types +std:confval argparse_usage_code_language +std:confval argparse_usage_pattern +std:confval demo_show_callouts +std:confval demo_theme_accent +std:confval fastmcp_area_map +std:confval fastmcp_collector_mode +std:confval fastmcp_model_classes +std:confval fastmcp_model_module +std:confval fastmcp_section_badge_map +std:confval fastmcp_section_badge_pages +std:confval fastmcp_server_module +std:confval fastmcp_tool_modules +std:confval html_baseurl +std:confval ogp_canonical_url +std:confval ogp_custom_meta_tags +std:confval ogp_description_length +std:confval ogp_enable_meta_description +std:confval ogp_image +std:confval ogp_image_alt +std:confval ogp_site_name +std:confval ogp_site_url +std:confval ogp_social_cards +std:confval ogp_type +std:confval ogp_use_first_image +std:confval pytest_fixture_builtin_links +std:confval pytest_fixture_external_links +std:confval pytest_fixture_hidden_dependencies +std:confval pytest_fixture_lint_level +std:confval site_url +std:confval sitemap_excludes +std:confval sitemap_filename +std:confval sitemap_indent +std:confval sitemap_locales +std:confval sitemap_show_lastmod +std:confval sitemap_url_scheme +std:confval sphinx_font_css_variables +std:confval sphinx_font_fallbacks +std:confval sphinx_font_preload +std:confval sphinx_fonts +std:doc api +std:doc architecture +std:doc configuration +std:doc gallery +std:doc history +std:doc index +std:doc packages/gp-furo-theme/how-to +std:doc packages/gp-furo-theme/index +std:doc packages/gp-sphinx/how-to +std:doc packages/gp-sphinx/index +std:doc packages/index +std:doc packages/sphinx-autodoc-api-style/examples +std:doc packages/sphinx-autodoc-api-style/how-to +std:doc packages/sphinx-autodoc-api-style/index +std:doc packages/sphinx-autodoc-api-style/reference +std:doc packages/sphinx-autodoc-api-style/tutorial +std:doc packages/sphinx-autodoc-argparse/examples +std:doc packages/sphinx-autodoc-argparse/how-to +std:doc packages/sphinx-autodoc-argparse/index +std:doc packages/sphinx-autodoc-argparse/reference +std:doc packages/sphinx-autodoc-argparse/tutorial +std:doc packages/sphinx-autodoc-docutils/examples +std:doc packages/sphinx-autodoc-docutils/how-to +std:doc packages/sphinx-autodoc-docutils/index +std:doc packages/sphinx-autodoc-docutils/tutorial +std:doc packages/sphinx-autodoc-fastmcp/examples +std:doc packages/sphinx-autodoc-fastmcp/how-to +std:doc packages/sphinx-autodoc-fastmcp/index +std:doc packages/sphinx-autodoc-fastmcp/reference +std:doc packages/sphinx-autodoc-fastmcp/tutorial +std:doc packages/sphinx-autodoc-pytest-fixtures/examples +std:doc packages/sphinx-autodoc-pytest-fixtures/how-to +std:doc packages/sphinx-autodoc-pytest-fixtures/index +std:doc packages/sphinx-autodoc-pytest-fixtures/tutorial +std:doc packages/sphinx-autodoc-sphinx/examples +std:doc packages/sphinx-autodoc-sphinx/how-to +std:doc packages/sphinx-autodoc-sphinx/index +std:doc packages/sphinx-autodoc-sphinx/reference +std:doc packages/sphinx-autodoc-sphinx/tutorial +std:doc packages/sphinx-autodoc-typehints-gp/examples +std:doc packages/sphinx-autodoc-typehints-gp/how-to +std:doc packages/sphinx-autodoc-typehints-gp/index +std:doc packages/sphinx-autodoc-typehints-gp/tutorial +std:doc packages/sphinx-fonts/how-to +std:doc packages/sphinx-fonts/index +std:doc packages/sphinx-fonts/reference +std:doc packages/sphinx-gp-opengraph/how-to +std:doc packages/sphinx-gp-opengraph/index +std:doc packages/sphinx-gp-opengraph/reference +std:doc packages/sphinx-gp-sitemap/how-to +std:doc packages/sphinx-gp-sitemap/index +std:doc packages/sphinx-gp-sitemap/reference +std:doc packages/sphinx-gp-theme/how-to +std:doc packages/sphinx-gp-theme/index +std:doc packages/sphinx-ux-autodoc-layout/examples +std:doc packages/sphinx-ux-autodoc-layout/how-to +std:doc packages/sphinx-ux-autodoc-layout/index +std:doc packages/sphinx-ux-autodoc-layout/reference +std:doc packages/sphinx-ux-autodoc-layout/tutorial +std:doc packages/sphinx-ux-badges/examples +std:doc packages/sphinx-ux-badges/explanation +std:doc packages/sphinx-ux-badges/index +std:doc packages/sphinx-ux-badges/reference +std:doc packages/sphinx-ux-badges/tutorial +std:doc packages/sphinx-vite-builder/how-to +std:doc packages/sphinx-vite-builder/index +std:doc project/code-style +std:doc project/contributing +std:doc project/index +std:doc project/releasing +std:doc quickstart +std:doc whats-new +std:label all-workspace-packages +std:label architecture +std:label argparse-optionsindex +std:label argparse-programsindex +std:label base-argparse-directive +std:label configuration +std:label create-session +std:label delete-session +std:label developmental-releases +std:label downstream-conf-py +std:label fastmcp-server-module +std:label fastmcp-tool-create-session +std:label fastmcp-tool-delete-session +std:label fastmcp-tool-list-sessions +std:label from-docs-url +std:label from-docs_url +std:label from-overrides +std:label from-source-repository +std:label gallery +std:label genindex +std:label gp-furo-theme +std:label gp-furo-theme-how-to +std:label gp-furo-theme-wheels-now-ship-with-vite-built-css-and-js +std:label gp-sphinx +std:label gp-sphinx-how-to +std:label gp-sphinx-integrated-autodoc-design-system +std:label gp-sphinx-no-more-theme-flicker-on-initial-load-or-toggle +std:label gp-sphinx-preserve-docs-url-path-component-in-derived-urls +std:label gp-sphinx-seo-config-auto-wired-from-docs-url +std:label history +std:label how-sitemap-xml-is-built +std:label id2 +std:label index +std:label injected-setup-app +std:label list-sessions +std:label merge-sphinx-config-parameters +std:label modindex +std:label new-package-gp-furo-theme +std:label new-package-gp-sphinx +std:label new-package-gp-sphinx-vite +std:label new-package-sphinx-autodoc-argparse +std:label new-package-sphinx-autodoc-fastmcp +std:label new-package-sphinx-autodoc-pytest-fixtures +std:label new-package-sphinx-autodoc-typehints-gp +std:label new-package-sphinx-fonts +std:label new-package-sphinx-gp-opengraph +std:label new-package-sphinx-gp-sitemap +std:label new-package-sphinx-gp-theme +std:label new-package-sphinx-ux-autodoc-layout +std:label new-package-sphinx-ux-badges +std:label new-package-sphinx-vite-builder +std:label project +std:label py-modindex +std:label quickstart +std:label ref-cross-reference-ids +std:label search +std:label shared-default-constants +std:label sphinx-autodoc-api-style +std:label sphinx-autodoc-api-style-examples +std:label sphinx-autodoc-api-style-how-to +std:label sphinx-autodoc-api-style-reference +std:label sphinx-autodoc-api-style-tutorial +std:label sphinx-autodoc-argparse +std:label sphinx-autodoc-argparse-examples +std:label sphinx-autodoc-argparse-how-to +std:label sphinx-autodoc-argparse-new-argparse-sphinx-domain +std:label sphinx-autodoc-argparse-no-more-duplicate-label-warnings-on-multi-page-docs +std:label sphinx-autodoc-argparse-reference +std:label sphinx-autodoc-argparse-tutorial +std:label sphinx-autodoc-docutils +std:label sphinx-autodoc-docutils-examples +std:label sphinx-autodoc-docutils-how-to +std:label sphinx-autodoc-docutils-register-aware-directive-and-role-discovery +std:label sphinx-autodoc-docutils-surface-failed-setup-replay-in-build-log +std:label sphinx-autodoc-docutils-tutorial +std:label sphinx-autodoc-fastmcp +std:label sphinx-autodoc-fastmcp-decorator-registered-components-no-longer-dropped +std:label sphinx-autodoc-fastmcp-examples +std:label sphinx-autodoc-fastmcp-how-to +std:label sphinx-autodoc-fastmcp-reference +std:label sphinx-autodoc-fastmcp-section-labels-resolve-by-component-name +std:label sphinx-autodoc-fastmcp-surface-real-import-failures +std:label sphinx-autodoc-fastmcp-tutorial +std:label sphinx-autodoc-pytest-fixtures +std:label sphinx-autodoc-pytest-fixtures-examples +std:label sphinx-autodoc-pytest-fixtures-how-to +std:label sphinx-autodoc-pytest-fixtures-tutorial +std:label sphinx-autodoc-pytest-fixtures-typealias-resolution +std:label sphinx-autodoc-sphinx +std:label sphinx-autodoc-sphinx-examples +std:label sphinx-autodoc-sphinx-how-to +std:label sphinx-autodoc-sphinx-reference +std:label sphinx-autodoc-sphinx-tutorial +std:label sphinx-autodoc-typehints-gp +std:label sphinx-autodoc-typehints-gp-empty-examples-references-sections-render-their-rubric +std:label sphinx-autodoc-typehints-gp-examples +std:label sphinx-autodoc-typehints-gp-exc-references-with-mod-foo-shorten-to-foo +std:label sphinx-autodoc-typehints-gp-how-to +std:label sphinx-autodoc-typehints-gp-raises-type-fields-preserve-parameterised-generics +std:label sphinx-autodoc-typehints-gp-tutorial +std:label sphinx-fonts +std:label sphinx-fonts-full-weight-range-for-ibm-plex-sans-and-mono +std:label sphinx-fonts-how-to +std:label sphinx-fonts-reference +std:label sphinx-gp-opengraph +std:label sphinx-gp-opengraph-how-to +std:label sphinx-gp-opengraph-html-escape-every-meta-tag-attribute +std:label sphinx-gp-opengraph-reference +std:label sphinx-gp-opengraph-xhtml-self-closing-void-tags-no-longer-drop-trailing-title-text +std:label sphinx-gp-sitemap +std:label sphinx-gp-sitemap-complete-sitemap-on-incremental-and-parallel-builds +std:label sphinx-gp-sitemap-how-to +std:label sphinx-gp-sitemap-reference +std:label sphinx-gp-theme +std:label sphinx-gp-theme-argparse-directives-now-follow-the-active-theme +std:label sphinx-gp-theme-copy-buttons-survive-spa-navigation +std:label sphinx-gp-theme-gp-sphinx-navigated-event-after-spa-nav +std:label sphinx-gp-theme-how-to +std:label sphinx-gp-theme-light-mode-code-blocks-now-render-with-a-light-palette +std:label sphinx-gp-theme-light-mode-shell-prompts-and-command-output-now-visually-distinct +std:label sphinx-gp-theme-sidebar-logo-no-longer-disappears-on-ios-safari-during-navigation +std:label sphinx-gp-theme-spa-nav-scrolls-to-anchor-on-cross-page-fragments +std:label sphinx-ux-autodoc-layout +std:label sphinx-ux-autodoc-layout-examples +std:label sphinx-ux-autodoc-layout-how-to +std:label sphinx-ux-autodoc-layout-reference +std:label sphinx-ux-autodoc-layout-tutorial +std:label sphinx-ux-badges +std:label sphinx-ux-badges-examples +std:label sphinx-ux-badges-explanation +std:label sphinx-ux-badges-reference +std:label sphinx-ux-badges-restore-background-border-and-tooltip-styling +std:label sphinx-ux-badges-shared-badge-surface +std:label sphinx-ux-badges-tutorial +std:label sphinx-vite-builder +std:label sphinx-vite-builder-how-to +std:label whats-new +std:label when-to-use-auto-pytest-plugin diff --git a/tests/docs/test_cluster_toctree.py b/tests/docs/test_cluster_toctree.py new file mode 100644 index 00000000..6053a00a --- /dev/null +++ b/tests/docs/test_cluster_toctree.py @@ -0,0 +1,150 @@ +"""Tests for the ``{cluster-toctree}`` directive. + +Verifies the rendered hidden toctree only includes Shipped packages +(shipped-py + shipped-js), skipping Emerging packages so the build +never references a missing docname (Risk 2 mitigation). +""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def _toctree_lines(rendered: str) -> list[str]: + """Return the leaf entries inside the rendered ``{toctree}`` block.""" + inside = False + leaves: list[str] = [] + for line in rendered.splitlines(): + if line.startswith("```{toctree}"): + inside = True + continue + if line.startswith("```") and inside: + inside = False + continue + if not inside: + continue + stripped = line.strip() + if not stripped: + continue + if stripped.startswith(":"): + continue + leaves.append(stripped) + return leaves + + +def test_cluster_toctree_autodoc_includes_seven_autodoc_packages() -> None: + """The autodoc cluster has exactly the seven sphinx-autodoc-* packages.""" + rendered = package_reference._cluster_toctree_markdown( + "autodoc", + caption=None, + titlesonly=False, + ) + leaves = _toctree_lines(rendered) + assert len(leaves) == 7 + assert all(leaf.startswith("packages/sphinx-autodoc-") for leaf in leaves) + assert all(leaf.endswith("/index") for leaf in leaves) + + +def test_cluster_toctree_renders_caption_and_titlesonly_when_requested() -> None: + """Optional :caption: and :titlesonly: lines appear when set.""" + rendered = package_reference._cluster_toctree_markdown( + "autodoc", + caption="Autodoc", + titlesonly=True, + ) + assert ":caption: Autodoc" in rendered + assert ":titlesonly:" in rendered + assert ":hidden:" in rendered + + +def test_cluster_toctree_omits_caption_when_unset() -> None: + """No :caption: line is emitted when the option is None.""" + rendered = package_reference._cluster_toctree_markdown( + "autodoc", + caption=None, + titlesonly=False, + ) + assert ":caption:" not in rendered + + +def test_cluster_toctree_returns_empty_for_unknown_cluster() -> None: + """Empty cluster name renders as empty string (caller logs warning).""" + rendered = package_reference._cluster_toctree_markdown( + "definitely-no-such-cluster", + caption=None, + titlesonly=False, + ) + assert rendered == "" + + +def test_cluster_toctree_includes_shipped_js_packages() -> None: + """The tokens cluster includes JS-only Shipped packages alongside Python ones.""" + rendered = package_reference._cluster_toctree_markdown( + "tokens", + caption=None, + titlesonly=False, + ) + leaves = _toctree_lines(rendered) + assert "packages/sphinx-fonts/index" in leaves + assert "packages/@gp-sphinx/furo-tokens/index" in leaves + + +def test_cluster_toctree_skips_emerging_packages() -> None: + """No Emerging package appears in any cluster's toctree.""" + emerging_names = { + record.name + for record in package_reference.workspace_package_records() + if record.state == "emerging" + } + if not emerging_names: + pytest.skip("no Emerging packages in workspace; nothing to assert") + + for cluster in ("theme-coordinator", "tokens", "autodoc", "ux", "build-seo"): + rendered = package_reference._cluster_toctree_markdown( + cluster, + caption=None, + titlesonly=False, + ) + leaves = _toctree_lines(rendered) + for emerging in emerging_names: + assert f"packages/{emerging}/index" not in leaves + + +def test_cluster_toctree_leaves_are_alphabetical() -> None: + """Within a cluster, package landings sort alphabetically by name.""" + rendered = package_reference._cluster_toctree_markdown( + "autodoc", + caption=None, + titlesonly=False, + ) + leaves = _toctree_lines(rendered) + assert leaves == sorted(leaves) + + +def test_cluster_toctree_every_shipped_package_classified() -> None: + """Every Shipped package falls into one of the recognized clusters.""" + expected_clusters = {"theme-coordinator", "tokens", "autodoc", "ux", "build-seo"} + seen: set[str] = set() + for cluster in expected_clusters: + rendered = package_reference._cluster_toctree_markdown( + cluster, + caption=None, + titlesonly=False, + ) + for leaf in _toctree_lines(rendered): + # leaf format: packages//index + seen.add(leaf[len("packages/") : -len("/index")]) + shipped_names = { + record.name + for record in package_reference.workspace_package_records() + if record.state in {"shipped-py", "shipped-js"} + } + missing = shipped_names - seen + assert not missing, f"Shipped packages without a cluster: {sorted(missing)}" diff --git a/tests/docs/test_inline_highlight.py b/tests/docs/test_inline_highlight.py new file mode 100644 index 00000000..1b9ae88f --- /dev/null +++ b/tests/docs/test_inline_highlight.py @@ -0,0 +1,200 @@ +"""Tests for the inline-highlight transform. + +Validates the four content-pattern dispatchers (bare RST role, +RST role-with-content, shell session, inline RST directive) plus +the dimensional-invariant guarantee that the rendered output keeps +the ```` outer wrapper. +""" + +from __future__ import annotations + +import pathlib +import sys +import typing as t + +import pytest +from docutils import nodes + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import inline_highlight + + +class _PatternFixture(t.NamedTuple): + """Fixture row for the ``_inline_html_for`` dispatcher.""" + + test_id: str + text: str + expected_class: str | None + expected_token_value: str | None + + +_PATTERN_FIXTURES: list[_PatternFixture] = [ + _PatternFixture( + test_id="bare_rst_role_simple", + text=":tool:", + expected_class="na", + expected_token_value=":tool:", + ), + _PatternFixture( + test_id="bare_rst_role_namespaced", + text=":argparse:program:", + expected_class="na", + expected_token_value=":argparse:program:", + ), + _PatternFixture( + test_id="rst_role_with_content", + text=":tool:`list_sessions`", + expected_class="nv", + expected_token_value="`list_sessions`", + ), + _PatternFixture( + test_id="rst_namespaced_role_with_content", + text=":argparse:program:`myapp`", + expected_class="nv", + expected_token_value="`myapp`", + ), + _PatternFixture( + test_id="shell_session", + text="$ uv run pytest", + expected_class="gp", + expected_token_value="$ ", + ), + _PatternFixture( + test_id="inline_rst_directive", + text=".. autodirective::", + expected_class="ow", + expected_token_value="autodirective", + ), + _PatternFixture( + test_id="plain_prose_no_match", + text="just some prose", + expected_class=None, + expected_token_value=None, + ), + _PatternFixture( + test_id="plain_module_name_no_match", + text="my_project.docs", + expected_class=None, + expected_token_value=None, + ), +] + + +@pytest.mark.parametrize( + list(_PatternFixture._fields), + _PATTERN_FIXTURES, + ids=[case.test_id for case in _PATTERN_FIXTURES], +) +def test_inline_html_for_dispatches_by_pattern( + test_id: str, + text: str, + expected_class: str | None, + expected_token_value: str | None, +) -> None: + """``_inline_html_for`` returns the right tokens or ``None``.""" + rendered = inline_highlight._inline_html_for(text) + if expected_class is None: + assert rendered is None, f"{test_id}: expected None, got {rendered!r}" + return + assert rendered is not None + assert f'class="{expected_class}"' in rendered, ( + f"{test_id}: expected class {expected_class!r} in {rendered!r}" + ) + assert expected_token_value is not None # narrow for mypy + assert expected_token_value in rendered, ( + f"{test_id}: expected token value {expected_token_value!r} in {rendered!r}" + ) + + +def test_transform_preserves_code_outer_wrapper() -> None: + """Replacement raw HTML keeps ````. + + Dimensional invariant: Furo's inline-literal styling targets + ``code.literal``. If the transform replaced ```` with + ````, the box styling (background, padding, border-radius, + font-size) would be lost and lines would warp. Verify the outer + element is still ```` with the expected classes. + """ + document = _make_document_with_literal(":tool:`list_sessions`") + transform = inline_highlight.InlineHighlightTransform(document) + transform.apply() + + raw_nodes = list(document.findall(nodes.raw)) + assert raw_nodes, "expected the literal to have been replaced with raw HTML" + rendered = raw_nodes[0].astext() + assert rendered.startswith('') + assert rendered.endswith("") + + +def test_transform_skips_unmatched_literals() -> None: + """A literal whose content matches no pattern stays untouched.""" + document = _make_document_with_literal("just plain prose here") + transform = inline_highlight.InlineHighlightTransform(document) + transform.apply() + + # The original literal node should still be in the document + literals = list(document.findall(nodes.literal)) + assert len(literals) == 1 + assert literals[0].astext() == "just plain prose here" + + +def test_transform_skips_empty_literals() -> None: + """An empty literal is not rewritten (would produce empty span output).""" + document = _make_document_with_literal("") + transform = inline_highlight.InlineHighlightTransform(document) + transform.apply() + + raw_nodes = list(document.findall(nodes.raw)) + assert raw_nodes == [] + + +def test_inline_formatter_strips_trailing_newline() -> None: + r"""The custom formatter drops the trailing ``(Text, '\n')`` token. + + Without this, every inline span would emit a phantom ```` at + the end, breaking visual tightness against subsequent prose. + """ + rendered = inline_highlight._inline_html_for(":tool:`x`") + assert rendered is not None + # The output must not end with a newline (which would produce a + # phantom whitespace span after Sphinx's writer wraps it). + assert not rendered.endswith("\n") + + +def test_bare_rst_role_html_escapes_content() -> None: + """The bare-role helper escapes HTML special characters defensively.""" + # Hypothetical edge case: a literal whose content matches the bare + # role pattern but happens to contain a < or & character. The + # current pattern won't match such content (regex restricts to + # word + hyphen), but the escaping is still defensive. + rendered = inline_highlight._bare_rst_role_html(":a&b:") + assert "&" in rendered + + +def _make_document_with_literal(text: str) -> nodes.document: + """Build a minimal docutils document containing one ``nodes.literal``. + + Uses the canonical ``OptionParser(components=(Parser,))`` recipe to + derive default settings, then ``new_document()`` to attach a reporter + and source path. The returned document has everything + ``Transform.apply`` needs to traverse and rewrite nodes. + """ + import warnings + + from docutils.parsers.rst import Parser + from docutils.utils import new_document + + with warnings.catch_warnings(): + # docutils' OptionParser triggers a DeprecationWarning since + # 0.21 (replaced by argparse-based class). The replacement + # isn't shipped yet upstream; suppress the warning until it is. + warnings.simplefilter("ignore", DeprecationWarning) + from docutils.frontend import OptionParser + + settings = OptionParser(components=(Parser,)).get_default_values() + document = new_document("", settings) + paragraph = nodes.paragraph() + paragraph += nodes.literal(text, text) + document += paragraph + return document diff --git a/tests/docs/test_live_signature.py b/tests/docs/test_live_signature.py new file mode 100644 index 00000000..2c19a719 --- /dev/null +++ b/tests/docs/test_live_signature.py @@ -0,0 +1,72 @@ +"""Tests for the ``{live-signature}`` showcase directive. + +The directive imports a package's module and renders each public +callable's signature from the running interpreter. +""" + +from __future__ import annotations + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def test_live_signature_markdown_renders_public_callables_for_sphinx_fonts() -> None: + """sphinx_fonts has at least one public callable; markdown lists it. + + Body-only: the directive emits no anchor or H1; the stub at + ``packages/sphinx-fonts/signatures.md`` provides those so Sphinx + finds a page title at parse time. + """ + rendered = package_reference._live_signature_markdown("sphinx-fonts") + assert rendered, "expected non-empty markdown for sphinx-fonts" + # Body-only output: no anchor or H1 emitted by the directive + assert "(sphinx-fonts-signatures)=" not in rendered + assert "# Signatures (live)" not in rendered + # The setup function must appear as a public callable + assert ( + "### `setup`" in rendered + or "### `merge_sphinx_config`" in rendered + or any(line.startswith("### `") for line in rendered.splitlines()) + ) + + +def test_live_signature_markdown_is_empty_for_unknown_package() -> None: + """Unknown package returns the empty string (caller logs a warning).""" + rendered = package_reference._live_signature_markdown("definitely-no-such-pkg") + assert rendered == "" + + +def test_live_signature_markdown_skips_re_exports() -> None: + """Public callables whose ``__module__`` differs are filtered out. + + Re-exports (e.g. ``from x import y`` at module top-level) belong + to their owning module; rendering them on every importer would + create duplicate signature blocks. + """ + pairs = package_reference._public_callables("sphinx_fonts") + for name, _sig in pairs: + obj = getattr(__import__("sphinx_fonts"), name) + owner = getattr(obj, "__module__", None) + assert owner == "sphinx_fonts", ( + f"{name!r} is a re-export from {owner!r} and should be filtered" + ) + + +def test_public_callables_returns_signatures_for_dataclass_helpers() -> None: + """The helper handles regular functions (their ``inspect.signature`` works).""" + pairs = package_reference._public_callables("sphinx_fonts") + assert len(pairs) >= 1 + for name, sig in pairs: + assert isinstance(name, str) + assert sig.startswith("(") + assert sig.endswith(")") or "->" in sig + + +def test_public_callables_returns_empty_for_unimportable_module() -> None: + """Unimportable module does not crash the build.""" + pairs = package_reference._public_callables("sphinx_definitely_no_such_module") + assert pairs == [] diff --git a/tests/docs/test_no_filler.py b/tests/docs/test_no_filler.py new file mode 100644 index 00000000..64508f0e --- /dev/null +++ b/tests/docs/test_no_filler.py @@ -0,0 +1,94 @@ +"""Banned-strings denylist guard for shipped per-package subpages. + +Ensures no per-package documentation file in ``packages//docs/`` +or ``docs/packages//`` contains placeholder filler such as +``TBD``, ``Coming soon``, ``Lorem ipsum``, or ``FIXME``. The migration +script in ``scripts/docs_split.py`` (and any human author) MUST +produce subpages that ship — never empty stubs. + +This test runs across the entire workspace docs tree so any regression +trips CI before reaching readers. +""" + +from __future__ import annotations + +import pathlib +import re + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +_BANNED_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"\b(?:TBD|XXX|FIXME|placeholder)\b", re.IGNORECASE), + re.compile(r"\bComing soon\b", re.IGNORECASE), + re.compile(r"\bintentionally blank\b", re.IGNORECASE), + re.compile(r"\bLorem ipsum\b", re.IGNORECASE), + # Parens are non-word characters so a leading/trailing \b would + # never match here; the literal parens are sufficient delimiters. + re.compile(r"\(write me\)", re.IGNORECASE), +) + + +def _find_banned(text: str) -> re.Match[str] | None: + """Return the first banned-pattern match in ``text``, or ``None``.""" + for pattern in _BANNED_PATTERNS: + match = pattern.search(text) + if match is not None: + return match + return None + + +def _shipped_subpage_files() -> list[pathlib.Path]: + """Return every docs markdown file that ships to readers. + + Walks both the co-located package source tree + (``packages/*/docs/*.md``) and the legacy in-docs tree + (``docs/packages//*.md`` and the migration-window flat + pages ``docs/packages/*.md``). Excludes ``packages/*/README.md`` + because READMEs target PyPI, not the docs site. + """ + files: list[pathlib.Path] = [] + files.extend((REPO_ROOT / "packages").glob("*/docs/*.md")) + files.extend((REPO_ROOT / "docs" / "packages").rglob("*.md")) + return files + + +def test_shipped_subpages_contain_no_banned_filler() -> None: + """No shipped subpage carries a placeholder string from the denylist.""" + offenders: list[str] = [] + for md_path in _shipped_subpage_files(): + text = md_path.read_text(encoding="utf-8") + match = _find_banned(text) + if match is not None: + offenders.append( + f"{md_path.relative_to(REPO_ROOT)}: {match.group()!r} " + f"at offset {match.start()}", + ) + assert not offenders, "banned filler in shipped subpages:\n" + "\n".join(offenders) + + +@pytest.mark.parametrize( + ("token", "should_match"), + [ + ("TBD", True), + ("Coming soon", True), + ("intentionally blank", True), + ("Lorem ipsum dolor", True), + ("(write me)", True), + ("XXX", True), + ("FIXME", True), + ("placeholder", True), + ("Tutorial: document your first FastMCP tool", False), + ("Reference", False), + ("see :doc:`how-to`", False), + ], + ids=lambda v: str(v), +) +def test_banned_patterns_match_only_filler_words( + token: str, + should_match: bool, +) -> None: + """Banned-pattern set matches each filler token but skips real prose.""" + assert (_find_banned(token) is not None) is should_match diff --git a/tests/docs/test_no_legacy_pages.py b/tests/docs/test_no_legacy_pages.py new file mode 100644 index 00000000..7bbb1362 --- /dev/null +++ b/tests/docs/test_no_legacy_pages.py @@ -0,0 +1,41 @@ +"""Stale-legacy-page CI gate (G1 of the per-package docs migration). + +Asserts no flat ``docs/packages/.md`` co-exists with a +per-package ``docs/packages//index.md``. During the migration +window (Group E) such co-existence would silently shadow either form +depending on Sphinx's docname resolution; tripping this gate makes +that drift loud. + +Once Group E completes, every flat per-package page should be gone +(replaced by the per-package directory). The check therefore also +fails if any flat per-package ``.md`` lingers when the +``/`` directory exists. +""" + +from __future__ import annotations + +import pathlib + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PACKAGES_DIR = REPO_ROOT / "docs" / "packages" + +# packages/index.md is the workspace inventory page, not a per-package +# legacy page; exclude it from the check. +_INDEX_NAME = "index" + + +def test_no_per_package_dir_co_exists_with_flat_md() -> None: + """No ``.md`` shares a stem with an existing ``/`` directory.""" + flat_stems = { + path.stem for path in PACKAGES_DIR.glob("*.md") if path.stem != _INDEX_NAME + } + dir_stems = { + path.name + for path in PACKAGES_DIR.iterdir() + if path.is_dir() and (path / "index.md").is_file() + } + overlap = flat_stems & dir_stems + assert not overlap, ( + "Stale legacy flat pages co-exist with per-package directories: " + f"{sorted(overlap)} — delete the flat .md files." + ) diff --git a/tests/docs/test_objects_inv_compat.py b/tests/docs/test_objects_inv_compat.py new file mode 100644 index 00000000..876a51f7 --- /dev/null +++ b/tests/docs/test_objects_inv_compat.py @@ -0,0 +1,140 @@ +r"""Risk-1 mitigation: prove the per-package migration loses no xref targets. + +The pre-migration ``objects.inv`` is captured in +``tests/docs/__snapshots__/objects-inv-baseline.txt`` (one +``\t`` line per entry, sorted). Every subsequent +docs build must produce a SUPERSET — no entry from the baseline +may disappear, even if entries move between docnames. + +The fixture builds the live ``docs/`` tree once per test session +via ``sphinx.application.Sphinx`` against a tmp output dir, then +parses the generated ``objects.inv``. Cost: ~10-30 seconds per +session, paid once. + +Group G4 of the migration plan refreshes this snapshot to the +post-migration superset once every package has migrated. +""" + +from __future__ import annotations + +import io +import pathlib + +import pytest +from sphinx.util.inventory import InventoryFile + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +SNAPSHOT = REPO_ROOT / "tests" / "docs" / "__snapshots__" / "objects-inv-baseline.txt" + + +def _join(base: str, target: str) -> str: + """Inventory join function — return ``target`` since paths are relative.""" + return target + + +def _flatten_inventory(inv_path: pathlib.Path) -> set[str]: + """Return ``{domainname, ...}`` for every entry in ``inv_path``.""" + with inv_path.open("rb") as handle: + inventory = InventoryFile.load(handle, "", _join) + return { + f"{domain}\t{name}" for domain, entries in inventory.items() for name in entries + } + + +# Risk 1 (woven plan §5.3) is specifically about py-domain +# cross-references not being lost during the migration. std:doc / +# std:label entries are EXPECTED to change as docnames reorganize +# (e.g. packages/sphinx-fonts -> packages/sphinx-fonts/index + +# /how-to + /reference) and rediraffe handles those URL-level +# redirects for legacy consumers. We assert the *xref-stable* +# domains form a superset. +_XREF_STABLE_DOMAIN_PREFIXES: tuple[str, ...] = ( + "py:", + "rst:", + "argparse:", +) + + +def _baseline_keys() -> set[str]: + """Load the committed baseline filtered to xref-stable domains.""" + text = SNAPSHOT.read_text(encoding="utf-8") + keys: set[str] = set() + for line in text.splitlines(): + if not line.strip(): + continue + domain = line.split("\t", 1)[0] + if any(domain.startswith(prefix) for prefix in _XREF_STABLE_DOMAIN_PREFIXES): + keys.add(line) + return keys + + +@pytest.fixture(scope="module") +def _live_objects_inv(tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path: + """Build the live ``docs/`` tree once and return the resulting inventory path. + + Uses ``sphinx.application.Sphinx`` directly so the build is + hermetic against the developer's working ``docs/_build/`` cache. + Module-scoped — built once per pytest module run. + """ + from sphinx.application import Sphinx + + src_dir = REPO_ROOT / "docs" + out_dir = tmp_path_factory.mktemp("objects-inv") + doctree_dir = out_dir / ".doctrees" + html_dir = out_dir / "html" + + # status/warning streams swallowed; CI's `just build-docs` is the + # build whose warnings are surfaced under -W. + status = io.StringIO() + warning = io.StringIO() + app = Sphinx( + srcdir=str(src_dir), + confdir=str(src_dir), + outdir=str(html_dir), + doctreedir=str(doctree_dir), + buildername="dirhtml", + status=status, + warning=warning, + freshenv=True, + ) + app.build() + inv_path = html_dir / "objects.inv" + if not inv_path.is_file(): + pytest.fail(f"sphinx build produced no objects.inv at {inv_path}") + return inv_path + + +@pytest.mark.integration +def test_objects_inv_is_superset_of_baseline( + _live_objects_inv: pathlib.Path, +) -> None: + """No baseline cross-reference target is missing from the live build.""" + live_keys = _flatten_inventory(_live_objects_inv) + baseline = _baseline_keys() + missing = baseline - live_keys + assert not missing, ( + f"{len(missing)} cross-reference target(s) lost since baseline:\n" + + "\n".join(sorted(missing)[:20]) + + ("\n..." if len(missing) > 20 else "") + ) + + +def test_baseline_snapshot_is_sorted_and_unique() -> None: + """The baseline file is canonical: sorted, no duplicates, no blanks.""" + text = SNAPSHOT.read_text(encoding="utf-8") + lines = text.splitlines() + non_empty = [line for line in lines if line.strip()] + assert non_empty == sorted(non_empty), "baseline must be sorted" + assert len(non_empty) == len(set(non_empty)), "baseline must be unique" + + +def test_baseline_snapshot_has_expected_shape() -> None: + r"""Every line is ``\t`` (single tab, non-empty halves).""" + text = SNAPSHOT.read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + assert line.count("\t") == 1, f"line {lineno}: expected one tab, got {line!r}" + domain, name = line.split("\t", 1) + assert domain, f"line {lineno}: empty domain" + assert name, f"line {lineno}: empty name" diff --git a/tests/docs/test_package_dependents.py b/tests/docs/test_package_dependents.py new file mode 100644 index 00000000..54e0fd14 --- /dev/null +++ b/tests/docs/test_package_dependents.py @@ -0,0 +1,66 @@ +"""Tests for the ``{package-dependents}`` showcase directive.""" + +from __future__ import annotations + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def test_package_dependents_for_sphinx_ux_badges_includes_known_dependents() -> None: + """sphinx-ux-badges is depended on by autodoc + UX layout packages. + + The exact set varies as the workspace evolves, but the result must + be a subset of all shipped-py packages and must NOT include + sphinx-ux-badges itself. + """ + deps = package_reference._package_dependents("sphinx-ux-badges") + assert "sphinx-ux-badges" not in deps + shipped_names = { + record.name + for record in package_reference.workspace_package_records() + if record.state == "shipped-py" + } + assert set(deps) <= shipped_names + + +def test_package_dependents_for_unknown_package_is_empty() -> None: + """No dependents for a package nobody has heard of.""" + assert package_reference._package_dependents("definitely-no-such-pkg") == [] + + +def test_package_dependents_markdown_renders_intro_paragraph() -> None: + """Body-only output: directive emits intro + bullets only. + + The stub at ``packages//dependents.md`` provides anchor + + H1 so Sphinx finds a page title at parse time. + """ + rendered = package_reference._package_dependents_markdown("sphinx-ux-badges") + # No anchor or H1 emitted by the directive + assert "(sphinx-ux-badges-dependents)=" not in rendered + assert "# Dependents" not in rendered + assert "Workspace packages that declare a `sphinx-ux-badges` dependency" in rendered + + +def test_package_dependents_markdown_emits_doc_xrefs_for_each_dependent() -> None: + """Each dependent renders as an absolute ``{doc}`` cross-reference bullet. + + Uses a leading ``/`` so cross-references resolve from the source + root rather than the current page's directory. + """ + rendered = package_reference._package_dependents_markdown("sphinx-ux-badges") + deps = package_reference._package_dependents("sphinx-ux-badges") + if deps: + for dep in deps: + assert f"{{doc}}`/packages/{dep}/index`" in rendered + else: + assert "No workspace package currently depends" in rendered + + +def test_package_dependents_markdown_returns_empty_for_unknown_package() -> None: + """Unknown package name returns the empty string.""" + rendered = package_reference._package_dependents_markdown("no-such-pkg") + assert rendered == "" diff --git a/tests/docs/test_package_kitchen_sink.py b/tests/docs/test_package_kitchen_sink.py new file mode 100644 index 00000000..1bbae1f5 --- /dev/null +++ b/tests/docs/test_package_kitchen_sink.py @@ -0,0 +1,63 @@ +"""Tests for the ``{package-kitchen-sink}`` showcase directive.""" + +from __future__ import annotations + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def test_kitchen_sink_markdown_renders_intro_for_known_package() -> None: + """Body-only output: directive emits intro + per-directive sections. + + The stub at ``packages//kitchen-sink.md`` provides anchor + + H1 so Sphinx finds a page title. + """ + rendered = package_reference._kitchen_sink_markdown("sphinx-autodoc-argparse") + assert rendered, "expected non-empty markdown for sphinx-autodoc-argparse" + # No anchor or H1 emitted by the directive + assert "(sphinx-autodoc-argparse-kitchen-sink)=" not in rendered + assert "# Kitchen sink" not in rendered + assert "Every directive and role this package registers" in rendered + + +def test_kitchen_sink_markdown_lists_each_directive_with_example_block() -> None: + """Each registered directive gets a ``### `name``` heading + rst fence. + + The fence MUST be tagged ``rst`` (pygments RstLexer) — using + ``text`` ships an unstyled ``
`` block, + which is the original bug that motivated this assertion. + """ + rendered = package_reference._kitchen_sink_markdown("sphinx-autodoc-argparse") + # sphinx-autodoc-argparse registers an ``argparse`` directive + assert "## Directives" in rendered + assert "### `argparse`" in rendered + # Each directive section opens an rst fence (NOT text — text would + # disable highlighting) and shows .. name:: invocation + assert "```rst" in rendered + assert "```text" not in rendered + assert ".. argparse::" in rendered + + +def test_kitchen_sink_markdown_lists_each_role_under_roles_heading() -> None: + """Roles section enumerates every registered role with ``:name:`` syntax.""" + rendered = package_reference._kitchen_sink_markdown("sphinx-autodoc-fastmcp") + if "## Roles" in rendered: + # The heading appears only when the package registers roles + assert "- `:" in rendered # at least one role bullet + + +def test_kitchen_sink_markdown_returns_empty_for_unknown_package() -> None: + """Unknown package name returns the empty string.""" + rendered = package_reference._kitchen_sink_markdown("definitely-no-such-pkg") + assert rendered == "" + + +def test_kitchen_sink_markdown_returns_empty_for_package_with_no_surface() -> None: + """A package whose modules register no directives or roles renders empty.""" + # gp-sphinx is a coordinator with no surface registered via add_directive + rendered = package_reference._kitchen_sink_markdown("gp-sphinx") + assert rendered == "" diff --git a/tests/docs/test_package_landing.py b/tests/docs/test_package_landing.py new file mode 100644 index 00000000..ba392337 --- /dev/null +++ b/tests/docs/test_package_landing.py @@ -0,0 +1,189 @@ +"""Tests for the ``{package-landing}`` directive. + +Exercises the pure markdown helper (``_package_landing_markdown``) and +the candidate-subpage path discovery (``_candidate_subpage_paths``) at +unit level. Integration testing of the directive itself happens via +the live docs build covered by ``tests/docs/test_objects_inv_compat.py``. +""" + +from __future__ import annotations + +import pathlib +import sys +import typing as t + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def _shipped_py_fixture() -> package_reference.PackageDocsRecord: + """Return the live record for ``sphinx-fonts`` (smallest shipped-py package).""" + record = next( + ( + r + for r in package_reference.workspace_package_records() + if r.name == "sphinx-fonts" + ), + None, + ) + assert record is not None, "sphinx-fonts must exist in the workspace" + return record + + +def test_package_landing_markdown_includes_meta_directive() -> None: + """Rendered markdown calls gp-sphinx-package-meta for the badge row. + + Anchor and H1 live in the stub at docs/packages//index.md so + Sphinx determines the page title at parse time; the directive + emits only the body (meta, synopsis, grid, toctree). + """ + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown(record, []) + assert f"```{{gp-sphinx-package-meta}} {record.name}" in rendered + # Anchor + H1 are intentionally NOT emitted by the directive + assert f"({record.name})=" not in rendered + assert f"# {record.name}" not in rendered + + +def test_package_landing_markdown_includes_synopsis_block() -> None: + """When the record has a description, it is rendered as a block-quote.""" + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown(record, []) + assert "> " in rendered # block quote marker + assert record.description in rendered + + +def test_package_landing_markdown_falls_back_when_description_empty() -> None: + """An empty description is replaced with a non-empty placeholder line.""" + record = package_reference.PackageDocsRecord( + name="example-pkg", + state="shipped-py", + cluster="autodoc", + package_dir=pathlib.Path("/tmp/example"), + manifest_path=None, + src_dir=None, + module_name="example_pkg", + description="", + version="", + repository_url="", + pypi_url=None, + npm_url=None, + maturity="Alpha", + ) + rendered = package_reference._package_landing_markdown(record, []) + assert "No description provided" in rendered + + +def test_package_landing_markdown_with_no_subpages_emits_no_toctree() -> None: + """When no subpages exist on disk, the hidden toctree is omitted.""" + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown(record, []) + assert "```{toctree}" not in rendered + + +def test_package_landing_markdown_with_subpages_emits_grid_and_toctree() -> None: + """When subpages are present, both the grid cards and toctree appear.""" + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown( + record, + ["tutorial", "reference"], + ) + assert "::::{grid}" in rendered + assert ":::{grid-item-card} {octicon}`rocket` Tutorial" in rendered + assert ":::{grid-item-card} {octicon}`book` API Reference" in rendered + assert "```{toctree}" in rendered + assert "tutorial" in rendered + assert "reference" in rendered + + +def test_package_landing_markdown_extra_subpage_uses_octicon_when_known() -> None: + """A package opting into ``errors`` gets the alert octicon and title.""" + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown(record, ["errors"]) + assert ":::{grid-item-card} {octicon}`alert` Errors" in rendered + + +def test_package_landing_markdown_unknown_subpage_uses_link_octicon() -> None: + """A subpage with no octicon entry falls back to the generic link icon.""" + record = _shipped_py_fixture() + rendered = package_reference._package_landing_markdown(record, ["custom-page"]) + assert ":::{grid-item-card} {octicon}`link` Custom Page" in rendered + + +class _CandidatePathFixture(t.NamedTuple): + """Fixture row asserting the candidate-subpage path map.""" + + test_id: str + package_name: str + extra: tuple[str, ...] + expected_keys: frozenset[str] + + +_CANDIDATE_PATH_FIXTURES: list[_CandidatePathFixture] = [ + _CandidatePathFixture( + test_id="defaults_only", + package_name="sphinx-fonts", + extra=(), + expected_keys=frozenset( + {"tutorial", "how-to", "reference", "explanation", "examples"}, + ), + ), + _CandidatePathFixture( + test_id="defaults_plus_errors_extra", + package_name="sphinx-fonts", + extra=("errors",), + expected_keys=frozenset( + { + "tutorial", + "how-to", + "reference", + "explanation", + "examples", + "errors", + }, + ), + ), +] + + +@pytest.mark.parametrize( + list(_CandidatePathFixture._fields), + _CANDIDATE_PATH_FIXTURES, + ids=[case.test_id for case in _CANDIDATE_PATH_FIXTURES], +) +def test_candidate_subpage_paths_covers_defaults_and_extras( + test_id: str, + package_name: str, + extra: tuple[str, ...], + expected_keys: frozenset[str], +) -> None: + """Candidate map includes the Diátaxis defaults plus declared extras.""" + base = next( + r + for r in package_reference.workspace_package_records() + if r.name == package_name + ) + record = package_reference.PackageDocsRecord( + name=base.name, + state=base.state, + cluster=base.cluster, + package_dir=base.package_dir, + manifest_path=base.manifest_path, + src_dir=base.src_dir, + module_name=base.module_name, + description=base.description, + version=base.version, + repository_url=base.repository_url, + pypi_url=base.pypi_url, + npm_url=base.npm_url, + maturity=base.maturity, + docs_opts=package_reference.DocsOpts(extra=extra), + ) + paths = package_reference._candidate_subpage_paths(record) + assert frozenset(paths.keys()) == expected_keys + for subpage, path in paths.items(): + assert path.name == f"{subpage}.md" + assert path.parent.name == record.name diff --git a/tests/docs/test_sidebar_density.py b/tests/docs/test_sidebar_density.py new file mode 100644 index 00000000..3659f10a --- /dev/null +++ b/tests/docs/test_sidebar_density.py @@ -0,0 +1,177 @@ +"""Permissive upper-bound guard against sidebar-overflow regressions. + +Counts every toctree leaf entry under ``docs/index.md`` and any +nested ``docs/**/index.md`` files. The bound is intentionally +**permissive** during the per-package docs migration window; once +the migration completes, ``Group G2`` of the migration plan +tightens it to the exact post-migration value (``19 * 6 + +workspace_chrome`` per Risk 2 in the woven plan). + +Failure mode this catches: a refactor that accidentally explodes +the sidebar (e.g. promoting every H2 in a flat page to its own +toctree leaf) before that change reaches a reader. +""" + +from __future__ import annotations + +import pathlib +import re + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +_TOCTREE_FENCE = re.compile(r"^(?P\s*)```+\{toctree\}\s*$") +_TOCTREE_CLOSE = re.compile(r"^(?P\s*)```+\s*$") +_TOCTREE_OPTION = re.compile(r"^\s*:[a-zA-Z][\w-]*:") + + +def _toctree_entries(md_path: pathlib.Path) -> list[str]: + """Return every leaf entry across all ``{toctree}`` blocks in ``md_path``. + + Skips blank lines, MyST option lines (``:caption:``, ``:hidden:``, + ``:titlesonly:``, ``:maxdepth:``, …), and the directive's own + fences. Each remaining non-empty line is one toctree leaf. + + Examples + -------- + >>> from pathlib import Path + >>> import textwrap, tempfile + >>> with tempfile.TemporaryDirectory() as tmp: + ... p = Path(tmp) / "x.md" + ... _ = p.write_text(textwrap.dedent(''' + ... # Title + ... + ... ```{toctree} + ... :caption: Group + ... :hidden: + ... + ... packages/foo/index + ... packages/bar/index + ... ``` + ... ''')) + ... entries = _toctree_entries(p) + >>> entries + ['packages/foo/index', 'packages/bar/index'] + """ + entries: list[str] = [] + inside = False + fence_indent = "" + for line in md_path.read_text(encoding="utf-8").splitlines(): + if not inside: + match = _TOCTREE_FENCE.match(line) + if match is not None: + inside = True + fence_indent = match.group("indent") + continue + close = _TOCTREE_CLOSE.match(line) + if close is not None and close.group("indent") == fence_indent: + inside = False + continue + stripped = line.strip() + if not stripped: + continue + if _TOCTREE_OPTION.match(line): + continue + entries.append(stripped) + return entries + + +def _all_toctree_entries() -> list[str]: + """Return every toctree leaf from ``docs/**/index.md``. + + Reads ``docs/index.md`` plus any ``docs//index.md`` so the + count reflects what the rendered sidebar actually nests. Files + under ``_build/`` are excluded. + + Note: cluster-toctree directive calls in ``docs/index.md`` are + NOT counted here — they emit toctree leaves at build time. Use + :func:`_post_migration_total_leaves` for the rendered total. + """ + docs_dir = REPO_ROOT / "docs" + entries: list[str] = [] + for index_md in sorted(docs_dir.rglob("index.md")): + if "_build" in index_md.parts: + continue + entries.extend(_toctree_entries(index_md)) + return entries + + +def _per_package_subpages() -> list[str]: + """Return every ```` subpage anchored under ``docs/packages//``. + + Each per-package landing has its own ``{toctree}`` listing siblings + (tutorial / how-to / reference / explanation / examples / errors / + cli). Walk the per-package directories to count those leaves. + """ + packages_dir = REPO_ROOT / "docs" / "packages" + leaves: list[str] = [] + for landing in sorted(packages_dir.glob("*/index.md")): + package_name = landing.parent.name + for sub in sorted(landing.parent.glob("*.md")): + if sub.name == "index.md": + continue + leaves.append(f"packages/{package_name}/{sub.stem}") + return leaves + + +def _cluster_toctree_leaves() -> list[str]: + """Return every leaf the cluster-toctree directives emit. + + Mirrors the directive's logic without invoking Sphinx: walk the + workspace, skip Emerging packages, pin to per-package landings. + """ + import sys + + sys.path.insert(0, str(REPO_ROOT / "docs" / "_ext")) + import package_reference + + return [ + f"packages/{record.name}/index" + for record in package_reference.workspace_package_records() + if record.state in {"shipped-py", "shipped-js"} + ] + + +def _post_migration_total_leaves() -> int: + """Sum of structural toctree leaves the rendered sidebar exposes.""" + return ( + len(_all_toctree_entries()) + + len(_cluster_toctree_leaves()) + + len(_per_package_subpages()) + ) + + +# Post-migration ceiling per the woven plan §2.1 / Risk 2. +# Calculation: +# - workspace chrome entries in docs/index.md (whats-new, gallery, +# architecture, quickstart, configuration, packages/index, api, +# project/index, history, contributing, code-style, releasing) = ~12 +# - cluster-toctree leaves: one per Shipped package (16 today) = 16 +# - per-package subpages: ~3-5 per package, max 6 = up to 96 +# - margin for additional clusters / chrome growth = +20 +# +# Total ceiling: 19 packages * 6 max-subpages + 30 chrome = 144. +# Today's value sits comfortably below this. +_POST_MIGRATION_BOUND = 19 * 6 + 30 + + +def test_toctree_entries_within_post_migration_upper_bound() -> None: + """Total toctree leaves stay under the post-migration ceiling.""" + total = _post_migration_total_leaves() + assert total <= _POST_MIGRATION_BOUND, ( + f"sidebar-density regression: {total} toctree leaves " + f"exceeds post-migration bound {_POST_MIGRATION_BOUND}" + ) + + +def test_toctree_entries_include_workspace_packages() -> None: + """Smoke check: at least one ``packages/...`` entry is present today.""" + entries = _all_toctree_entries() + package_entries = [e for e in entries if e.startswith("packages/")] + assert package_entries, "expected at least one packages/* toctree leaf" + + +def test_toctree_entries_have_no_leading_or_trailing_whitespace() -> None: + """Every leaf survives strip() unchanged — guard against indent drift.""" + for entry in _all_toctree_entries(): + assert entry == entry.strip() diff --git a/tests/docs/test_subpage_exists_role.py b/tests/docs/test_subpage_exists_role.py new file mode 100644 index 00000000..49647f2f --- /dev/null +++ b/tests/docs/test_subpage_exists_role.py @@ -0,0 +1,129 @@ +"""Tests for the ``{subpage-exists}`` conditional cross-reference role. + +The role guards "Where to next" links in tutorials and how-tos so the +prose never breaks when a sibling subpage has not been authored yet. +When the target docname resolves, the role emits a Sphinx ``:doc:`` +cross-reference; otherwise it degrades to plain text. +""" + +from __future__ import annotations + +import pathlib +import sys +import typing as t +from types import SimpleNamespace + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +class _FakeEnv: + """Minimal Sphinx env stand-in carrying ``found_docs`` + ``docname``.""" + + def __init__(self, *, found_docs: set[str], docname: str) -> None: + self.found_docs = found_docs + self.docname = docname + + +def _make_inliner(env: _FakeEnv) -> SimpleNamespace: + """Return a docutils-inliner stand-in that exposes ``settings.env``.""" + document = SimpleNamespace(settings=SimpleNamespace(env=env)) + return SimpleNamespace(document=document) + + +class _SubpageExistsCase(t.NamedTuple): + """Fixture row for ``subpage_exists_role`` outcomes.""" + + test_id: str + found_docs: frozenset[str] + current_docname: str + target: str + expected_xref: bool + + +_SUBPAGE_EXISTS_CASES: list[_SubpageExistsCase] = [ + _SubpageExistsCase( + test_id="sibling_present_renders_xref", + found_docs=frozenset({"packages/foo/index", "packages/foo/how-to"}), + current_docname="packages/foo/tutorial", + target="how-to", + expected_xref=True, + ), + _SubpageExistsCase( + test_id="sibling_absent_degrades_to_plain_text", + found_docs=frozenset({"packages/foo/index"}), + current_docname="packages/foo/tutorial", + target="errors", + expected_xref=False, + ), + _SubpageExistsCase( + test_id="absolute_docname_present_renders_xref", + found_docs=frozenset({"packages/foo/index"}), + current_docname="quickstart", + target="packages/foo/index", + expected_xref=True, + ), + _SubpageExistsCase( + test_id="absolute_docname_absent_degrades_to_plain_text", + found_docs=frozenset({"packages/bar/index"}), + current_docname="quickstart", + target="packages/foo/index", + expected_xref=False, + ), +] + + +@pytest.mark.parametrize( + list(_SubpageExistsCase._fields), + _SUBPAGE_EXISTS_CASES, + ids=[case.test_id for case in _SUBPAGE_EXISTS_CASES], +) +def test_subpage_exists_role( + test_id: str, + found_docs: frozenset[str], + current_docname: str, + target: str, + expected_xref: bool, +) -> None: + """Subpage role emits xref when target resolves, plain text otherwise.""" + from sphinx import addnodes + + env = _FakeEnv(found_docs=set(found_docs), docname=current_docname) + inliner = _make_inliner(env) + inline_nodes, messages = package_reference.subpage_exists_role( + "subpage-exists", + f"{{subpage-exists}}`{target}`", + target, + lineno=1, + inliner=inliner, + ) + + assert messages == [] + assert len(inline_nodes) == 1 + if expected_xref: + assert isinstance(inline_nodes[0], addnodes.pending_xref) + assert inline_nodes[0]["reftarget"] == target + else: + assert not isinstance(inline_nodes[0], addnodes.pending_xref) + assert inline_nodes[0].astext() == target + + +def test_subpage_target_exists_helper_handles_top_level_docname() -> None: + """The helper does not crash when current docname has no slash.""" + env = _FakeEnv(found_docs={"index"}, docname="index") + assert package_reference._subpage_target_exists(env, "index") is True + assert package_reference._subpage_target_exists(env, "missing") is False + + +def test_subpage_target_exists_helper_strips_no_path_components() -> None: + """Sibling resolution prefers exact matches before sibling resolution.""" + env = _FakeEnv( + found_docs={"how-to", "packages/foo/how-to"}, + docname="packages/foo/tutorial", + ) + # exact-match beats sibling-resolution; both succeed but "how-to" alone + # is enough to satisfy the role. + assert package_reference._subpage_target_exists(env, "how-to") is True diff --git a/tests/docs/test_surface_changelog.py b/tests/docs/test_surface_changelog.py new file mode 100644 index 00000000..cd081186 --- /dev/null +++ b/tests/docs/test_surface_changelog.py @@ -0,0 +1,92 @@ +"""Tests for the ``{surface-changelog}`` showcase directive.""" + +from __future__ import annotations + +import json +import pathlib +import sys + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def test_current_surface_keys_includes_directive_role_and_config_kinds() -> None: + """The flat key set distinguishes ``directive:`` / ``role:`` / ``config:``.""" + record = next( + r + for r in package_reference.workspace_package_records() + if r.name == "sphinx-autodoc-argparse" + ) + keys = package_reference._current_surface_keys(record) + assert any(k.startswith("directive:") for k in keys) + # argparse registers at least one directive + assert "directive:argparse" in keys + + +def test_surface_changelog_markdown_warns_when_no_snapshot_exists( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A package with no snapshot file gets the no-prior-snapshot notice. + + Body-only output: the directive emits the comparison body; the + stub at ``packages//surface-diff.md`` provides anchor + H1. + """ + monkeypatch.setattr( + package_reference, + "_surface_snapshot_path", + lambda name: tmp_path / f"{name}-no-such-file.json", + ) + rendered = package_reference._surface_changelog_markdown("sphinx-fonts") + # No anchor or H1 emitted by the directive + assert "(sphinx-fonts-surface-diff)=" not in rendered + assert "# Surface diff" not in rendered + assert "No prior snapshot recorded" in rendered + + +def test_surface_changelog_markdown_renders_added_section( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When the snapshot is empty, every current surface key shows under Added.""" + snapshot = tmp_path / "snapshot.json" + snapshot.write_text(json.dumps([]), encoding="utf-8") + monkeypatch.setattr( + package_reference, + "_surface_snapshot_path", + lambda _name: snapshot, + ) + rendered = package_reference._surface_changelog_markdown("sphinx-autodoc-argparse") + assert "## Added" in rendered + assert "directive:argparse" in rendered + assert "## Removed" not in rendered # no removals against empty snapshot + + +def test_surface_changelog_markdown_renders_removed_when_snapshot_has_extras( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Snapshot keys not in the current surface show under Removed.""" + snapshot = tmp_path / "snapshot.json" + snapshot.write_text( + json.dumps(["directive:argparse", "directive:never-existed"]), + encoding="utf-8", + ) + monkeypatch.setattr( + package_reference, + "_surface_snapshot_path", + lambda _name: snapshot, + ) + rendered = package_reference._surface_changelog_markdown("sphinx-autodoc-argparse") + assert "## Removed" in rendered + assert "directive:never-existed" in rendered + assert "## Unchanged" in rendered # argparse stayed + + +def test_surface_changelog_markdown_returns_empty_for_unknown_package() -> None: + """Unknown package returns the empty string.""" + rendered = package_reference._surface_changelog_markdown("definitely-no-such-pkg") + assert rendered == "" diff --git a/tests/docs/test_workspace_package_grid_groups.py b/tests/docs/test_workspace_package_grid_groups.py new file mode 100644 index 00000000..9feeaf75 --- /dev/null +++ b/tests/docs/test_workspace_package_grid_groups.py @@ -0,0 +1,109 @@ +"""Tests for the ``{workspace-package-grid} :groups: by-cluster`` extension. + +The legacy default (no ``:groups:`` option) must remain identical to +the pre-extension output so existing ``docs/packages/index.md`` +invocations keep rendering unchanged. The new ``by-cluster`` mode +emits one grid per cluster with framing prose, and renders Emerging +packages as GitHub-linked cards (no docname link). +""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "_ext")) + +import package_reference + + +def test_default_grid_unchanged_from_legacy_layout() -> None: + """No ``groups=`` argument renders the original single-grid layout.""" + rendered = package_reference.workspace_package_grid_markdown() + assert rendered.startswith("::::{grid}") + # legacy layout has exactly one outer grid + assert rendered.count("::::{grid}") == 1 + # no per-cluster headings appear + assert "## " not in rendered + + +def test_by_cluster_grid_emits_per_cluster_heading_and_prose() -> None: + """``groups=by-cluster`` adds a heading + framing prose for each cluster.""" + rendered = package_reference.workspace_package_grid_markdown( + groups="by-cluster", + ) + expected_headings = [ + "## Theme & coordinator", + "## Tokens", + "## Autodoc extensions", + "## UX components", + "## Build & SEO", + ] + for heading in expected_headings: + assert heading in rendered + + +def test_by_cluster_grid_emits_one_grid_per_nonempty_cluster() -> None: + """``groups=by-cluster`` opens a fresh ``{grid}`` block per cluster.""" + rendered = package_reference.workspace_package_grid_markdown( + groups="by-cluster", + ) + # 5 clusters all populated (tokens, autodoc, ux, theme-coordinator, + # build-seo) -> 5 grids today. + assert rendered.count("::::{grid}") == 5 + + +def test_by_cluster_grid_includes_shipped_js_packages() -> None: + """Token cluster includes ``@gp-sphinx/furo-tokens`` (shipped-js).""" + rendered = package_reference.workspace_package_grid_markdown( + groups="by-cluster", + ) + assert ":::{grid-item-card} @gp-sphinx/furo-tokens" in rendered + + +def test_by_cluster_grid_renders_emerging_as_github_link() -> None: + """An Emerging record renders without a docname link, pointing at GitHub.""" + record = package_reference.PackageDocsRecord( + name="example-emerging-pkg", + state="emerging", + cluster="tokens", + package_dir=pathlib.Path("/tmp/example"), + manifest_path=None, + src_dir=None, + module_name="", + description="", + version="", + repository_url="https://github.com/example/repo", + pypi_url=None, + npm_url=None, + maturity="Unknown", + ) + lines = package_reference._grid_card_lines_for_record(record) + text = "\n".join(lines) + assert ":::{grid-item-card} example-emerging-pkg" in text + assert ":link: https://github.com/example/repo" in text + assert ":link-type: doc" not in text + assert "Coming soon" in text + + +def test_by_cluster_grid_renders_shipped_py_with_doc_link() -> None: + """A shipped-py record uses ``:link-type: doc`` for the package's landing.""" + record = next( + r + for r in package_reference.workspace_package_records() + if r.name == "sphinx-fonts" + ) + lines = package_reference._grid_card_lines_for_record(record) + text = "\n".join(lines) + assert ":::{grid-item-card} sphinx-fonts" in text + assert ":link: sphinx-fonts" in text + assert ":link-type: doc" in text + assert "+++" in text # maturity badge separator + + +def test_workspace_package_grid_markdown_rejects_unknown_groups_argument() -> None: + """Passing an unsupported ``groups=`` value raises ValueError.""" + with pytest.raises(ValueError, match="unsupported groups argument"): + package_reference.workspace_package_grid_markdown(groups="alphabetical") diff --git a/tests/ext/test_myst_lexer.py b/tests/ext/test_myst_lexer.py index c932b261..08f41986 100644 --- a/tests/ext/test_myst_lexer.py +++ b/tests/ext/test_myst_lexer.py @@ -229,3 +229,101 @@ def test_tokenize_myst_helper() -> None: def test_tokenize_myst_returns_backtick_for_eval_rst() -> None: tokens = tokenize_myst("```{eval-rst}\nHello RST\n```\n") assert (_BACKTICK, "```{eval-rst}") in tokens + + +# --------------------------------------------------------------------------- +# Colon-fence (MyST colon_fence extension): :::{} ... ::: +# --------------------------------------------------------------------------- + + +_NAME_TAG = "Token.Name.Tag" + + +class ColonFenceFixture(t.NamedTuple): + """Fixture for :::{...} colon-fence tokenization assertions.""" + + test_id: str + input_text: str + expected_contains: list[tuple[str, str]] + + +COLON_FENCE_FIXTURES: list[ColonFenceFixture] = [ + ColonFenceFixture( + test_id="opening_is_backtick", + input_text=":::{note}\nhi\n:::\n", + expected_contains=[(_BACKTICK, ":::{note}")], + ), + ColonFenceFixture( + test_id="opening_with_info_string", + input_text=":::{auto-pytest-plugin} my_project.pp\n:::\n", + expected_contains=[ + (_BACKTICK, ":::{auto-pytest-plugin} my_project.pp"), + ], + ), + ColonFenceFixture( + test_id="closing_is_backtick", + input_text=":::{note}\nhi\n:::\n", + expected_contains=[(_BACKTICK, ":::\n")], + ), + ColonFenceFixture( + test_id="option_key_is_name_tag", + input_text=":::{auto-pytest-plugin} my_project.pp\n:package: my-project\n:::\n", + expected_contains=[(_NAME_TAG, ":package:")], + ), + ColonFenceFixture( + test_id="hyphenated_directive_name", + input_text=":::{tab-set}\nbody\n:::\n", + expected_contains=[(_BACKTICK, ":::{tab-set}")], + ), +] + + +@pytest.mark.parametrize( + list(ColonFenceFixture._fields), + COLON_FENCE_FIXTURES, + ids=[f.test_id for f in COLON_FENCE_FIXTURES], +) +def test_colon_fence_markers( + test_id: str, + input_text: str, + expected_contains: list[tuple[str, str]], +) -> None: + """Colon-fence tokenization emits the expected token types.""" + tokens = get_tokens(input_text) + for tok, val in expected_contains: + assert (tok, val) in tokens, ( + f"Expected ({tok!r}, {val!r}) in tokens for test_id={test_id!r}\n" + f"Got: {tokens}" + ) + + +def test_colon_fence_user_failing_snippet_round_trip() -> None: + """The exact snippet from sphinx-autodoc-pytest-fixtures/tutorial.md. + + Regression guard: this snippet was the original report — pygments' + MarkdownLexer (and the workspace's MystLexer extension) had no rule + for ``:::`` colon fences, so the entire block fell through to plain + ``Token.Text`` and the rendered HTML was unstyled. Tokenizing must + now produce both fence boundaries as ``String.Backtick`` and the + option key as ``Name.Tag``. + """ + snippet = ( + ":::{auto-pytest-plugin} my_project.pytest_plugin\n:package: my-project\n:::\n" + ) + tokens = tokenize_myst(snippet) + assert (_BACKTICK, ":::{auto-pytest-plugin} my_project.pytest_plugin") in tokens + assert (_NAME_TAG, ":package:") in tokens + assert (_BACKTICK, ":::\n") in tokens + + +def test_colon_fence_does_not_match_inline_colon_text() -> None: + """Inline text starting with `:::` mid-line stays as Text, not a fence. + + The opening rule anchors `^:::` at the start of a line, so prose + like ``Note: :::-style`` doesn't accidentally trigger fence + tokenization. + """ + tokens = tokenize_myst("Some inline :::not-a-fence text\n") + # No String.Backtick spans should be emitted from this prose + backticks = [val for tok, val in tokens if tok == _BACKTICK] + assert backticks == [] diff --git a/tests/test_docs_package_pages.py b/tests/test_docs_package_pages.py index 42a968a3..13d56352 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -20,12 +20,41 @@ REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] DOCS_ROOT = REPO_ROOT / "docs" -PACKAGE_PAGES = sorted( - [ - *(DOCS_ROOT / "packages").glob("sphinx-autodoc-*.md"), - *(DOCS_ROOT / "packages").glob("sphinx-ux-*.md"), - ] -) + + +def _autodoc_and_ux_package_paths() -> list[pathlib.Path]: + """Return one docs page per autodoc / ux package. + + Accepts both layouts during the per-package migration window: + ``docs/packages/.md`` (legacy flat) and + ``docs/packages//examples.md`` (post-migration). The + examples.md subpage is where live-demo content moves once the + package has migrated. + """ + packages_dir = DOCS_ROOT / "packages" + paths: list[pathlib.Path] = [] + candidates = sorted( + { + p.parent.name if p.parent.name != "packages" else p.stem + for p in [ + *packages_dir.glob("sphinx-autodoc-*.md"), + *packages_dir.glob("sphinx-ux-*.md"), + *packages_dir.glob("sphinx-autodoc-*/index.md"), + *packages_dir.glob("sphinx-ux-*/index.md"), + ] + } + ) + for name in candidates: + flat = packages_dir / f"{name}.md" + examples = packages_dir / name / "examples.md" + if examples.is_file(): + paths.append(examples) + elif flat.is_file(): + paths.append(flat) + return paths + + +PACKAGE_PAGES = _autodoc_and_ux_package_paths() LIVE_DEMO_MARKERS = ( "```{eval-rst}", "```{gp-sphinx-badge-demo}", @@ -34,8 +63,22 @@ "{tool}`", "{toolref}`", ) + + +def _fastmcp_docs_page() -> str: + """Return the FastMCP examples / demos page contents. + + Prefers ``packages/sphinx-autodoc-fastmcp/examples.md`` (post-E7 + migration); falls back to the flat legacy page until the + migration lands. + """ + examples = DOCS_ROOT / "packages" / "sphinx-autodoc-fastmcp" / "examples.md" + flat = DOCS_ROOT / "packages" / "sphinx-autodoc-fastmcp.md" + return examples.read_text() if examples.is_file() else flat.read_text() + + _FASTMCP_DEMO_MODULE = (DOCS_ROOT / "_ext" / "fastmcp_demo_tools.py").read_text() -_FASTMCP_DOCS_PAGE = (DOCS_ROOT / "packages" / "sphinx-autodoc-fastmcp.md").read_text() +_FASTMCP_DOCS_PAGE = _fastmcp_docs_page() _FASTMCP_CONF = textwrap.dedent( f"""\ from __future__ import annotations @@ -76,17 +119,43 @@ def _section_content(text: str, heading: str) -> str: return match.group("body") +def _test_id_for(path: pathlib.Path) -> str: + """Stable test ID across legacy + per-package layouts.""" + if path.name == "examples.md": + return path.parent.name + return path.stem + + @pytest.mark.parametrize( "page_path", PACKAGE_PAGES, - ids=[path.stem for path in PACKAGE_PAGES], + ids=[_test_id_for(path) for path in PACKAGE_PAGES], ) def test_autodoc_package_pages_have_copyable_examples_and_live_demos( page_path: pathlib.Path, ) -> None: - """Each autodoc package page includes examples and rendered demos.""" + """Each autodoc package page exposes live demos. + + Pre-migration: a flat ``packages/.md`` page carried both + ``## Working usage examples`` and ``## Live demos`` H2 sections. + Post-migration: those H2 sections live on per-package subpages + (``packages//{tutorial,examples}.md``); for migrated + packages we assert the ``examples.md`` subpage carries one of + the live-demo markers, and skip the H2 / package-reference + checks (those live on the landing now). + """ text = page_path.read_text() + if page_path.name == "examples.md": + # Migrated package: examples.md must carry at least one live + # demo marker; the package-reference and section structure + # are owned by the landing. + assert any(marker in text for marker in LIVE_DEMO_MARKERS), ( + f"{page_path.relative_to(REPO_ROOT)} missing live demo marker" + ) + return + + # Legacy flat page: assert the original three structural pieces. working_examples = _section_content(text, "Working usage examples") live_demos = _section_content(text, "Live demos") @@ -101,7 +170,13 @@ def test_docs_conf_registers_fastmcp_demo_page_support() -> None: assert '"sphinx_autodoc_fastmcp"' in text assert 'fastmcp_tool_modules=["fastmcp_demo_tools"]' in text - assert '"packages/sphinx-autodoc-fastmcp"' in text + # Post-E7: fastmcp_area_map points at the per-package examples + # subpage where the live demos render. Pre-migration accepts the + # legacy flat-page docname. + assert ( + '"packages/sphinx-autodoc-fastmcp/examples"' in text + or '"packages/sphinx-autodoc-fastmcp"' in text + ) @pytest.fixture(scope="module") diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 7c635973..8a96c8d6 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -38,6 +38,91 @@ def test_workspace_packages_lists_publishable_packages() -> None: } +def test_workspace_package_records_includes_shipped_js_packages() -> None: + """Workspace records include JS-only packages that have a ``package.json``.""" + records = package_reference.workspace_package_records() + js_records = [r for r in records if r.state == "shipped-js"] + js_names = {r.name for r in js_records} + assert "@gp-sphinx/furo-tokens" in js_names + + +def test_workspace_package_records_classify_python_packages_as_shipped_py() -> None: + """Every package returned by ``workspace_packages`` has a shipped-py record.""" + py_names = {p["name"] for p in package_reference.workspace_packages()} + records = package_reference.workspace_package_records() + shipped_py_names = {r.name for r in records if r.state == "shipped-py"} + assert py_names <= shipped_py_names + + +def test_workspace_package_records_emerging_packages_have_no_manifest() -> None: + """Emerging records have ``manifest_path is None``.""" + records = package_reference.workspace_package_records() + for record in records: + if record.state == "emerging": + assert record.manifest_path is None + + +def test_workspace_package_records_assign_each_package_a_known_cluster() -> None: + """Every shipped record has a non-``unknown`` cluster.""" + records = package_reference.workspace_package_records() + for record in records: + if record.state in {"shipped-py", "shipped-js"}: + assert record.cluster != "unknown", ( + f"package {record.name!r} fell through cluster classification" + ) + + +def test_workspace_package_records_pypi_url_only_for_python_packages() -> None: + """PyPI URLs are populated for shipped-py records and absent elsewhere.""" + records = package_reference.workspace_package_records() + for record in records: + if record.state == "shipped-py": + assert record.pypi_url == f"https://pypi.org/project/{record.name}/" + else: + assert record.pypi_url is None + + +def test_workspace_package_records_npm_url_only_for_js_packages() -> None: + """Npm URLs are populated for shipped-js records and absent elsewhere. + + The npm web UI serves scoped packages at ``/package/@scope/name`` + with the literal ``@`` and ``/`` — pinning the URL form here so a + future regression that percent-encodes the slash gets caught + (the resulting ``%2f`` URL 404s on npmjs.com). + """ + records = package_reference.workspace_package_records() + for record in records: + if record.state == "shipped-js": + assert record.npm_url is not None + assert record.npm_url == f"https://www.npmjs.com/package/{record.name}", ( + f"npm_url for {record.name} should preserve the manifest name verbatim" + ) + else: + assert record.npm_url is None + + +def test_workspace_package_records_parse_docs_opts_from_pyproject() -> None: + """``[tool.gp-sphinx.docs]`` overrides round-trip into ``DocsOpts``.""" + helper = package_reference._docs_opts_from_pyproject + opts = helper( + { + "tool": { + "gp-sphinx": { + "docs": { + "extra": ["errors", "cli"], + "showcase": ["kitchen-sink"], + "reference_link": "/configuration", + }, + }, + }, + }, + ) + assert opts.extra == ("errors", "cli") + assert opts.showcase == ("kitchen-sink",) + assert opts.reference_link == "/configuration" + assert opts.omit == () + + def test_collect_extension_surface_for_sphinx_fonts() -> None: """The surface collector captures live config registration.""" surface = package_reference.collect_extension_surface("sphinx_fonts") @@ -80,17 +165,24 @@ def test_package_reference_markdown_omits_surface_tables() -> None: def test_docs_package_pages_exist_for_every_workspace_package() -> None: - """Each publishable package has a matching docs page.""" - page_names = { - path.stem - for path in (REPO_ROOT / "docs" / "packages").glob("*.md") - if path.stem != "index" + """Each publishable package has either a flat ``.md`` or ``/index.md``. + + Migration accepts either layout: pre-migration packages keep their + legacy ``docs/packages/.md`` pages, post-migration packages + have ``docs/packages//index.md`` rendered by + ``{package-landing}``. Both forms count as a present docs page. + """ + packages_dir = REPO_ROOT / "docs" / "packages" + flat_pages = { + path.stem for path in packages_dir.glob("*.md") if path.stem != "index" } + dir_pages = {path.parent.name for path in packages_dir.glob("*/index.md")} + available_pages = flat_pages | dir_pages package_names = { package["name"] for package in package_reference.workspace_packages() } - assert package_names <= page_names, ( - f"Missing docs pages for packages: {package_names - page_names}" + assert package_names <= available_pages, ( + f"Missing docs pages for packages: {package_names - available_pages}" ) @@ -305,6 +397,58 @@ class _MockEnv: assert entry.docname == expected_docname +def test_register_extension_objects_uses_reference_docname_after_migration() -> None: + """Per-package /reference docname is preferred when found_docs contains it.""" + + class _MockPyDomain: + objects: t.ClassVar[dict[str, t.Any]] = {} + + class _MockEnv: + domains: t.ClassVar[dict[str, object]] = {"py": _MockPyDomain()} + # Simulate post-migration state for one package only + found_docs: t.ClassVar[set[str]] = { + "packages/sphinx-autodoc-docutils/reference", + } + + package_reference._register_extension_objects(None, _MockEnv()) + + entry = _MockPyDomain.objects.get( + "sphinx_autodoc_docutils._directives.AutoDirective" + ) + assert entry is not None + # Migrated package: docname points at the reference subpage + assert entry.docname == "packages/sphinx-autodoc-docutils/reference" + + # Other (un-migrated) packages still resolve to the flat docname + other_entry = _MockPyDomain.objects.get( + "sphinx_autodoc_sphinx._directives.AutoconfigvalueDirective" + ) + assert other_entry is not None + assert other_entry.docname == "packages/sphinx-autodoc-sphinx" + + +def test_register_extension_objects_skips_emerging_packages() -> None: + """Emerging records have no module to introspect — skip silently.""" + + # Build a state that resembles the production code path with no + # _real_ Emerging packages (the workspace currently has none); the + # test verifies the code path's branching, not data presence. + class _MockPyDomain: + objects: t.ClassVar[dict[str, t.Any]] = {} + + class _MockEnv: + domains: t.ClassVar[dict[str, object]] = {"py": _MockPyDomain()} + found_docs: t.ClassVar[set[str]] = set() + + package_reference._register_extension_objects(None, _MockEnv()) + # No assertion on size here — we just verify the function returns + # cleanly without raising on the emerging branch (covered by the + # iteration logic; the workspace has zero Emerging packages today + # so nothing exercises the early-continue, but the code path + # remains sound). + assert isinstance(_MockPyDomain.objects, dict) + + def test_workspace_package_grid_markdown_badge_not_in_card_titles() -> None: """Maturity badges appear in the card footer, not in card title lines.""" output = package_reference.workspace_package_grid_markdown()