From 79d4e9dcf1a7d6b2cc63792394532c3deb12c9ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 07:46:33 -0500 Subject: [PATCH 01/11] sphinx-ux-octicons(feat[new-package]): Curated GitHub Octicons under the gp-sphinx namespace why: Provide a first-party `{octicon}` role that ships only the icons gp-sphinx actually consumes and keeps every emitted class inside the `gp-sphinx-octicon` namespace. what: - Add `sphinx-ux-octicons` workspace package with `OcticonRole`, `render_octicon`, and `load_octicons` exposed from the top-level module - Bundle 18 curated octicons (12 currently in use plus 6 headroom) as `_data/octicons.json` audited from `_data/octicons_curated.txt` - Ship `_static/css/sphinx_ux_octicons.css` under `@layer gp-sphinx` using `currentColor` so icons inherit the surrounding text colour - Add a `scripts/sync_octicons.py` maintainer one-shot that rewrites the bundled JSON from an upstream `@primer/octicons` checkout - Cover the package with render, height-parse, role, and per-icon snapshot tests (snapshots stored under `tests/ext/octicons`) - Wire the package into the workspace: `pyproject.toml`, docs package index, redirect map, cluster classification, CI smoke runner, and the docs sys.path bootstrap --- docs/_ext/package_reference.py | 1 + docs/conf.py | 4 + docs/packages/index.md | 1 + docs/packages/sphinx-ux-octicons/index.md | 6 + docs/redirects.txt | 1 + packages/sphinx-ux-octicons/README.md | 31 ++++ packages/sphinx-ux-octicons/pyproject.toml | 40 +++++ .../scripts/sync_octicons.py | 123 ++++++++++++++ .../src/sphinx_ux_octicons/__init__.py | 79 +++++++++ .../sphinx_ux_octicons/_data/octicons.json | 92 +++++++++++ .../_data/octicons_curated.txt | 18 +++ .../src/sphinx_ux_octicons/_nodes.py | 67 ++++++++ .../src/sphinx_ux_octicons/_render.py | 121 ++++++++++++++ .../src/sphinx_ux_octicons/_role.py | 69 ++++++++ .../_static/css/sphinx_ux_octicons.css | 14 ++ .../src/sphinx_ux_octicons/_visitors.py | 46 ++++++ .../src/sphinx_ux_octicons/py.typed | 0 pyproject.toml | 4 + scripts/ci/package_tools.py | 21 +++ tests/ext/octicons/__init__.py | 3 + .../octicons/__snapshots__/test_snapshot.ambr | 55 +++++++ tests/ext/octicons/test_integration.py | 103 ++++++++++++ tests/ext/octicons/test_render.py | 42 +++++ .../ext/octicons/test_render_height_parse.py | 98 ++++++++++++ tests/ext/octicons/test_role.py | 151 ++++++++++++++++++ tests/ext/octicons/test_snapshot.py | 35 ++++ tests/test_package_reference.py | 1 + uv.lock | 15 ++ 28 files changed, 1241 insertions(+) create mode 100644 docs/packages/sphinx-ux-octicons/index.md create mode 100644 packages/sphinx-ux-octicons/README.md create mode 100644 packages/sphinx-ux-octicons/pyproject.toml create mode 100644 packages/sphinx-ux-octicons/scripts/sync_octicons.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/__init__.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons.json create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons_curated.txt create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_nodes.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_render.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_role.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_static/css/sphinx_ux_octicons.css create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_visitors.py create mode 100644 packages/sphinx-ux-octicons/src/sphinx_ux_octicons/py.typed create mode 100644 tests/ext/octicons/__init__.py create mode 100644 tests/ext/octicons/__snapshots__/test_snapshot.ambr create mode 100644 tests/ext/octicons/test_integration.py create mode 100644 tests/ext/octicons/test_render.py create mode 100644 tests/ext/octicons/test_render_height_parse.py create mode 100644 tests/ext/octicons/test_role.py create mode 100644 tests/ext/octicons/test_snapshot.py diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 16bfe3ca..fde64e1b 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -200,6 +200,7 @@ class PackageDocsRecord: "@gp-sphinx/serene-tokens": "tokens", "sphinx-fonts": "tokens", "sphinx-ux-badges": "ux", + "sphinx-ux-octicons": "ux", "sphinx-ux-autodoc-layout": "ux", "sphinx-vite-builder": "build-seo", "sphinx-gp-opengraph": "build-seo", diff --git a/docs/conf.py b/docs/conf.py index 833a1b3b..f15ac695 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,6 +46,10 @@ 0, str(project_root / "packages" / "sphinx-ux-badges" / "src"), ) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-ux-octicons" / "src"), +) sys.path.insert( 0, str(project_root / "packages" / "sphinx-autodoc-fastmcp" / "src"), diff --git a/docs/packages/index.md b/docs/packages/index.md index 170e8631..3d7b70b9 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -15,6 +15,7 @@ and independently installable. The rendering pipeline every autodoc extension consumes: - [`sphinx-ux-badges`](sphinx-ux-badges/index.md) — badge primitives and colour palette +- [`sphinx-ux-octicons`](sphinx-ux-octicons/index.md) — curated GitHub Octicons as a Sphinx `{octicon}` role - [`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 diff --git a/docs/packages/sphinx-ux-octicons/index.md b/docs/packages/sphinx-ux-octicons/index.md new file mode 100644 index 00000000..a8c2207b --- /dev/null +++ b/docs/packages/sphinx-ux-octicons/index.md @@ -0,0 +1,6 @@ +(sphinx-ux-octicons)= + +# sphinx-ux-octicons + +```{package-landing} sphinx-ux-octicons +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 7f7971e3..1c47c187 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -8,6 +8,7 @@ extensions/sphinx-autodoc-docutils packages/sphinx-autodoc-docutils extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style extensions/sphinx-ux-badges packages/sphinx-ux-badges +extensions/sphinx-ux-octicons packages/sphinx-ux-octicons extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp extensions/sphinx-ux-autodoc-layout packages/sphinx-ux-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts diff --git a/packages/sphinx-ux-octicons/README.md b/packages/sphinx-ux-octicons/README.md new file mode 100644 index 00000000..5b236c77 --- /dev/null +++ b/packages/sphinx-ux-octicons/README.md @@ -0,0 +1,31 @@ +# sphinx-ux-octicons + +Curated GitHub Octicons exposed as a Sphinx `{octicon}` role under the +`gp-sphinx-octicon` CSS namespace. + +## Install + +```console +$ pip install sphinx-ux-octicons +``` + +## Usage + +Add the extension to your `conf.py`: + +```python +extensions = ["sphinx_ux_octicons"] +``` + +Then reference any bundled icon in MyST or reStructuredText: + +```markdown +Launch with {octicon}`rocket` or {octicon}`rocket;1.5em` or +{octicon}`rocket;24px;my-extra-class`. +``` + +## Bundled icons + +`rocket`, `tools`, `book`, `light-bulb`, `star`, `alert`, `terminal`, +`paintbrush`, `code`, `device-camera`, `diff`, `link`, `home`, `gear`, +`package`, `info`, `check-circle`, `x-circle`. diff --git a/packages/sphinx-ux-octicons/pyproject.toml b/packages/sphinx-ux-octicons/pyproject.toml new file mode 100644 index 00000000..62aa90ec --- /dev/null +++ b/packages/sphinx-ux-octicons/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-ux-octicons" +version = "0.0.1a18" +description = "Curated GitHub Octicons as a Sphinx role under the gp-sphinx-* namespace" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "octicons", "icons", "documentation"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_ux_octicons"] diff --git a/packages/sphinx-ux-octicons/scripts/sync_octicons.py b/packages/sphinx-ux-octicons/scripts/sync_octicons.py new file mode 100644 index 00000000..e5727eb2 --- /dev/null +++ b/packages/sphinx-ux-octicons/scripts/sync_octicons.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Refresh the bundled ``octicons.json`` from an upstream @primer/octicons checkout. + +Reads ``_data/octicons_curated.txt`` for the audited icon list, locates the +upstream SVG for each name in either an installed ``@primer/octicons`` npm +package (``node_modules/@primer/octicons/build/svg``) or a local clone at +``~/study/octicons/icons``, parses out the ```` payload of the +16px variant, and writes the result to ``_data/octicons.json`` in this +package. + +This script is a maintainer one-shot — consumers install the bundled +``octicons.json`` directly from the wheel and never invoke it. + +Usage +----- + +:: + + $ python scripts/sync_octicons.py # auto-detect + $ python scripts/sync_octicons.py /path/to/svgs # explicit source +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import sys + +_HERE = pathlib.Path(__file__).resolve().parent +_PKG_ROOT = _HERE.parent +_DATA_DIR = _PKG_ROOT / "src" / "sphinx_ux_octicons" / "_data" +_CURATED = _DATA_DIR / "octicons_curated.txt" +_OUTPUT = _DATA_DIR / "octicons.json" + +_SVG_PATH_RE = re.compile(r"()", re.DOTALL) +_SVG_WIDTH_RE = re.compile(r'width="(\d+)"') +_SVG_HEIGHT_RE = re.compile(r'height="(\d+)"') + + +def _candidate_roots() -> list[pathlib.Path]: + npm_relative = pathlib.Path("node_modules/@primer/octicons/build/svg") + return [ + pathlib.Path.home() / "study" / "octicons" / "icons", + _PKG_ROOT.parents[2] / npm_relative, + pathlib.Path.cwd() / npm_relative, + ] + + +def _resolve_root(explicit: str | None) -> pathlib.Path: + if explicit is not None: + root = pathlib.Path(explicit).expanduser().resolve() + if not root.is_dir(): + msg = f"source path is not a directory: {root}" + raise SystemExit(msg) + return root + for candidate in _candidate_roots(): + if candidate.is_dir(): + return candidate + candidates_text = "\n ".join(str(c) for c in _candidate_roots()) + msg = ( + "could not locate upstream @primer/octicons SVGs. Install them with:\n" + " npm install @primer/octicons\n" + "or clone https://github.com/primer/octicons to ~/study/octicons,\n" + f"or pass the SVG directory explicitly. Tried:\n {candidates_text}" + ) + raise SystemExit(msg) + + +def _read_curated() -> list[str]: + text = _CURATED.read_text(encoding="utf-8") + return [line.strip() for line in text.splitlines() if line.strip()] + + +def _parse_svg(svg_text: str) -> dict[str, int | str]: + width_match = _SVG_WIDTH_RE.search(svg_text) + height_match = _SVG_HEIGHT_RE.search(svg_text) + path_match = _SVG_PATH_RE.search(svg_text) + if width_match is None or height_match is None or path_match is None: + msg = "could not parse width/height/path from SVG" + raise ValueError(msg) + return { + "width": int(width_match.group(1)), + "height": int(height_match.group(1)), + "path": path_match.group(1), + } + + +def _build(root: pathlib.Path, names: list[str]) -> dict[str, dict[str, int | str]]: + out: dict[str, dict[str, int | str]] = {} + missing: list[str] = [] + for name in names: + svg_file = root / f"{name}-16.svg" + if not svg_file.is_file(): + missing.append(name) + continue + out[name] = _parse_svg(svg_file.read_text(encoding="utf-8")) + if missing: + msg = f"missing 16px SVGs under {root}: {', '.join(missing)}" + raise SystemExit(msg) + return dict(sorted(out.items())) + + +def main(argv: list[str] | None = None) -> int: + """Entry point for the sync script.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "source", + nargs="?", + help="Path to the @primer/octicons build/svg directory.", + ) + args = parser.parse_args(argv) + root = _resolve_root(args.source) + names = _read_curated() + payload = _build(root, names) + _OUTPUT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + sys.stdout.write(f"wrote {len(payload)} icons to {_OUTPUT}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/__init__.py b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/__init__.py new file mode 100644 index 00000000..d41078fb --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/__init__.py @@ -0,0 +1,79 @@ +"""Curated GitHub Octicons as a Sphinx ``{octicon}`` role. + +Ships a small audited set of icons under the ``gp-sphinx-octicon`` CSS +namespace and registers the ``octicon`` role so MyST and reStructuredText +sources can call it as a drop-in replacement for sphinx-design's +implementation. + +Examples +-------- +>>> from sphinx_ux_octicons import render_octicon, setup +>>> svg = render_octicon("rocket") +>>> "gp-sphinx-octicon--rocket" in svg +True +>>> callable(setup) +True +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +from sphinx.application import Sphinx + +from sphinx_ux_octicons._nodes import OcticonNode +from sphinx_ux_octicons._render import load_octicons, render_octicon +from sphinx_ux_octicons._role import OcticonRole +from sphinx_ux_octicons._visitors import visit_octicon_html + +__all__ = [ + "OcticonNode", + "OcticonRole", + "load_octicons", + "render_octicon", + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "0.0.1a18" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the ``octicon`` role, the :class:`OcticonNode`, and shared CSS. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> from sphinx_ux_octicons import setup + >>> callable(setup) + True + """ + app.add_node(OcticonNode, html=(visit_octicon_html, None)) + app.add_role("octicon", OcticonRole()) + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_ux_octicons.css") + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons.json b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons.json new file mode 100644 index 00000000..c24b4cce --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons.json @@ -0,0 +1,92 @@ +{ + "alert": { + "height": 16, + "path": "", + "width": 16 + }, + "book": { + "height": 16, + "path": "", + "width": 16 + }, + "check-circle": { + "height": 16, + "path": "", + "width": 16 + }, + "code": { + "height": 16, + "path": "", + "width": 16 + }, + "device-camera": { + "height": 16, + "path": "", + "width": 16 + }, + "diff": { + "height": 16, + "path": "", + "width": 16 + }, + "gear": { + "height": 16, + "path": "", + "width": 16 + }, + "home": { + "height": 16, + "path": "", + "width": 16 + }, + "info": { + "height": 16, + "path": "", + "width": 16 + }, + "light-bulb": { + "height": 16, + "path": "", + "width": 16 + }, + "link": { + "height": 16, + "path": "", + "width": 16 + }, + "package": { + "height": 16, + "path": "", + "width": 16 + }, + "paintbrush": { + "height": 16, + "path": "", + "width": 16 + }, + "rocket": { + "height": 16, + "path": "", + "width": 16 + }, + "star": { + "height": 16, + "path": "", + "width": 16 + }, + "terminal": { + "height": 16, + "path": "", + "width": 16 + }, + "tools": { + "height": 16, + "path": "", + "width": 16 + }, + "x-circle": { + "height": 16, + "path": "", + "width": 16 + } +} diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons_curated.txt b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons_curated.txt new file mode 100644 index 00000000..539cd66f --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_data/octicons_curated.txt @@ -0,0 +1,18 @@ +rocket +tools +book +light-bulb +star +alert +terminal +paintbrush +code +device-camera +diff +link +home +gear +package +info +check-circle +x-circle diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_nodes.py b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_nodes.py new file mode 100644 index 00000000..2a3db4c0 --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_nodes.py @@ -0,0 +1,67 @@ +"""``OcticonNode`` -- docutils node for inline GitHub octicons. + +Subclasses :class:`docutils.nodes.inline` so unregistered builders (text, +LaTeX, man) fall back to ``visit_inline`` via Sphinx's MRO-based +dispatch and render the icon name surrogate carried as a +:class:`docutils.nodes.Text` child. + +The HTML visitor emits the pre-rendered SVG payload stored on +``node["svg_markup"]`` directly and skips descent, so the text child +never leaks into HTML output. + +Examples +-------- +>>> node = OcticonNode("rocket", svg_markup="") +>>> node.astext() +'rocket' + +>>> node["svg_markup"] +'' +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + + +class OcticonNode(nodes.inline): + """Inline node carrying a pre-rendered octicon SVG and a name fallback. + + The HTML visitor reads ``node["svg_markup"]`` and writes the SVG into + the document body before raising :class:`docutils.nodes.SkipNode`. + Other builders rely on docutils' MRO dispatch to ``visit_inline`` and + render the :class:`docutils.nodes.Text` child holding the icon name. + + Parameters + ---------- + name : str + Icon name; rendered as visible fallback text for non-HTML + builders. + svg_markup : str + Pre-rendered inline SVG string produced by + :func:`sphinx_ux_octicons._render.render_octicon`. + **attributes : Any + Additional docutils node attributes. + + Examples + -------- + >>> node = OcticonNode("alert", svg_markup="...") + >>> node.astext() + 'alert' + + >>> "" in node["svg_markup"] + True + """ + + def __init__( + self, + name: str = "", + *, + svg_markup: str = "", + **attributes: t.Any, + ) -> None: + children = [nodes.Text(name)] if name else [] + super().__init__("", *children, **attributes) + self["svg_markup"] = svg_markup diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_render.py b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_render.py new file mode 100644 index 00000000..7e82a0c2 --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_render.py @@ -0,0 +1,121 @@ +"""Octicon rendering primitives. + +Loads bundled octicon data and renders inline SVG fragments under the +``gp-sphinx-octicon`` CSS namespace. ``render_octicon`` is independent of +Sphinx so callers may use it directly from other extensions or tests. + +Examples +-------- +>>> svg = render_octicon("rocket") +>>> svg.startswith(">> 'class="gp-sphinx-octicon gp-sphinx-octicon--rocket"' in svg +True +""" + +from __future__ import annotations + +import functools +import importlib.resources +import json +import re +import typing as t + +if t.TYPE_CHECKING: + from collections.abc import Sequence + +_HEIGHT_RE = re.compile(r"^(?P\d+(?:\.\d+)?)(?Ppx|em|rem)$") + + +@functools.lru_cache(maxsize=1) +def load_octicons() -> dict[str, dict[str, t.Any]]: + """Return the bundled octicon registry. + + Returns + ------- + dict[str, dict[str, Any]] + Mapping of icon name to ``{"width": int, "height": int, "path": str}``. + + Examples + -------- + >>> data = load_octicons() + >>> "rocket" in data + True + >>> sorted(data["rocket"].keys()) + ['height', 'path', 'width'] + """ + data_pkg = importlib.resources.files(__package__).joinpath("_data") + raw = data_pkg.joinpath("octicons.json").read_text(encoding="utf-8") + parsed: dict[str, dict[str, t.Any]] = json.loads(raw) + return parsed + + +def render_octicon( + name: str, + *, + height: str = "1em", + classes: Sequence[str] = (), +) -> str: + """Render an octicon as an inline SVG string. + + Parameters + ---------- + name : str + Icon name, e.g. ``"rocket"``. + height : str, optional + CSS length with a ``px``, ``em``, or ``rem`` unit (default ``"1em"``). + The matching width is derived from the icon's aspect ratio. + classes : Sequence[str], optional + Additional CSS classes appended after the base + ``gp-sphinx-octicon gp-sphinx-octicon--`` pair. + + Returns + ------- + str + Inline SVG markup ready for embedding in HTML output. + + Raises + ------ + KeyError + If ``name`` is not a bundled icon. + ValueError + If ``height`` does not match ````. + + Examples + -------- + >>> svg = render_octicon("rocket", height="24px") + >>> 'width="24.0px"' in svg + True + >>> 'height="24.0px"' in svg + True + """ + registry = load_octicons() + if name not in registry: + raise KeyError(name) + entry = registry[name] + + match = _HEIGHT_RE.match(height) + if match is None: + msg = f"invalid height {height!r}; expected " + raise ValueError(msg) + value = round(float(match.group("value")), 3) + unit = match.group("unit") + + original_width = int(entry["width"]) + original_height = int(entry["height"]) + width_value = round(original_width * value / original_height, 3) + path = str(entry["path"]) + + class_value = " ".join( + ("gp-sphinx-octicon", f"gp-sphinx-octicon--{name}", *classes), + ).strip() + attrs = { + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": f"0 0 {original_width} {original_height}", + "width": f"{width_value}{unit}", + "height": f"{value}{unit}", + "class": class_value, + "aria-hidden": "true", + } + attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items()) + return f"{path}" diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_role.py b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_role.py new file mode 100644 index 00000000..bd6c731b --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_role.py @@ -0,0 +1,69 @@ +"""Sphinx role implementation for ``{octicon}``.""" + +from __future__ import annotations + +from docutils import nodes +from sphinx.util.docutils import SphinxRole + +from sphinx_ux_octicons._nodes import OcticonNode +from sphinx_ux_octicons._render import render_octicon + + +class OcticonRole(SphinxRole): + """Inline role that emits a GitHub Octicon SVG. + + The role text accepts up to three ``;``-separated arguments: + ``name``, ``name;height``, or ``name;height;classes``. ``height`` is a + CSS length (``1em``, ``24px``, ``1.5rem``); ``classes`` is a + whitespace-separated list of extra CSS classes. + + Emits an :class:`OcticonNode` whose HTML visitor writes the inline + SVG and skips descent, while non-HTML builders (text, man, LaTeX) + fall back via MRO to ``visit_inline`` and render the icon name as + visible text. + + Examples + -------- + >>> from sphinx_ux_octicons._role import OcticonRole + >>> callable(OcticonRole) + True + >>> issubclass(OcticonRole, SphinxRole) + True + """ + + def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Parse the role text and emit the icon node. + + Returns + ------- + tuple[list[nodes.Node], list[nodes.system_message]] + ``([node], [])`` on success, ``([problematic], [message])`` on + parse failure. + + Examples + -------- + >>> from sphinx_ux_octicons._role import OcticonRole + >>> OcticonRole.run.__qualname__ + 'OcticonRole.run' + """ + values = self.text.split(";") + name = values[0].strip() + height = values[1].strip() if len(values) >= 2 and values[1].strip() else "1em" + classes = tuple(values[2].split()) if len(values) >= 3 else () + try: + svg = render_octicon(name, height=height, classes=classes) + except (KeyError, ValueError) as exc: + message = self.inliner.reporter.error( + f"invalid octicon {self.text!r}: {exc}", + line=self.lineno, + ) + problematic = self.inliner.problematic( + self.rawtext, + self.rawtext, + message, + ) + return [problematic], [message] + + node = OcticonNode(name, svg_markup=svg) + self.set_source_info(node) + return [node], [] diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_static/css/sphinx_ux_octicons.css b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_static/css/sphinx_ux_octicons.css new file mode 100644 index 00000000..356ba025 --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_static/css/sphinx_ux_octicons.css @@ -0,0 +1,14 @@ +/* sphinx_ux_octicons — inline SVG icon defaults. + * + * Class selectors live under the ``gp-sphinx-octicon`` namespace and stay + * inside ``@layer gp-sphinx`` so precedence is declarative against Furo's + * own layered styles. ``currentColor`` lets the icon inherit colour from + * the surrounding text — callers don't need to thread CSS variables. + */ +@layer gp-sphinx { + .gp-sphinx-octicon { + display: inline-block; + vertical-align: text-top; + fill: currentColor; + } +} diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_visitors.py b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_visitors.py new file mode 100644 index 00000000..2bf2af2f --- /dev/null +++ b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/_visitors.py @@ -0,0 +1,46 @@ +"""HTML5 visitor for :class:`OcticonNode`. + +The visitor writes the pre-rendered SVG payload directly into the HTML +output and raises :class:`docutils.nodes.SkipNode` so docutils does not +descend into the :class:`docutils.nodes.Text` child carrying the icon +name fallback for non-HTML builders. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +if t.TYPE_CHECKING: + from sphinx.writers.html5 import HTML5Translator + + from sphinx_ux_octicons._nodes import OcticonNode + + +def visit_octicon_html(self: HTML5Translator, node: OcticonNode) -> None: + """Emit the pre-rendered SVG markup and skip child rendering. + + Parameters + ---------- + self : HTML5Translator + Active HTML writer instance. + node : OcticonNode + Octicon node carrying ``svg_markup``. + + Raises + ------ + docutils.nodes.SkipNode + Always, after writing the SVG payload, to prevent docutils from + rendering the icon-name :class:`docutils.nodes.Text` fallback + child into HTML output. + + Examples + -------- + >>> from sphinx_ux_octicons._nodes import OcticonNode + >>> node = OcticonNode("rocket", svg_markup="") + >>> node["svg_markup"] + '' + """ + self.body.append(node["svg_markup"]) + raise nodes.SkipNode diff --git a/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/py.typed b/packages/sphinx-ux-octicons/src/sphinx_ux_octicons/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index e8bea896..3eb89dab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ sphinx-autodoc-api-style = { workspace = true } sphinx-autodoc-fastmcp = { workspace = true } sphinx-autodoc-typehints-gp = { workspace = true } sphinx-ux-badges = { workspace = true } +sphinx-ux-octicons = { workspace = true } sphinx-ux-autodoc-layout = { workspace = true } sphinx-gp-opengraph = { workspace = true } sphinx-gp-sitemap = { workspace = true } @@ -44,6 +45,7 @@ dev = [ "sphinx-autodoc-api-style", "sphinx-autodoc-fastmcp", "sphinx-ux-badges", + "sphinx-ux-octicons", "sphinx-ux-autodoc-layout", "sphinx-gp-opengraph", "sphinx-gp-sitemap", @@ -167,6 +169,7 @@ known-first-party = [ "sphinx_autodoc_fastmcp", "sphinx_autodoc_typehints_gp", "sphinx_ux_badges", + "sphinx_ux_octicons", "sphinx_ux_autodoc_layout", "sphinx_gp_opengraph", "sphinx_gp_sitemap", @@ -217,6 +220,7 @@ testpaths = [ "packages/sphinx-autodoc-typehints-gp/src", "packages/sphinx-ux-autodoc-layout/src", "packages/sphinx-ux-badges/src", + "packages/sphinx-ux-octicons/src", "packages/sphinx-gp-opengraph/src", "packages/sphinx-gp-sitemap/src", "packages/sphinx-vite-builder/src", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index d591f9ae..d9b49d82 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -661,6 +661,26 @@ def smoke_sphinx_ux_badges(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_ux_octicons(dist_dir: pathlib.Path, version: str) -> None: + """Verify the ux-octicons extension installs, imports, and renders cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_ux_octicons; " + "from sphinx_ux_octicons import render_octicon, setup; " + "assert callable(setup); " + "svg = render_octicon('rocket'); " + "assert 'gp-sphinx-octicon--rocket' in svg" + ), + ) + + def smoke_sphinx_ux_autodoc_layout(dist_dir: pathlib.Path, version: str) -> None: """Verify the ux-autodoc-layout extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -848,6 +868,7 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "sphinx-autodoc-argparse": smoke_sphinx_autodoc_argparse, "sphinx-autodoc-api-style": smoke_sphinx_autodoc_api_style, "sphinx-ux-badges": smoke_sphinx_ux_badges, + "sphinx-ux-octicons": smoke_sphinx_ux_octicons, "sphinx-autodoc-docutils": smoke_sphinx_autodoc_docutils, "sphinx-autodoc-fastmcp": smoke_sphinx_autodoc_fastmcp, "sphinx-ux-autodoc-layout": smoke_sphinx_ux_autodoc_layout, diff --git a/tests/ext/octicons/__init__.py b/tests/ext/octicons/__init__.py new file mode 100644 index 00000000..24eb58b8 --- /dev/null +++ b/tests/ext/octicons/__init__.py @@ -0,0 +1,3 @@ +"""Tests for sphinx-ux-octicons.""" + +from __future__ import annotations diff --git a/tests/ext/octicons/__snapshots__/test_snapshot.ambr b/tests/ext/octicons/__snapshots__/test_snapshot.ambr new file mode 100644 index 00000000..cc918714 --- /dev/null +++ b/tests/ext/octicons/__snapshots__/test_snapshot.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_render_snapshot[alert][alert] + '' +# --- +# name: test_render_snapshot[book][book] + '' +# --- +# name: test_render_snapshot[check-circle][check-circle] + '' +# --- +# name: test_render_snapshot[code][code] + '' +# --- +# name: test_render_snapshot[device-camera][device-camera] + '' +# --- +# name: test_render_snapshot[diff][diff] + '' +# --- +# name: test_render_snapshot[gear][gear] + '' +# --- +# name: test_render_snapshot[home][home] + '' +# --- +# name: test_render_snapshot[info][info] + '' +# --- +# name: test_render_snapshot[light-bulb][light-bulb] + '' +# --- +# name: test_render_snapshot[link][link] + '' +# --- +# name: test_render_snapshot[package][package] + '' +# --- +# name: test_render_snapshot[paintbrush][paintbrush] + '' +# --- +# name: test_render_snapshot[rocket][rocket] + '' +# --- +# name: test_render_snapshot[star][star] + '' +# --- +# name: test_render_snapshot[terminal][terminal] + '' +# --- +# name: test_render_snapshot[tools][tools] + '' +# --- +# name: test_render_snapshot[x-circle][x-circle] + '' +# --- diff --git a/tests/ext/octicons/test_integration.py b/tests/ext/octicons/test_integration.py new file mode 100644 index 00000000..3a349d67 --- /dev/null +++ b/tests/ext/octicons/test_integration.py @@ -0,0 +1,103 @@ +"""Integration tests for ``{octicon}`` role rendering. + +Verifies that the HTML builder emits the SVG payload without leaking the +icon-name fallback text, and that the text builder renders the icon name +instead of raw SVG markup. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + extensions = [ + "myst_parser", + "sphinx_ux_octicons", + ] + """ +) + +_INDEX_MD = textwrap.dedent( + """\ + # Demo + + Inline: {octicon}`rocket` here. + """ +) + + +@pytest.fixture(scope="module") +def octicons_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a minimal MyST project with the ``{octicon}`` role (HTML builder).""" + cache_root = tmp_path_factory.mktemp("octicons-html") + scenario = SphinxScenario( + buildername="html", + files=( + ScenarioFile("conf.py", _CONF_PY), + ScenarioFile("index.md", _INDEX_MD), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.fixture(scope="module") +def octicons_text_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a minimal MyST project with the ``{octicon}`` role (text builder).""" + cache_root = tmp_path_factory.mktemp("octicons-text") + scenario = SphinxScenario( + buildername="text", + files=( + ScenarioFile("conf.py", _CONF_PY), + ScenarioFile("index.md", _INDEX_MD), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.mark.integration +def test_html_emits_svg_without_icon_name_leak( + octicons_html_result: SharedSphinxResult, +) -> None: + """HTML output contains the SVG and never the trailing-name leak.""" + html = read_output(octicons_html_result, "index.html") + + # Exactly one rendered SVG element with the expected class pair. + svg_open = 'rocket" not in html + # Alternate framing in case the leak is wrapped in another tag. + assert ">rocket<" not in html.split("", 1)[1][:32] + + +@pytest.mark.integration +def test_text_builder_renders_icon_name_fallback( + octicons_text_result: SharedSphinxResult, +) -> None: + """Text builder renders the icon name as the visible surrogate.""" + text = read_output(octicons_text_result, "index.txt") + # The icon name is the visible surrogate carried as a Text child for + # builders other than HTML. + assert "rocket" in text + # The text builder must not emit raw SVG markup. + assert " None: + """All 18 curated icons land in the bundled JSON.""" + assert len(_BUNDLED) == 18 + + +@pytest.mark.parametrize( + list(IconFixture._fields), + _BUNDLED, + ids=[f.test_id for f in _BUNDLED], +) +def test_render_icon_emits_payload_and_class(test_id: str, name: str) -> None: + """render_octicon embeds the icon's path payload and namespace class.""" + svg = render_octicon(name) + entry = load_octicons()[name] + assert svg.startswith("") + assert entry["path"] in svg + expected_class = f'class="gp-sphinx-octicon gp-sphinx-octicon--{name}"' + assert expected_class in svg diff --git a/tests/ext/octicons/test_render_height_parse.py b/tests/ext/octicons/test_render_height_parse.py new file mode 100644 index 00000000..b09a55b9 --- /dev/null +++ b/tests/ext/octicons/test_render_height_parse.py @@ -0,0 +1,98 @@ +"""Height-parse and lookup error coverage for ``render_octicon``.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_ux_octicons._render import load_octicons, render_octicon + + +class HeightFixture(t.NamedTuple): + """One height-parse case.""" + + test_id: str + height: str + expected_width: str + expected_height: str + + +_VALID: list[HeightFixture] = [ + HeightFixture( + test_id="em-default", + height="1em", + expected_width='width="1.0em"', + expected_height='height="1.0em"', + ), + HeightFixture( + test_id="px-24", + height="24px", + expected_width='width="24.0px"', + expected_height='height="24.0px"', + ), + HeightFixture( + test_id="rem-fractional", + height="1.5rem", + expected_width='width="1.5rem"', + expected_height='height="1.5rem"', + ), +] + + +@pytest.mark.parametrize( + list(HeightFixture._fields), + _VALID, + ids=[f.test_id for f in _VALID], +) +def test_render_valid_height_units( + test_id: str, + height: str, + expected_width: str, + expected_height: str, +) -> None: + """Valid CSS lengths render width/height with the same unit and a 1:1 ratio.""" + # The bundled 16x16 icons keep a square aspect ratio, so width == height. + svg = render_octicon("rocket", height=height) + assert expected_width in svg + assert expected_height in svg + + +def test_render_aspect_ratio_preserved() -> None: + """Width tracks ``original_width * value / original_height`` exactly.""" + # rocket is 16x16; scaling height to 32px should yield width 32px too. + svg = render_octicon("rocket", height="32px") + assert 'width="32.0px"' in svg + assert 'height="32.0px"' in svg + + +def test_render_rejects_unitless_height() -> None: + """Heights without a CSS unit raise ``ValueError``.""" + with pytest.raises(ValueError, match="invalid height"): + render_octicon("rocket", height="1") + + +def test_render_rejects_unknown_unit() -> None: + """Unsupported units raise ``ValueError``.""" + with pytest.raises(ValueError, match="invalid height"): + render_octicon("rocket", height="2pt") + + +def test_render_rejects_unknown_icon() -> None: + """Unknown icon names raise ``KeyError`` with the requested name.""" + with pytest.raises(KeyError) as excinfo: + render_octicon("not-a-real-icon") + assert excinfo.value.args == ("not-a-real-icon",) + + +def test_render_appends_extra_classes() -> None: + """Extra classes are appended after the namespace pair.""" + svg = render_octicon("rocket", classes=("extra-one", "extra-two")) + assert ( + 'class="gp-sphinx-octicon gp-sphinx-octicon--rocket extra-one extra-two"' in svg + ) + + +def test_load_octicons_is_cached() -> None: + """``load_octicons`` returns the same mapping on repeated calls.""" + assert load_octicons() is load_octicons() diff --git a/tests/ext/octicons/test_role.py b/tests/ext/octicons/test_role.py new file mode 100644 index 00000000..a6104425 --- /dev/null +++ b/tests/ext/octicons/test_role.py @@ -0,0 +1,151 @@ +"""Role-level coverage for ``OcticonRole``. + +Instead of standing up a Sphinx app, these tests wire ``OcticonRole`` +against a minimal stub inliner so the three role syntaxes +(``name``, ``name;height``, ``name;height;classes``) and the error path +can be exercised in microseconds. +""" + +from __future__ import annotations + +import types +import typing as t + +from docutils import nodes + +from sphinx_ux_octicons._nodes import OcticonNode +from sphinx_ux_octicons._role import OcticonRole + + +class _StubReporter: + """Capture reported errors and provide stable source/line tuples.""" + + def __init__(self) -> None: + self.errors: list[str] = [] + + def error(self, message: str, *, line: int | None = None) -> nodes.system_message: + self.errors.append(message) + return nodes.system_message(message, type="ERROR", level=3, line=line or 0) + + def get_source_and_line(self, lineno: int | None = None) -> tuple[str, int]: + return ("", lineno or 0) + + +class _StubInliner: + """Mimic the ``docutils`` ``Inliner`` surface used by ``SphinxRole``.""" + + def __init__(self) -> None: + self.reporter = _StubReporter() + + def problematic( + self, + rawtext: str, + text: str, + message: nodes.system_message, + ) -> nodes.problematic: + return nodes.problematic(rawtext, text) + + +def _make_role(text: str) -> OcticonRole: + role = OcticonRole() + role.name = "octicon" + role.rawtext = f"`{text}`" + role.text = text + role.lineno = 1 + role.inliner = t.cast(t.Any, _StubInliner()) + role.options = {} + role.content = () + return role + + +def test_role_name_only_produces_octicon_node() -> None: + """``{octicon}`rocket`` emits an :class:`OcticonNode` carrying the SVG.""" + role = _make_role("rocket") + out, messages = role.run() + assert messages == [] + assert len(out) == 1 + node = out[0] + assert isinstance(node, OcticonNode) + svg = node["svg_markup"] + assert svg.startswith(" None: + """``name;height`` propagates the height through to the rendered SVG.""" + role = _make_role("rocket;24px") + out, messages = role.run() + assert messages == [] + node = out[0] + assert isinstance(node, OcticonNode) + svg = node["svg_markup"] + assert 'height="24.0px"' in svg + assert 'width="24.0px"' in svg + + +def test_role_name_height_and_classes() -> None: + """``name;height;classes`` appends extra classes after the namespace pair.""" + role = _make_role("rocket;1em;my-class other-class") + out, _ = role.run() + node = out[0] + assert isinstance(node, OcticonNode) + svg = node["svg_markup"] + assert ( + 'class="gp-sphinx-octicon gp-sphinx-octicon--rocket my-class other-class"' + in svg + ) + + +def test_role_unknown_icon_reports_error() -> None: + """Unknown icons report a docutils system message and emit a problematic node.""" + role = _make_role("nope") + out, messages = role.run() + assert len(messages) == 1 + assert len(out) == 1 + assert isinstance(out[0], nodes.problematic) + assert "nope" in role.inliner.reporter.errors[0] # type: ignore[attr-defined] + + +def test_role_invalid_height_reports_error() -> None: + """Invalid height strings surface as system messages, not raises.""" + role = _make_role("rocket;1") + out, messages = role.run() + assert len(messages) == 1 + assert isinstance(out[0], nodes.problematic) + + +def test_role_empty_height_falls_back_to_default() -> None: + """An empty height segment falls back to the ``1em`` default.""" + role = _make_role("rocket;") + out, messages = role.run() + assert messages == [] + node = out[0] + assert isinstance(node, OcticonNode) + svg = node["svg_markup"] + assert 'height="1.0em"' in svg + + +def test_role_set_source_info_uses_inliner_reporter() -> None: + """``set_source_info`` populates ``node.source`` / ``node.line``.""" + + class _SourceInliner(_StubInliner): + pass + + role = OcticonRole() + role.name = "octicon" + role.rawtext = "`rocket`" + role.text = "rocket" + role.lineno = 42 + role.inliner = t.cast(t.Any, _SourceInliner()) + # SphinxRole.set_source_info reads inliner.document.settings.env for env + # access; we never trigger env access here so a placeholder is enough. + role.inliner.document = t.cast(t.Any, types.SimpleNamespace()) + role.options = {} + role.content = () + + out, _ = role.run() + node = out[0] + assert node.line == 42 + assert node.source == "" diff --git a/tests/ext/octicons/test_snapshot.py b/tests/ext/octicons/test_snapshot.py new file mode 100644 index 00000000..4c453390 --- /dev/null +++ b/tests/ext/octicons/test_snapshot.py @@ -0,0 +1,35 @@ +"""Snapshot coverage of ``render_octicon`` for every bundled icon.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_ux_octicons._render import load_octicons, render_octicon + + +class IconFixture(t.NamedTuple): + """One curated icon name.""" + + test_id: str + name: str + + +_BUNDLED: list[IconFixture] = [ + IconFixture(test_id=name, name=name) for name in sorted(load_octicons()) +] + + +@pytest.mark.parametrize( + list(IconFixture._fields), + _BUNDLED, + ids=[f.test_id for f in _BUNDLED], +) +def test_render_snapshot( + test_id: str, + name: str, + snapshot_html_fragment: t.Callable[..., None], +) -> None: + """Each bundled icon renders to a stable SVG fragment.""" + snapshot_html_fragment(render_octicon(name), name=name) diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 8a96c8d6..69e85a67 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -26,6 +26,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-autodoc-argparse", "sphinx-autodoc-api-style", "sphinx-ux-badges", + "sphinx-ux-octicons", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", "sphinx-ux-autodoc-layout", diff --git a/uv.lock b/uv.lock index 3b523314..41a6426e 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,7 @@ members = [ "sphinx-gp-theme", "sphinx-ux-autodoc-layout", "sphinx-ux-badges", + "sphinx-ux-octicons", "sphinx-vite-builder", ] @@ -545,6 +546,7 @@ dev = [ { name = "sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, + { name = "sphinx-ux-octicons" }, { name = "sphinx-vite-builder" }, { name = "syrupy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -584,6 +586,7 @@ dev = [ { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout", editable = "packages/sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges", editable = "packages/sphinx-ux-badges" }, + { name = "sphinx-ux-octicons", editable = "packages/sphinx-ux-octicons" }, { name = "sphinx-vite-builder", editable = "packages/sphinx-vite-builder" }, { name = "syrupy", specifier = ">=5.1.0" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -1878,6 +1881,18 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] +[[package]] +name = "sphinx-ux-octicons" +version = "0.0.1a18" +source = { editable = "packages/sphinx-ux-octicons" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] + [[package]] name = "sphinx-vite-builder" version = "0.0.1a20" From 68e4a263c54a30b4b16383571c6becb4a20d02f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 08:23:31 -0500 Subject: [PATCH 02/11] sphinx-ux-badges(feat[bdg-roles]): Add 22 {bdg-*} MyST roles backed by BadgeNode why: Provide first-party `{bdg-}` and `{bdg--line}` roles that route through BadgeNode and the gp-sphinx-* namespace, so the authoring surface is drop-in while every emitted class stays inside gp-sphinx-badge. what: - Add _roles.py with a programmatic factory and `register_bdg_roles` registering both fill and outline variants for the eleven semantic colours (primary, secondary, success, info, warning, danger, light, muted, dark, white, black) - Extend sab_palettes.css with semantic colour triples (bg/fg/border) plus dark-mode overrides, and bind each `gp-sphinx-badge--color-*` class to the shared `--gp-sphinx-badge-{bg,fg,border}` tokens - Add `SAB.COLOR_*` constants for the eleven colours - Wire `register_bdg_roles(app)` into `setup()` and re-export it from the package's `__all__` - Cover the new surface with role-registration, factory-invocation, and integration-build tests in `tests/ext/badges/test_badges.py` --- .../src/sphinx_ux_badges/__init__.py | 4 + .../src/sphinx_ux_badges/_css.py | 19 ++ .../src/sphinx_ux_badges/_roles.py | 141 +++++++++++++ .../_static/css/sab_palettes.css | 153 ++++++++++++++ tests/ext/badges/test_badges.py | 188 ++++++++++++++++++ 5 files changed, 505 insertions(+) create mode 100644 packages/sphinx-ux-badges/src/sphinx_ux_badges/_roles.py diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py index 6ab88f14..81b4cba5 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/__init__.py @@ -32,6 +32,7 @@ ) from sphinx_ux_badges._css import SAB from sphinx_ux_badges._nodes import BadgeNode +from sphinx_ux_badges._roles import register_bdg_roles from sphinx_ux_badges._visitors import depart_badge_html, visit_badge_html __all__ = [ @@ -43,6 +44,7 @@ "build_badge_group", "build_badge_group_from_specs", "build_toolbar", + "register_bdg_roles", "setup", ] @@ -82,6 +84,8 @@ def _add_static_path(app: Sphinx) -> None: app.add_css_file("css/sphinx_ux_badges.css") app.add_css_file("css/sab_palettes.css") + register_bdg_roles(app) + return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 948e79fe..abd913f6 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -34,6 +34,12 @@ >>> SAB.TYPE_CONFIG 'gp-sphinx-badge--type-config' + +>>> SAB.COLOR_PRIMARY +'gp-sphinx-badge--color-primary' + +>>> SAB.COLOR_BLACK +'gp-sphinx-badge--color-black' """ from __future__ import annotations @@ -167,6 +173,19 @@ class SAB: META_BETA = "gp-sphinx-badge--meta-beta" META_LINK = "gp-sphinx-badge--meta-link" + # ── Semantic colour palette (drives {bdg-*} roles) ──── + COLOR_PRIMARY = "gp-sphinx-badge--color-primary" + COLOR_SECONDARY = "gp-sphinx-badge--color-secondary" + COLOR_SUCCESS = "gp-sphinx-badge--color-success" + COLOR_INFO = "gp-sphinx-badge--color-info" + COLOR_WARNING = "gp-sphinx-badge--color-warning" + COLOR_DANGER = "gp-sphinx-badge--color-danger" + COLOR_LIGHT = "gp-sphinx-badge--color-light" + COLOR_MUTED = "gp-sphinx-badge--color-muted" + COLOR_DARK = "gp-sphinx-badge--color-dark" + COLOR_WHITE = "gp-sphinx-badge--color-white" + COLOR_BLACK = "gp-sphinx-badge--color-black" + @staticmethod def obj_type(name: str) -> str: """Return the type-specific CSS class for a Python API object. diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_roles.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_roles.py new file mode 100644 index 00000000..47ff007a --- /dev/null +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_roles.py @@ -0,0 +1,141 @@ +"""MyST ``{bdg-*}`` role factory and registration. + +Provides 22 drop-in roles -- ``{bdg-}`` and ``{bdg--line}`` -- +that emit a :class:`BadgeNode` carrying the gp-sphinx-* color classes from +``sab_palettes.css``. Each role accepts the literal role text as the visible +badge label, matching sphinx-design's authoring surface. + +Examples +-------- +>>> _BDG_COLORS[0] +'primary' + +>>> len(_BDG_COLORS) +11 + +>>> role = _make_bdg_role("primary", outline=False) +>>> nodes_, messages = role( +... "bdg-primary", +... "{bdg-primary}`Hi`", +... "Hi", +... 0, +... None, # type: ignore[arg-type] +... ) +>>> badge = nodes_[0] +>>> badge.astext() +'Hi' + +>>> "gp-sphinx-badge--color-primary" in badge["classes"] +True + +>>> "gp-sphinx-badge--filled" in badge["classes"] +True + +>>> outline_role = _make_bdg_role("danger", outline=True) +>>> outline_nodes, _ = outline_role( +... "bdg-danger-line", +... "{bdg-danger-line}`Hot`", +... "Hot", +... 0, +... None, # type: ignore[arg-type] +... ) +>>> "gp-sphinx-badge--outline" in outline_nodes[0]["classes"] +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +from sphinx_ux_badges._css import SAB +from sphinx_ux_badges._nodes import BadgeNode + +if t.TYPE_CHECKING: + from docutils.parsers.rst.states import Inliner + from sphinx.application import Sphinx + from sphinx.util.typing import RoleFunction + + +_BDG_COLORS: tuple[str, ...] = ( + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "muted", + "dark", + "white", + "black", +) + + +def _make_bdg_role(color: str, *, outline: bool) -> RoleFunction: + """Return a docutils role function for ``{bdg-}`` markup. + + Parameters + ---------- + color : str + Semantic color name (e.g. ``"primary"``, ``"danger"``). + outline : bool + When ``True`` the badge gets :attr:`SAB.OUTLINE`; otherwise + :attr:`SAB.FILLED`. + + Returns + ------- + RoleFunction + Callable suitable for :meth:`sphinx.application.Sphinx.add_role`. + + Examples + -------- + >>> role = _make_bdg_role("success", outline=False) + >>> callable(role) + True + """ + fill_class = SAB.OUTLINE if outline else SAB.FILLED + color_class = f"gp-sphinx-badge--color-{color}" + + def role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: Inliner, + /, + options: dict[str, t.Any] | None = None, + content: t.Sequence[str] = (), + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + badge = BadgeNode( + text, + classes=[color_class, fill_class], + ) + return [badge], [] + + role.__name__ = f"bdg_{color}{'_line' if outline else ''}_role" + role.__qualname__ = role.__name__ + return role + + +def register_bdg_roles(app: Sphinx) -> None: + """Register all ``{bdg-}`` and ``{bdg--line}`` roles. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Examples + -------- + >>> from sphinx_ux_badges._roles import register_bdg_roles + >>> callable(register_bdg_roles) + True + """ + for color in _BDG_COLORS: + app.add_role(f"bdg-{color}", _make_bdg_role(color, outline=False)) + app.add_role( + f"bdg-{color}-line", + _make_bdg_role(color, outline=True), + ) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index db98e575..ea8ba75c 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -126,6 +126,51 @@ --gp-sphinx-badge-meta-link-fg: #6b7280; --gp-sphinx-badge-meta-link-border: #d1d5db; + + /* ── Semantic colour palette (drives {bdg-*} roles) ─── */ + --gp-sphinx-badge-color-primary-bg: #0d6efd; + --gp-sphinx-badge-color-primary-fg: #ffffff; + --gp-sphinx-badge-color-primary-border: #0d6efd; + + --gp-sphinx-badge-color-secondary-bg: #6c757d; + --gp-sphinx-badge-color-secondary-fg: #ffffff; + --gp-sphinx-badge-color-secondary-border: #6c757d; + + --gp-sphinx-badge-color-success-bg: #198754; + --gp-sphinx-badge-color-success-fg: #ffffff; + --gp-sphinx-badge-color-success-border: #198754; + + --gp-sphinx-badge-color-info-bg: #0dcaf0; + --gp-sphinx-badge-color-info-fg: #1f2937; + --gp-sphinx-badge-color-info-border: #0dcaf0; + + --gp-sphinx-badge-color-warning-bg: #ffc107; + --gp-sphinx-badge-color-warning-fg: #1f2937; + --gp-sphinx-badge-color-warning-border: #ffc107; + + --gp-sphinx-badge-color-danger-bg: #dc3545; + --gp-sphinx-badge-color-danger-fg: #ffffff; + --gp-sphinx-badge-color-danger-border: #dc3545; + + --gp-sphinx-badge-color-light-bg: #f8f9fa; + --gp-sphinx-badge-color-light-fg: #1f2937; + --gp-sphinx-badge-color-light-border: #d1d5db; + + --gp-sphinx-badge-color-muted-bg: #adb5bd; + --gp-sphinx-badge-color-muted-fg: #1f2937; + --gp-sphinx-badge-color-muted-border: #adb5bd; + + --gp-sphinx-badge-color-dark-bg: #212529; + --gp-sphinx-badge-color-dark-fg: #ffffff; + --gp-sphinx-badge-color-dark-border: #212529; + + --gp-sphinx-badge-color-white-bg: #ffffff; + --gp-sphinx-badge-color-white-fg: #1f2937; + --gp-sphinx-badge-color-white-border: #d1d5db; + + --gp-sphinx-badge-color-black-bg: #000000; + --gp-sphinx-badge-color-black-fg: #ffffff; + --gp-sphinx-badge-color-black-border: #000000; } /* ══════════════════════════════════════════════════════════ @@ -233,6 +278,51 @@ --gp-sphinx-badge-meta-link-fg: #9ca3af; --gp-sphinx-badge-meta-link-border: #4b5563; + + /* ── Semantic colour palette (drives {bdg-*} roles) ─── */ + --gp-sphinx-badge-color-primary-bg: #3b82f6; + --gp-sphinx-badge-color-primary-fg: #ffffff; + --gp-sphinx-badge-color-primary-border: #3b82f6; + + --gp-sphinx-badge-color-secondary-bg: #94a3b8; + --gp-sphinx-badge-color-secondary-fg: #0f172a; + --gp-sphinx-badge-color-secondary-border: #94a3b8; + + --gp-sphinx-badge-color-success-bg: #22c55e; + --gp-sphinx-badge-color-success-fg: #052e16; + --gp-sphinx-badge-color-success-border: #22c55e; + + --gp-sphinx-badge-color-info-bg: #22d3ee; + --gp-sphinx-badge-color-info-fg: #083344; + --gp-sphinx-badge-color-info-border: #22d3ee; + + --gp-sphinx-badge-color-warning-bg: #fbbf24; + --gp-sphinx-badge-color-warning-fg: #1f2937; + --gp-sphinx-badge-color-warning-border: #fbbf24; + + --gp-sphinx-badge-color-danger-bg: #f87171; + --gp-sphinx-badge-color-danger-fg: #1f2937; + --gp-sphinx-badge-color-danger-border: #f87171; + + --gp-sphinx-badge-color-light-bg: #e5e7eb; + --gp-sphinx-badge-color-light-fg: #1f2937; + --gp-sphinx-badge-color-light-border: #9ca3af; + + --gp-sphinx-badge-color-muted-bg: #6b7280; + --gp-sphinx-badge-color-muted-fg: #f3f4f6; + --gp-sphinx-badge-color-muted-border: #6b7280; + + --gp-sphinx-badge-color-dark-bg: #374151; + --gp-sphinx-badge-color-dark-fg: #ffffff; + --gp-sphinx-badge-color-dark-border: #374151; + + --gp-sphinx-badge-color-white-bg: #f9fafb; + --gp-sphinx-badge-color-white-fg: #1f2937; + --gp-sphinx-badge-color-white-border: #9ca3af; + + --gp-sphinx-badge-color-black-bg: #111827; + --gp-sphinx-badge-color-black-fg: #f9fafb; + --gp-sphinx-badge-color-black-border: #111827; } } @@ -341,6 +431,51 @@ body[data-theme="dark"] { --gp-sphinx-badge-meta-link-fg: #9ca3af; --gp-sphinx-badge-meta-link-border: #4b5563; + + /* ── Semantic colour palette (drives {bdg-*} roles) ─── */ + --gp-sphinx-badge-color-primary-bg: #3b82f6; + --gp-sphinx-badge-color-primary-fg: #ffffff; + --gp-sphinx-badge-color-primary-border: #3b82f6; + + --gp-sphinx-badge-color-secondary-bg: #94a3b8; + --gp-sphinx-badge-color-secondary-fg: #0f172a; + --gp-sphinx-badge-color-secondary-border: #94a3b8; + + --gp-sphinx-badge-color-success-bg: #22c55e; + --gp-sphinx-badge-color-success-fg: #052e16; + --gp-sphinx-badge-color-success-border: #22c55e; + + --gp-sphinx-badge-color-info-bg: #22d3ee; + --gp-sphinx-badge-color-info-fg: #083344; + --gp-sphinx-badge-color-info-border: #22d3ee; + + --gp-sphinx-badge-color-warning-bg: #fbbf24; + --gp-sphinx-badge-color-warning-fg: #1f2937; + --gp-sphinx-badge-color-warning-border: #fbbf24; + + --gp-sphinx-badge-color-danger-bg: #f87171; + --gp-sphinx-badge-color-danger-fg: #1f2937; + --gp-sphinx-badge-color-danger-border: #f87171; + + --gp-sphinx-badge-color-light-bg: #e5e7eb; + --gp-sphinx-badge-color-light-fg: #1f2937; + --gp-sphinx-badge-color-light-border: #9ca3af; + + --gp-sphinx-badge-color-muted-bg: #6b7280; + --gp-sphinx-badge-color-muted-fg: #f3f4f6; + --gp-sphinx-badge-color-muted-border: #6b7280; + + --gp-sphinx-badge-color-dark-bg: #374151; + --gp-sphinx-badge-color-dark-fg: #ffffff; + --gp-sphinx-badge-color-dark-border: #374151; + + --gp-sphinx-badge-color-white-bg: #f9fafb; + --gp-sphinx-badge-color-white-fg: #1f2937; + --gp-sphinx-badge-color-white-border: #9ca3af; + + --gp-sphinx-badge-color-black-bg: #111827; + --gp-sphinx-badge-color-black-fg: #f9fafb; + --gp-sphinx-badge-color-black-border: #111827; } /* ══════════════════════════════════════════════════════════ @@ -404,3 +539,21 @@ body[data-theme="dark"] { .gp-sphinx-badge--meta-alpha { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-meta-alpha-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-meta-alpha-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-meta-alpha-border); } .gp-sphinx-badge--meta-beta { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-meta-beta-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-meta-beta-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-meta-beta-border); } .gp-sphinx-badge--meta-link { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-meta-link-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-meta-link-border); } + +/* ══════════════════════════════════════════════════════════ + * Colour classes — semantic palette (drives {bdg-*} roles) + * Bound to the same --gp-sphinx-badge-{bg,fg,border} tokens the base + * badge consumes; the --outline modifier already swaps the bg to + * transparent via the .gp-sphinx-badge--outline rule. + * ══════════════════════════════════════════════════════════ */ +.gp-sphinx-badge--color-primary { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-primary-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-primary-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-primary-border); } +.gp-sphinx-badge--color-secondary { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-secondary-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-secondary-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-secondary-border); } +.gp-sphinx-badge--color-success { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-success-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-success-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-success-border); } +.gp-sphinx-badge--color-info { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-info-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-info-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-info-border); } +.gp-sphinx-badge--color-warning { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-warning-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-warning-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-warning-border); } +.gp-sphinx-badge--color-danger { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-danger-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-danger-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-danger-border); } +.gp-sphinx-badge--color-light { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-light-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-light-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-light-border); } +.gp-sphinx-badge--color-muted { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-muted-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-muted-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-muted-border); } +.gp-sphinx-badge--color-dark { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-dark-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-dark-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-dark-border); } +.gp-sphinx-badge--color-white { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-white-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-white-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-white-border); } +.gp-sphinx-badge--color-black { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-color-black-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-color-black-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-color-black-border); } diff --git a/tests/ext/badges/test_badges.py b/tests/ext/badges/test_badges.py index 7da09b13..9582e377 100644 --- a/tests/ext/badges/test_badges.py +++ b/tests/ext/badges/test_badges.py @@ -2,6 +2,9 @@ from __future__ import annotations +import textwrap +import typing as t + import pytest from docutils import nodes @@ -15,6 +18,14 @@ build_toolbar, ) from sphinx_ux_badges._css import SAB +from sphinx_ux_badges._roles import _BDG_COLORS, _make_bdg_role +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) def test_badge_node_is_inline_subclass() -> None: @@ -222,3 +233,180 @@ def test_css_constants() -> None: assert SAB.MD == "gp-sphinx-badge--size-md" assert SAB.LG == "gp-sphinx-badge--size-lg" assert SAB.XL == "gp-sphinx-badge--size-xl" + + +def test_css_color_constants() -> None: + """SAB exposes a COLOR_ constant for every {bdg-*} colour.""" + for color in _BDG_COLORS: + attr = f"COLOR_{color.upper()}" + assert hasattr(SAB, attr), f"SAB.{attr} missing" + assert getattr(SAB, attr) == f"gp-sphinx-badge--color-{color}" + + +# ── {bdg-*} role registration ──────────────────────────────── + + +class BdgRoleRegistrationFixture(t.NamedTuple): + """Single parametrized case for {bdg-*} role registration.""" + + test_id: str + role_name: str + + +_BDG_ROLE_FIXTURES: list[BdgRoleRegistrationFixture] = [ + BdgRoleRegistrationFixture(test_id=f"bdg-{c}", role_name=f"bdg-{c}") + for c in _BDG_COLORS +] + [ + BdgRoleRegistrationFixture(test_id=f"bdg-{c}-line", role_name=f"bdg-{c}-line") + for c in _BDG_COLORS +] + + +@pytest.mark.parametrize( + list(BdgRoleRegistrationFixture._fields), + _BDG_ROLE_FIXTURES, + ids=[f.test_id for f in _BDG_ROLE_FIXTURES], +) +def test_bdg_role_registration(test_id: str, role_name: str) -> None: + """Every {bdg-}{,-line} role registers when setup() runs.""" + from sphinx.util.docutils import docutils_namespace, is_role_registered + + from sphinx_ux_badges._roles import register_bdg_roles + + class _StubApp: + """Minimal app surface required by register_bdg_roles.""" + + def __init__(self) -> None: + self.added: list[tuple[str, t.Any]] = [] + + def add_role(self, name: str, role: t.Any) -> None: + self.added.append((name, role)) + # Mirror Sphinx behaviour so is_role_registered can see it. + from docutils.parsers.rst import roles as _roles + + _roles.register_local_role(name, role) + + with docutils_namespace(): + app = _StubApp() + register_bdg_roles(app) # type: ignore[arg-type] + registered_names = [name for name, _ in app.added] + assert role_name in registered_names + assert is_role_registered(role_name) + + +# ── {bdg-*} role factory invocation ────────────────────────── + + +class BdgFactoryFixture(t.NamedTuple): + """Single parametrized case for direct role-factory invocation.""" + + test_id: str + color: str + outline: bool + text: str + fill_class: str + + +_BDG_FACTORY_FIXTURES: list[BdgFactoryFixture] = [ + BdgFactoryFixture( + test_id=f"{c}-filled", + color=c, + outline=False, + text=f"{c.title()} Label", + fill_class=SAB.FILLED, + ) + for c in _BDG_COLORS +] + [ + BdgFactoryFixture( + test_id=f"{c}-outline", + color=c, + outline=True, + text=f"{c.title()} Outline", + fill_class=SAB.OUTLINE, + ) + for c in _BDG_COLORS +] + + +@pytest.mark.parametrize( + "case", + _BDG_FACTORY_FIXTURES, + ids=lambda c: c.test_id, +) +def test_bdg_role_invocation(case: BdgFactoryFixture) -> None: + """The role factory produces a BadgeNode with the expected classes.""" + role = _make_bdg_role(case.color, outline=case.outline) + role_name = f"bdg-{case.color}{'-line' if case.outline else ''}" + nodes_, messages = role( + role_name, + f"{{{role_name}}}`{case.text}`", + case.text, + 0, + None, # type: ignore[arg-type] + ) + + assert messages == [] + assert len(nodes_) == 1 + badge = nodes_[0] + assert isinstance(badge, BadgeNode) + assert badge.astext() == case.text + + classes = badge["classes"] + assert SAB.BADGE in classes + assert f"gp-sphinx-badge--color-{case.color}" in classes + assert case.fill_class in classes + + +# ── {bdg-*} integration build ──────────────────────────────── + + +_BDG_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + extensions = [ + "myst_parser", + "sphinx_ux_badges", + ] + """ +) + +_BDG_INDEX_MD = textwrap.dedent( + """\ + # Demo + + Filled: {bdg-primary}`Alpha` and outlined: {bdg-danger-line}`Hot`. + """ +) + + +@pytest.fixture(scope="module") +def bdg_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a MyST project that exercises {bdg-primary} and {bdg-danger-line}.""" + cache_root = tmp_path_factory.mktemp("bdg-html") + scenario = SphinxScenario( + buildername="html", + files=( + ScenarioFile("conf.py", _BDG_CONF_PY), + ScenarioFile("index.md", _BDG_INDEX_MD), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.mark.integration +def test_bdg_role_html_emits_expected_classes( + bdg_html_result: SharedSphinxResult, +) -> None: + """End-to-end HTML build renders gp-sphinx-badge--color-* classes.""" + html = read_output(bdg_html_result, "index.html") + + assert "gp-sphinx-badge--color-primary" in html + assert "gp-sphinx-badge--filled" in html + assert "Alpha" in html + + assert "gp-sphinx-badge--color-danger" in html + assert "gp-sphinx-badge--outline" in html + assert "Hot" in html From 60c41f4f7ad2be86d7650fe83d6543148352112c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 08:47:25 -0500 Subject: [PATCH 03/11] sphinx-ux-grid(feat[new-package]): Drop-in {grid} and {grid-item-card} directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Provide a first-party drop-in for sphinx-design's grid markup that keeps every emitted class inside the `gp-sphinx-grid*` namespace and drives layout from CSS Grid via inlined CSS custom-property overrides instead of Bootstrap-derived float classes. what: - Add `sphinx-ux-grid` workspace package exposing `GridDirective`, `GridItemDirective`, `GridItemCardDirective`, `SUG`, and `LinkPassthrough` from the top-level module - Parse breakpoint column specs ("N" or "xs sm md lg" with each in [1..12]) into a four-int tuple and inline them as `--gp-sphinx-grid-cols-{xs,sm,md,lg}` style properties; `:gutter:` accepts the 0..5 Bootstrap scale (mapped to a fixed CSS length) or a raw CSS length, surfaced as `--gp-sphinx-grid-gutter` - Support every `{grid-item-card}` option the workspace consumes — `:link:` / `:link-type:` (url/any/ref/doc) with `addnodes.pending_xref` for non-URL targets, `:shadow:` (none/sm/md/lg), `:img-top:`, `:img-bottom:`, `:img-background:`, `:width:`, `:text-align:`, and the full set of `:class-*:` overrides - Split card content on `^^^` (header) and `+++` (footer) markers, matching the splitter shape consuming docs already use - Wrap each stretched link in a `LinkPassthrough` text element so the HTML5 writer accepts a bare `nodes.reference` / `pending_xref` as a card child without tripping its image-only assertion path - Ship `_static/css/sphinx_ux_grid.css` under `@layer gp-sphinx` driving the layout from the inlined custom properties with sensible fallbacks - Cover the package with option-parsing unit tests, docutils-tree directive tests (including stretched-link node-type assertions for `url` and `doc` link types), and an integration build asserting HTML structure plus internal-doc/external-URL link resolution - Wire the package into the workspace: `pyproject.toml` (sources, dev-deps, isort, testpaths), docs package index, redirect map, cluster classification, docs `sys.path` bootstrap, the CI smoke runner, and `tests/test_package_reference.py` --- docs/_ext/package_reference.py | 1 + docs/conf.py | 4 + docs/packages/index.md | 1 + docs/packages/sphinx-ux-grid/index.md | 6 + docs/redirects.txt | 1 + packages/sphinx-ux-grid/README.md | 53 + packages/sphinx-ux-grid/pyproject.toml | 40 + .../src/sphinx_ux_grid/__init__.py | 100 ++ .../sphinx-ux-grid/src/sphinx_ux_grid/_css.py | 205 ++++ .../src/sphinx_ux_grid/_directives.py | 919 ++++++++++++++++++ .../src/sphinx_ux_grid/_nodes.py | 54 + .../_static/css/sphinx_ux_grid.css | 378 +++++++ .../src/sphinx_ux_grid/py.typed | 0 pyproject.toml | 4 + scripts/ci/package_tools.py | 24 + tests/ext/grid/__init__.py | 3 + tests/ext/grid/test_directive_tree.py | 429 ++++++++ tests/ext/grid/test_integration.py | 146 +++ tests/ext/grid/test_options.py | 111 +++ tests/test_package_reference.py | 1 + uv.lock | 15 + 21 files changed, 2495 insertions(+) create mode 100644 docs/packages/sphinx-ux-grid/index.md create mode 100644 packages/sphinx-ux-grid/README.md create mode 100644 packages/sphinx-ux-grid/pyproject.toml create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/__init__.py create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/_css.py create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/_directives.py create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/_nodes.py create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/_static/css/sphinx_ux_grid.css create mode 100644 packages/sphinx-ux-grid/src/sphinx_ux_grid/py.typed create mode 100644 tests/ext/grid/__init__.py create mode 100644 tests/ext/grid/test_directive_tree.py create mode 100644 tests/ext/grid/test_integration.py create mode 100644 tests/ext/grid/test_options.py diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index fde64e1b..9668773f 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -201,6 +201,7 @@ class PackageDocsRecord: "sphinx-fonts": "tokens", "sphinx-ux-badges": "ux", "sphinx-ux-octicons": "ux", + "sphinx-ux-grid": "ux", "sphinx-ux-autodoc-layout": "ux", "sphinx-vite-builder": "build-seo", "sphinx-gp-opengraph": "build-seo", diff --git a/docs/conf.py b/docs/conf.py index f15ac695..474c3530 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,10 @@ 0, str(project_root / "packages" / "sphinx-ux-octicons" / "src"), ) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-ux-grid" / "src"), +) sys.path.insert( 0, str(project_root / "packages" / "sphinx-autodoc-fastmcp" / "src"), diff --git a/docs/packages/index.md b/docs/packages/index.md index 3d7b70b9..45c83642 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -16,6 +16,7 @@ The rendering pipeline every autodoc extension consumes: - [`sphinx-ux-badges`](sphinx-ux-badges/index.md) — badge primitives and colour palette - [`sphinx-ux-octicons`](sphinx-ux-octicons/index.md) — curated GitHub Octicons as a Sphinx `{octicon}` role +- [`sphinx-ux-grid`](sphinx-ux-grid/index.md) — CSS-Grid `{grid}` and `{grid-item-card}` directives - [`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 diff --git a/docs/packages/sphinx-ux-grid/index.md b/docs/packages/sphinx-ux-grid/index.md new file mode 100644 index 00000000..c8ef0a5d --- /dev/null +++ b/docs/packages/sphinx-ux-grid/index.md @@ -0,0 +1,6 @@ +(sphinx-ux-grid)= + +# sphinx-ux-grid + +```{package-landing} sphinx-ux-grid +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 1c47c187..3e337ccf 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -9,6 +9,7 @@ extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style extensions/sphinx-ux-badges packages/sphinx-ux-badges extensions/sphinx-ux-octicons packages/sphinx-ux-octicons +extensions/sphinx-ux-grid packages/sphinx-ux-grid extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp extensions/sphinx-ux-autodoc-layout packages/sphinx-ux-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts diff --git a/packages/sphinx-ux-grid/README.md b/packages/sphinx-ux-grid/README.md new file mode 100644 index 00000000..98b2cbc4 --- /dev/null +++ b/packages/sphinx-ux-grid/README.md @@ -0,0 +1,53 @@ +# sphinx-ux-grid + +CSS-Grid-backed `{grid}`, `{grid-item}`, and `{grid-item-card}` directives +under the `gp-sphinx-grid` CSS namespace. + +The directives are a drop-in alternative to sphinx-design's grid markup: +they accept the same option names (`:gutter:`, `:columns:`, `:link:`, +`:link-type:`, `:shadow:`, `:img-top:`, …) and the same `^^^`/`+++` +header/footer split inside `{grid-item-card}`. The layout is plain CSS +Grid; per-directive overrides flow through CSS custom properties inlined +on each container's `style` attribute, so no Bootstrap-derived float +classes are emitted and degradation to text/man/latex falls out of the +underlying `nodes.container` writer. + +## Install + +```console +$ pip install sphinx-ux-grid +``` + +## Usage + +Add the extension to your `conf.py`: + +```python +extensions = ["sphinx_ux_grid"] +``` + +Then write a grid in MyST or reStructuredText: + +```markdown +::::{grid} 1 2 3 4 +:gutter: 3 + +:::{grid-item-card} First +:link: page-one +:link-type: doc + +Card body content. + ++++ + +Card footer. +::: + +:::{grid-item-card} Second +:link: https://example.com +:link-type: url + +Body only. +::: +:::: +``` diff --git a/packages/sphinx-ux-grid/pyproject.toml b/packages/sphinx-ux-grid/pyproject.toml new file mode 100644 index 00000000..33e3cf69 --- /dev/null +++ b/packages/sphinx-ux-grid/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-ux-grid" +version = "0.0.1a18" +description = "CSS-Grid-backed {grid} and {grid-item-card} directives under the gp-sphinx-* namespace" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "grid", "card", "layout", "documentation"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_ux_grid"] diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/__init__.py b/packages/sphinx-ux-grid/src/sphinx_ux_grid/__init__.py new file mode 100644 index 00000000..f9b962fb --- /dev/null +++ b/packages/sphinx-ux-grid/src/sphinx_ux_grid/__init__.py @@ -0,0 +1,100 @@ +"""CSS-Grid-backed ``{grid}`` and ``{grid-item-card}`` directives. + +Provides three directives (``grid``, ``grid-item``, ``grid-item-card``) +that render plain :class:`docutils.nodes.container` trees carrying +``gp-sphinx-grid*`` CSS classes. Per-directive overrides (column counts, +gutters) are inlined as CSS custom properties on each container's +``style`` attribute, and the bundled stylesheet reads those properties +to drive a CSS Grid layout — no Bootstrap-derived float classes are +emitted, and degradation to text/man/latex falls out of the underlying +``nodes.container`` writer. + +Examples +-------- +>>> from sphinx_ux_grid import SUG, setup +>>> SUG.GRID +'gp-sphinx-grid' + +>>> callable(setup) +True +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +from sphinx.application import Sphinx + +from sphinx_ux_grid._css import SUG +from sphinx_ux_grid._directives import ( + GridDirective, + GridItemCardDirective, + GridItemDirective, +) +from sphinx_ux_grid._nodes import ( + LinkPassthrough, + _depart_passthrough, + _visit_passthrough, +) + +__all__ = [ + "SUG", + "GridDirective", + "GridItemCardDirective", + "GridItemDirective", + "LinkPassthrough", + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "0.0.1a18" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the three grid directives and the bundled stylesheet. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> from sphinx_ux_grid import setup + >>> callable(setup) + True + """ + app.add_node( + LinkPassthrough, + html=(_visit_passthrough, _depart_passthrough), + latex=(_visit_passthrough, _depart_passthrough), + text=(_visit_passthrough, _depart_passthrough), + man=(_visit_passthrough, _depart_passthrough), + texinfo=(_visit_passthrough, _depart_passthrough), + ) + app.add_directive("grid", GridDirective) + app.add_directive("grid-item", GridItemDirective) + app.add_directive("grid-item-card", GridItemCardDirective) + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_ux_grid.css") + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/_css.py b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_css.py new file mode 100644 index 00000000..cde4da2c --- /dev/null +++ b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_css.py @@ -0,0 +1,205 @@ +"""Shared CSS class name constants for sphinx_ux_grid. + +Examples +-------- +>>> SUG.GRID +'gp-sphinx-grid' + +>>> SUG.ITEM +'gp-sphinx-grid__item' + +>>> SUG.CARD +'gp-sphinx-grid-card' + +>>> SUG.CARD_BODY +'gp-sphinx-grid-card__body' + +>>> SUG.CARD_TITLE +'gp-sphinx-grid-card__title' + +>>> SUG.CARD_HEADER +'gp-sphinx-grid-card__header' + +>>> SUG.CARD_FOOTER +'gp-sphinx-grid-card__footer' + +>>> SUG.CARD_IMG_TOP +'gp-sphinx-grid-card__img-top' + +>>> SUG.CARD_IMG_BOTTOM +'gp-sphinx-grid-card__img-bottom' + +>>> SUG.OUTLINE +'gp-sphinx-grid-card--outline' + +>>> SUG.REVERSE +'gp-sphinx-grid--reverse' + +>>> SUG.shadow('md') +'gp-sphinx-grid-card--shadow-md' +""" + +from __future__ import annotations + + +class SUG: + """CSS class constants for sphinx_ux_grid under the ``gp-sphinx-`` namespace. + + Tier-B package-owned BEM classes (``gp-sphinx-grid__item``, + ``gp-sphinx-grid-card__body``, …) carry the grid's layout. Modifier + classes (``gp-sphinx-grid-card--shadow-md``) follow the axis-value + modifier convention shared with the rest of the workspace. + + Examples + -------- + >>> SUG.GRID + 'gp-sphinx-grid' + + >>> SUG.CARD_BODY + 'gp-sphinx-grid-card__body' + + >>> SUG.shadow('lg') + 'gp-sphinx-grid-card--shadow-lg' + + >>> SUG.text_align('center') + 'gp-sphinx-grid-card--text-center' + """ + + # ── Grid container & item ──────────────────────────── + GRID = "gp-sphinx-grid" + ITEM = "gp-sphinx-grid__item" + REVERSE = "gp-sphinx-grid--reverse" + OUTLINE_GRID = "gp-sphinx-grid--outline" + OUTLINE_ITEM = "gp-sphinx-grid__item--outline" + + # ── Card block ─────────────────────────────────────── + CARD = "gp-sphinx-grid-card" + CARD_BODY = "gp-sphinx-grid-card__body" + CARD_TITLE = "gp-sphinx-grid-card__title" + CARD_HEADER = "gp-sphinx-grid-card__header" + CARD_FOOTER = "gp-sphinx-grid-card__footer" + CARD_IMG_TOP = "gp-sphinx-grid-card__img-top" + CARD_IMG_BOTTOM = "gp-sphinx-grid-card__img-bottom" + CARD_IMG_BACKGROUND = "gp-sphinx-grid-card__img-background" + CARD_IMG_OVERLAY = "gp-sphinx-grid-card__overlay" + CARD_LINK = "gp-sphinx-grid-card__link" + CARD_HOVER = "gp-sphinx-grid-card--hover" + OUTLINE = "gp-sphinx-grid-card--outline" + + @staticmethod + def shadow(level: str) -> str: + """Return the shadow-modifier CSS class for ``level``. + + Parameters + ---------- + level : str + Shadow level — ``"sm"``, ``"md"``, or ``"lg"``. ``"none"`` + returns an empty string (no modifier applied). + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-grid-card--shadow-sm"``. + Empty string when ``level == "none"``. + + Examples + -------- + >>> SUG.shadow('sm') + 'gp-sphinx-grid-card--shadow-sm' + + >>> SUG.shadow('none') + '' + """ + if level == "none": + return "" + return f"gp-sphinx-grid-card--shadow-{level}" + + @staticmethod + def text_align(value: str) -> str: + """Return the text-align modifier CSS class for ``value``. + + Parameters + ---------- + value : str + Alignment value — ``"left"``, ``"right"``, ``"center"``, + or ``"justify"``. + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-grid-card--text-center"``. + + Examples + -------- + >>> SUG.text_align('left') + 'gp-sphinx-grid-card--text-left' + """ + return f"gp-sphinx-grid-card--text-{value}" + + @staticmethod + def item_direction(value: str) -> str: + """Return the ``gp-sphinx-grid__item--direction-`` modifier class. + + Parameters + ---------- + value : str + Direction value — ``"row"`` or ``"column"``. + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-grid__item--direction-row"``. + + Examples + -------- + >>> SUG.item_direction('row') + 'gp-sphinx-grid__item--direction-row' + """ + return f"gp-sphinx-grid__item--direction-{value}" + + @staticmethod + def item_align(value: str) -> str: + """Return the ``gp-sphinx-grid__item--align-`` modifier class. + + Parameters + ---------- + value : str + Alignment value — ``"start"``, ``"end"``, ``"center"``, + ``"justify"``, or ``"spaced"``. + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-grid__item--align-center"``. + + Examples + -------- + >>> SUG.item_align('center') + 'gp-sphinx-grid__item--align-center' + """ + return f"gp-sphinx-grid__item--align-{value}" + + @staticmethod + def width(value: str) -> str: + """Return the width modifier CSS class for ``value``. + + Parameters + ---------- + value : str + Width value — ``"auto"``, ``"25%"``, ``"50%"``, ``"75%"``, + or ``"100%"``. The trailing ``%`` is stripped. + + Returns + ------- + str + CSS class string, e.g. ``"gp-sphinx-grid-card--width-50"``. + + Examples + -------- + >>> SUG.width('50%') + 'gp-sphinx-grid-card--width-50' + + >>> SUG.width('auto') + 'gp-sphinx-grid-card--width-auto' + """ + return f"gp-sphinx-grid-card--width-{value.rstrip('%')}" diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/_directives.py b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_directives.py new file mode 100644 index 00000000..f69146b7 --- /dev/null +++ b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_directives.py @@ -0,0 +1,919 @@ +"""Directive implementations for ``{grid}``, ``{grid-item}``, and ``{grid-item-card}``. + +Each directive emits plain :class:`docutils.nodes.container` nodes carrying +CSS classes from :mod:`sphinx_ux_grid._css`. Breakpoint-specific values +(column counts, gutter) are inlined as CSS custom-property overrides on +the container's ``style`` attribute; the package's stylesheet reads those +custom properties to drive a CSS Grid layout — no Bootstrap-derived float +classes are emitted. + +Examples +-------- +>>> _columns_option("3") +(3, 3, 3, 3) + +>>> _columns_option("1 2 3 4") +(1, 2, 3, 4) + +>>> _gutter_to_length("3") +'1rem' +""" + +from __future__ import annotations + +import re +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives +from docutils.statemachine import StringList +from sphinx import addnodes +from sphinx.util.docutils import SphinxDirective + +from sphinx_ux_grid._css import SUG +from sphinx_ux_grid._nodes import LinkPassthrough + +_BREAKPOINTS: tuple[str, str, str, str] = ("xs", "sm", "md", "lg") + +# Bootstrap-style 0..5 spacing scale mapped to fixed CSS lengths. +_GUTTER_SCALE: dict[str, str] = { + "0": "0", + "1": "0.25rem", + "2": "0.5rem", + "3": "1rem", + "4": "1.5rem", + "5": "3rem", +} + +# Margin/padding share the gutter scale plus the ``auto`` keyword for margins. +_SPACING_SCALE: dict[str, str] = {**_GUTTER_SCALE, "auto": "auto"} + +_CSS_LENGTH_RE = re.compile( + r"^-?\d*\.?\d+(?:px|rem|em|%|vh|vw|ch|pt|cm|mm|in)$", +) + +_HEADER_RE = re.compile(r"^\^{3,}\s*$") +_FOOTER_RE = re.compile(r"^\+{3,}\s*$") + +_TEXT_ALIGN_VALUES = ("left", "right", "center", "justify") +_WIDTH_VALUES = ("auto", "25%", "50%", "75%", "100%") +_LINK_TYPE_VALUES = ("url", "any", "ref", "doc") +_SHADOW_VALUES = ("none", "sm", "md", "lg") +_CHILD_DIRECTION_VALUES = ("column", "row") +_CHILD_ALIGN_VALUES = ("start", "end", "center", "justify", "spaced") +_MARGIN_VALUES = ("auto", "0", "1", "2", "3", "4", "5") +_PADDING_VALUES = ("0", "1", "2", "3", "4", "5") + + +def _columns_option(argument: str | None) -> tuple[int, int, int, int]: + """Parse a column-count spec into a four-int tuple ``(xs, sm, md, lg)``. + + Accepts either a single integer ``"N"`` (applied to all breakpoints) + or four space-separated integers ``"xs sm md lg"``. Each value must + lie in ``[1..12]``. + + Parameters + ---------- + argument : str or None + The directive argument string. + + Returns + ------- + tuple[int, int, int, int] + Four column counts in ``(xs, sm, md, lg)`` order. + + Raises + ------ + ValueError + If ``argument`` is ``None``, empty, contains a non-integer, has + the wrong arity, or a value outside ``[1..12]``. + + Examples + -------- + >>> _columns_option("3") + (3, 3, 3, 3) + + >>> _columns_option("1 2 3 4") + (1, 2, 3, 4) + """ + msg = "argument must be 1 or 4 space-separated integers in [1..12] (xs sm md lg)" + if argument is None: + raise ValueError(msg) + parts = argument.strip().split() + if not parts: + raise ValueError(msg) + if len(parts) == 1: + parts = parts * 4 + if len(parts) != 4: + raise ValueError(msg) + try: + values = tuple(int(p) for p in parts) + except ValueError as exc: + raise ValueError(msg) from exc + for value in values: + if not (1 <= value <= 12): + raise ValueError(msg) + return (values[0], values[1], values[2], values[3]) + + +def _item_columns_option(argument: str | None) -> tuple[int, int, int, int]: + """Parse a ``:columns:`` value on ``{grid-item}`` / ``{grid-item-card}``. + + Same shape as :func:`_columns_option` — a single int or four ints in + ``[1..12]`` mapping to ``(xs, sm, md, lg)`` column spans. + + Parameters + ---------- + argument : str or None + The option value. + + Returns + ------- + tuple[int, int, int, int] + Column spans in ``(xs, sm, md, lg)`` order. + + Examples + -------- + >>> _item_columns_option("6") + (6, 6, 6, 6) + """ + return _columns_option(argument) + + +def _gutter_to_length(argument: str | None) -> str: + """Map a ``:gutter:`` value to a concrete CSS length. + + Integers ``0..5`` map to a fixed scale (``0``, ``0.25rem``, ``0.5rem``, + ``1rem``, ``1.5rem``, ``3rem``). Any other token shaped like a CSS + length (``1rem``, ``16px``, ``50%``) is passed through unchanged. + When the argument has multiple whitespace-separated values, the + first is used — per-breakpoint gutters collapse to a single grid gap. + + Parameters + ---------- + argument : str or None + The gutter spec. + + Returns + ------- + str + A concrete CSS length suitable for ``--gp-sphinx-grid-gutter``. + + Raises + ------ + ValueError + If ``argument`` is ``None``, empty, or neither a 0..5 scale int + nor a recognized CSS length. + + Examples + -------- + >>> _gutter_to_length("3") + '1rem' + + >>> _gutter_to_length("2rem") + '2rem' + """ + msg_required = "gutter argument required" + if argument is None: + raise ValueError(msg_required) + parts = argument.strip().split() + if not parts: + raise ValueError(msg_required) + head = parts[0] + if head in _GUTTER_SCALE: + return _GUTTER_SCALE[head] + if _CSS_LENGTH_RE.match(head): + return head + msg = f"gutter value {head!r} is not a 0..5 scale int or a CSS length" + raise ValueError(msg) + + +def _spacing_option(argument: str | None, *, allowed: tuple[str, ...]) -> list[str]: + """Validate a margin/padding spec — one (all) or four (xs sm md lg) values. + + Examples + -------- + >>> _spacing_option("3", allowed=_PADDING_VALUES) + ['3'] + + >>> _spacing_option("1 2 3 4", allowed=_PADDING_VALUES) + ['1', '2', '3', '4'] + + >>> _spacing_option("auto", allowed=_MARGIN_VALUES) + ['auto'] + + >>> _spacing_option("9", allowed=_PADDING_VALUES) + Traceback (most recent call last): + ... + ValueError: '9' not in ('0', '1', '2', '3', '4', '5') + """ + msg_required = "argument required" + if argument is None: + raise ValueError(msg_required) + values = argument.split() + for value in values: + if value not in allowed: + msg = f"{value!r} not in {allowed}" + raise ValueError(msg) + if len(values) not in (1, 4): + msg_arity = "margin/padding must be 1 (all) or 4 (xs sm md lg) values" + raise ValueError(msg_arity) + return values + + +def _margin_option(argument: str | None) -> list[str]: + """Validate the ``:margin:`` option (0..5 or 'auto'). + + Examples + -------- + >>> _margin_option("3") + ['3'] + + >>> _margin_option("auto auto 0 0") + ['auto', 'auto', '0', '0'] + """ + return _spacing_option(argument, allowed=_MARGIN_VALUES) + + +def _padding_option(argument: str | None) -> list[str]: + """Validate the ``:padding:`` option (0..5). + + Examples + -------- + >>> _padding_option("2") + ['2'] + + >>> _padding_option("0 1 2 3") + ['0', '1', '2', '3'] + """ + return _spacing_option(argument, allowed=_PADDING_VALUES) + + +def _spacing_to_length(value: str) -> str: + """Map a single margin/padding token to a CSS length (or ``auto``). + + Examples + -------- + >>> _spacing_to_length("3") + '1rem' + + >>> _spacing_to_length("auto") + 'auto' + """ + return _SPACING_SCALE[value] + + +def _spacing_to_style(prefix: str, values: list[str]) -> str: + """Render a margin/padding spec as ``---: `` pairs. + + ``values`` is either a 1-element list (applied to every breakpoint) or a + 4-element list in ``(xs, sm, md, lg)`` order. ``prefix`` is the CSS + custom-property root, e.g. ``"--gp-sphinx-grid-margin"``. + + Examples + -------- + A single token expands to all four breakpoints (``NORMALIZE_WHITESPACE`` + is enabled, so the wrapped expected output below still matches the + single-line return value): + + >>> _spacing_to_style("--gp-sphinx-grid-margin", ["3"]) + '--gp-sphinx-grid-margin-xs: 1rem; + --gp-sphinx-grid-margin-sm: 1rem; + --gp-sphinx-grid-margin-md: 1rem; + --gp-sphinx-grid-margin-lg: 1rem' + + A four-token list maps positionally to ``(xs, sm, md, lg)``: + + >>> _spacing_to_style("--gp-sphinx-grid-margin", ["1", "2", "3", "4"]) + '--gp-sphinx-grid-margin-xs: 0.25rem; + --gp-sphinx-grid-margin-sm: 0.5rem; + --gp-sphinx-grid-margin-md: 1rem; + --gp-sphinx-grid-margin-lg: 1.5rem' + + The ``auto`` keyword passes through (margins only): + + >>> _spacing_to_style("--gp-sphinx-grid-margin", ["auto"]) + '--gp-sphinx-grid-margin-xs: auto; + --gp-sphinx-grid-margin-sm: auto; + --gp-sphinx-grid-margin-md: auto; + --gp-sphinx-grid-margin-lg: auto' + """ + per_breakpoint = [values[0]] * 4 if len(values) == 1 else values + return _style_from_pairs( + (f"{prefix}-{bp}", _spacing_to_length(token)) + for bp, token in zip(_BREAKPOINTS, per_breakpoint, strict=True) + ) + + +def _choice(values: tuple[str, ...]) -> t.Callable[[str | None], str]: + """Build a ``directives.choice``-style validator from a fixed value set.""" + + def _validator(argument: str | None) -> str: + return directives.choice(argument or "", list(values)) + + return _validator + + +def _style_from_pairs(pairs: t.Iterable[tuple[str, str]]) -> str: + """Render an inline ``style`` string from ``(property, value)`` pairs. + + Examples + -------- + >>> _style_from_pairs([("a", "1"), ("b", "2")]) + 'a: 1; b: 2' + + >>> _style_from_pairs([]) + '' + """ + return "; ".join(f"{name}: {value}" for name, value in pairs) + + +def _columns_to_style(columns: tuple[int, int, int, int]) -> str: + """Render a column-count tuple as a ``--gp-sphinx-grid-cols-*`` style string. + + Parameters + ---------- + columns : tuple[int, int, int, int] + Column counts in ``(xs, sm, md, lg)`` order. + + Returns + ------- + str + Inline ``style`` value, e.g. + ``"--gp-sphinx-grid-cols-xs: 1; --gp-sphinx-grid-cols-sm: 2; …"``. + + Examples + -------- + >>> _columns_to_style((1, 2, 3, 4)).startswith("--gp-sphinx-grid-cols-xs: 1") + True + + >>> _columns_to_style((1, 2, 3, 4)).endswith("--gp-sphinx-grid-cols-lg: 4") + True + """ + return _style_from_pairs( + (f"--gp-sphinx-grid-cols-{bp}", str(count)) + for bp, count in zip(_BREAKPOINTS, columns, strict=True) + ) + + +def _item_span_to_style(columns: tuple[int, int, int, int]) -> str: + """Render an item-span tuple as a ``--gp-sphinx-grid-item-span-*`` style string. + + Parameters + ---------- + columns : tuple[int, int, int, int] + Column-span counts in ``(xs, sm, md, lg)`` order. + + Returns + ------- + str + Inline ``style`` value driving per-breakpoint ``grid-column: span N``. + + Examples + -------- + >>> _item_span_to_style((6, 6, 4, 3)).startswith("--gp-sphinx-grid-item-span-xs: 6") + True + + >>> "--gp-sphinx-grid-item-span-lg: 3" in _item_span_to_style((6, 6, 4, 3)) + True + """ + return _style_from_pairs( + (f"--gp-sphinx-grid-item-span-{bp}", str(count)) + for bp, count in zip(_BREAKPOINTS, columns, strict=True) + ) + + +def _merge_styles(*parts: str) -> str: + """Join non-empty inline ``style`` fragments with ``; `` separators. + + Examples + -------- + >>> _merge_styles("a: 1", "", "b: 2") + 'a: 1; b: 2' + + >>> _merge_styles("", "") + '' + """ + return "; ".join(p for p in parts if p) + + +class _CardContent(t.NamedTuple): + """Result of splitting a card body into optional header/body/footer.""" + + body_offset: int + body: StringList + header_offset: int | None + header: StringList | None + footer_offset: int | None + footer: StringList | None + + +def _split_card_content( + content: StringList, + offset: int, +) -> _CardContent: + """Split ``content`` on ``^^^`` (header) and ``+++`` (footer) markers. + + Mirrors sphinx-design's splitter shape — the first ``^^^`` separates + a header block from the body, and the last ``+++`` separates a footer + block from the body. + + Parameters + ---------- + content : StringList + The directive content lines. + offset : int + The starting line offset (``self.content_offset``). + + Returns + ------- + _CardContent + Body, header, and footer slices with their line offsets. + + Examples + -------- + >>> from docutils.statemachine import StringList + >>> lines = StringList(["head", "^^^", "body", "+++", "foot"], source="") + >>> result = _split_card_content(lines, offset=0) + >>> list(result.header), list(result.body), list(result.footer) + (['head'], ['body'], ['foot']) + + >>> bare = StringList(["only body"], source="") + >>> bare_result = _split_card_content(bare, offset=0) + >>> bare_result.header is None, list(bare_result.body), bare_result.footer is None + (True, ['only body'], True) + """ + header_index: int | None = None + footer_index: int | None = None + for index, line in enumerate(content): + if header_index is None and _HEADER_RE.match(line): + header_index = index + if _FOOTER_RE.match(line): + footer_index = index + + body_offset = offset + header_offset: int | None = None + header: StringList | None = None + footer_offset: int | None = None + footer: StringList | None = None + + if header_index is not None: + header_offset = offset + header = content[:header_index] + body_offset = offset + header_index + 1 + body_start = (header_index + 1) if header_index is not None else 0 + if footer_index is not None: + footer_offset = offset + footer_index + 1 + footer = content[footer_index + 1 :] + body = content[body_start:footer_index] + else: + body = content[body_start:] + return _CardContent( + body_offset=body_offset, + body=body, + header_offset=header_offset, + header=header, + footer_offset=footer_offset, + footer=footer, + ) + + +def _make_container( + classes: t.Sequence[str], + *, + style: str = "", + **attributes: t.Any, +) -> nodes.container: + """Build a ``nodes.container`` with class/style/extra attributes. + + Examples + -------- + >>> node = _make_container(["a", "", "b"], style="x: 1") + >>> node["classes"], node["style"], node["is_div"] + (['a', 'b'], 'x: 1', True) + + >>> bare = _make_container(["c"]) + >>> "style" in bare.attributes + False + """ + class_list = [c for c in classes if c] + attrs: dict[str, t.Any] = {"is_div": True, "classes": class_list, **attributes} + if style: + attrs["style"] = style + return nodes.container("", **attrs) + + +class GridDirective(SphinxDirective): + """The ``{grid}`` directive — container for ``{grid-item*}`` children. + + Argument (optional): a column-count spec — ``"N"`` or ``"xs sm md lg"``. + Defaults to one column at every breakpoint when omitted. + + Examples + -------- + >>> GridDirective.has_content + True + """ + + has_content = True + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + "gutter": directives.unchanged, + "margin": _margin_option, + "padding": _padding_option, + "outline": directives.flag, + "reverse": directives.flag, + "class-container": directives.class_option, + "class-row": directives.class_option, + } + + def run(self) -> list[nodes.Node]: + """Build the grid container and recurse into the directive body. + + Examples + -------- + >>> GridDirective.run.__qualname__ + 'GridDirective.run' + """ + try: + columns = ( + _columns_option(self.arguments[0]) if self.arguments else (1, 1, 1, 1) + ) + except ValueError as exc: + msg = f"Invalid {{grid}} argument: {exc}" + raise self.error(msg) from exc + try: + gutter = ( + _gutter_to_length(self.options["gutter"]) + if "gutter" in self.options + else None + ) + except ValueError as exc: + msg = f"Invalid :gutter: option: {exc}" + raise self.error(msg) from exc + + column_style = _columns_to_style(columns) + gutter_style = ( + f"--gp-sphinx-grid-gutter: {gutter}" if gutter is not None else "" + ) + margin_style = ( + _spacing_to_style("--gp-sphinx-grid-margin", self.options["margin"]) + if "margin" in self.options + else "" + ) + padding_style = ( + _spacing_to_style("--gp-sphinx-grid-padding", self.options["padding"]) + if "padding" in self.options + else "" + ) + + classes: list[str] = [SUG.GRID] + if "outline" in self.options: + classes.append(SUG.OUTLINE_GRID) + if "reverse" in self.options: + classes.append(SUG.REVERSE) + classes.extend(self.options.get("class-container", [])) + classes.extend(self.options.get("class-row", [])) + + container = _make_container( + classes, + style=_merge_styles( + column_style, gutter_style, margin_style, padding_style + ), + ) + self.set_source_info(container) + self.state.nested_parse(self.content, self.content_offset, container) + return [container] + + +class GridItemDirective(SphinxDirective): + """The ``{grid-item}`` directive — a span inside a ``{grid}``. + + Examples + -------- + >>> GridItemDirective.has_content + True + """ + + has_content = True + required_arguments = 0 + optional_arguments = 0 + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + "columns": _item_columns_option, + "child-direction": _choice(_CHILD_DIRECTION_VALUES), + "child-align": _choice(_CHILD_ALIGN_VALUES), + "margin": _margin_option, + "padding": _padding_option, + "outline": directives.flag, + "class": directives.class_option, + } + + def run(self) -> list[nodes.Node]: + """Build the grid-item container. + + Examples + -------- + >>> GridItemDirective.run.__qualname__ + 'GridItemDirective.run' + """ + columns = self.options.get("columns") + span_style = _item_span_to_style(columns) if columns else "" + margin_style = ( + _spacing_to_style("--gp-sphinx-grid-margin", self.options["margin"]) + if "margin" in self.options + else "" + ) + padding_style = ( + _spacing_to_style("--gp-sphinx-grid-padding", self.options["padding"]) + if "padding" in self.options + else "" + ) + + classes: list[str] = [SUG.ITEM] + if "outline" in self.options: + classes.append(SUG.OUTLINE_ITEM) + if "child-direction" in self.options: + classes.append(SUG.item_direction(self.options["child-direction"])) + if "child-align" in self.options: + classes.append(SUG.item_align(self.options["child-align"])) + classes.extend(self.options.get("class", [])) + + container = _make_container( + classes, + style=_merge_styles(span_style, margin_style, padding_style), + ) + self.set_source_info(container) + self.state.nested_parse(self.content, self.content_offset, container) + return [container] + + +class GridItemCardDirective(SphinxDirective): + """The ``{grid-item-card}`` directive — a card inside a grid item. + + Argument (optional): the card title. The body may include + ``^^^``-delimited header and ``+++``-delimited footer regions. + + Examples + -------- + >>> GridItemCardDirective.has_content + True + """ + + has_content = True + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + # grid-item options + "columns": _item_columns_option, + "child-direction": _choice(_CHILD_DIRECTION_VALUES), + "child-align": _choice(_CHILD_ALIGN_VALUES), + "margin": _margin_option, + "padding": _padding_option, + "outline": directives.flag, + "class-item": directives.class_option, + # card options + "width": _choice(_WIDTH_VALUES), + "text-align": _choice(_TEXT_ALIGN_VALUES), + "img-top": directives.uri, + "img-bottom": directives.uri, + "img-background": directives.uri, + "img-alt": directives.unchanged, + "link": directives.uri, + "link-type": _choice(_LINK_TYPE_VALUES), + "link-alt": directives.unchanged, + "shadow": _choice(_SHADOW_VALUES), + "class-card": directives.class_option, + "class-body": directives.class_option, + "class-title": directives.class_option, + "class-header": directives.class_option, + "class-footer": directives.class_option, + "class-img-top": directives.class_option, + "class-img-bottom": directives.class_option, + } + + def run(self) -> list[nodes.Node]: + """Build the grid-item wrapper, the card, and any header/footer. + + Examples + -------- + >>> GridItemCardDirective.run.__qualname__ + 'GridItemCardDirective.run' + """ + item = self._build_item_wrapper() + card_or_link = self._build_card() + item += card_or_link + return [item] + + def _build_item_wrapper(self) -> nodes.container: + columns = self.options.get("columns") + span_style = _item_span_to_style(columns) if columns else "" + + classes: list[str] = [SUG.ITEM] + if "outline" in self.options: + classes.append(SUG.OUTLINE_ITEM) + if "child-direction" in self.options: + classes.append(SUG.item_direction(self.options["child-direction"])) + if "child-align" in self.options: + classes.append(SUG.item_align(self.options["child-align"])) + classes.extend(self.options.get("class-item", [])) + + item = _make_container(classes, style=span_style) + self.set_source_info(item) + return item + + def _build_card(self) -> nodes.Node: + card = self._build_card_container() + self._populate_card(card) + link = self.options.get("link") + if link is not None: + card.append(self._build_link(link)) + return card + + def _build_card_container(self) -> nodes.container: + classes: list[str] = [SUG.CARD] + shadow_class = SUG.shadow(self.options.get("shadow", "sm")) + if shadow_class: + classes.append(shadow_class) + if "outline" in self.options: + classes.append(SUG.OUTLINE) + if "text-align" in self.options: + classes.append(SUG.text_align(self.options["text-align"])) + if "width" in self.options: + classes.append(SUG.width(self.options["width"])) + if "link" in self.options: + classes.append(SUG.CARD_HOVER) + classes.extend(self.options.get("class-card", [])) + + margin_style = ( + _spacing_to_style("--gp-sphinx-grid-margin", self.options["margin"]) + if "margin" in self.options + else "" + ) + padding_style = ( + _spacing_to_style("--gp-sphinx-grid-padding", self.options["padding"]) + if "padding" in self.options + else "" + ) + + card = _make_container( + classes, style=_merge_styles(margin_style, padding_style) + ) + self.set_source_info(card) + return card + + def _populate_card(self, card: nodes.container) -> None: + components = _split_card_content(self.content, self.content_offset) + img_alt = self.options.get("img-alt") or "" + container: nodes.Element = card + + if "img-background" in self.options: + card.append( + nodes.image( + "", + uri=self.options["img-background"], + alt=img_alt, + classes=[SUG.CARD_IMG_BACKGROUND], + ), + ) + overlay = _make_container([SUG.CARD_IMG_OVERLAY]) + self.set_source_info(overlay) + card.append(overlay) + container = overlay + + if "img-top" in self.options: + container.append( + nodes.image( + "", + uri=self.options["img-top"], + alt=img_alt, + classes=[ + SUG.CARD_IMG_TOP, + *self.options.get("class-img-top", []), + ], + ), + ) + + if components.header is not None and components.header_offset is not None: + container.append( + self._build_section( + "header", + components.header, + components.header_offset, + ), + ) + + body = self._build_section("body", components.body, components.body_offset) + if self.arguments: + body.insert(0, self._build_title(self.arguments[0])) + container.append(body) + + if components.footer is not None and components.footer_offset is not None: + container.append( + self._build_section( + "footer", + components.footer, + components.footer_offset, + ), + ) + + if "img-bottom" in self.options: + container.append( + nodes.image( + "", + uri=self.options["img-bottom"], + alt=img_alt, + classes=[ + SUG.CARD_IMG_BOTTOM, + *self.options.get("class-img-bottom", []), + ], + ), + ) + + _SECTION_CLASS: t.ClassVar[dict[str, str]] = { + "body": SUG.CARD_BODY, + "header": SUG.CARD_HEADER, + "footer": SUG.CARD_FOOTER, + } + + def _build_section( + self, + name: str, + content: StringList, + offset: int, + ) -> nodes.container: + classes = [ + self._SECTION_CLASS[name], + *self.options.get(f"class-{name}", []), + ] + section = _make_container(classes) + self.set_source_info(section) + self.state.nested_parse(content, offset, section) + return section + + def _build_title(self, raw: str) -> nodes.container: + classes = [ + SUG.CARD_TITLE, + *self.options.get("class-title", []), + ] + title = _make_container(classes) + text_nodes, _ = self.state.inline_text(raw, self.lineno) + title.extend(text_nodes) + self.set_source_info(title) + return title + + def _build_link(self, target: str) -> nodes.Element: + """Build a stretched-link node that overlays the parent card. + + The link is appended as the last child of the card; CSS positions + it ``inset: 0`` so the entire card surface becomes clickable. + For ``:link-type: url`` the wrapper is a :class:`nodes.reference`; + for ``ref`` / ``doc`` / ``any`` it is an + :class:`addnodes.pending_xref` that Sphinx resolves during the + post-transform pass. + + The xref / reference is wrapped in an ``_LinkPassthrough`` (a + thin :class:`nodes.TextElement` subclass) so the HTML5 writer + satisfies its ``len(node) == 1 and isinstance(node[0], image)`` + assertion path for non-image references. + """ + link_type = self.options.get("link-type", "any") + alt = self.options.get("link-alt") or target + classes = [SUG.CARD_LINK] + inline_label = nodes.inline(alt, alt) + + link: nodes.Element + if link_type == "url": + link = nodes.reference( + alt, + "", + inline_label, + refuri=target, + classes=classes, + ) + else: + # ``self.env`` is unavailable outside a Sphinx build (e.g. + # when the directive runs under a bare docutils parser in + # unit tests); fall back to an empty docname so the xref + # still has the shape the resolver expects. + try: + refdoc = self.env.docname + except AttributeError: + refdoc = "" + link = addnodes.pending_xref( + alt, + inline_label, + classes=classes, + reftarget=target, + refdoc=refdoc, + refdomain="" if link_type == "any" else "std", + reftype=link_type, + refexplicit="link-alt" in self.options, + refwarn=True, + ) + self.set_source_info(link) + wrapper = LinkPassthrough() + wrapper.append(link) + return wrapper diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/_nodes.py b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_nodes.py new file mode 100644 index 00000000..07ac7423 --- /dev/null +++ b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_nodes.py @@ -0,0 +1,54 @@ +"""Custom node types for sphinx_ux_grid. + +The only custom node :class:`LinkPassthrough` is a thin wrapper around +:class:`docutils.nodes.TextElement` that exists so a card's stretched +link node has a parent the HTML writer recognizes as a text element. +It emits no markup of its own — its children render directly. + +Examples +-------- +>>> from sphinx_ux_grid._nodes import LinkPassthrough +>>> issubclass(LinkPassthrough, object) +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + + +class LinkPassthrough(nodes.TextElement): + """Inline text-element carrier for a card's stretched-link node. + + Sphinx's HTML5 writer assumes a :class:`nodes.reference` or + :class:`sphinx.addnodes.pending_xref` lives inside a + :class:`nodes.TextElement`; wrapping the stretched link in this + passthrough lets such a node sit as a card child without triggering + the writer's image-only assertion path. + + The visitors registered for this node emit nothing — children render + in place. + + Examples + -------- + >>> from sphinx_ux_grid._nodes import LinkPassthrough + >>> issubclass(LinkPassthrough, nodes.TextElement) + True + """ + + +def _visit_passthrough(self: t.Any, node: nodes.Node) -> None: + """Emit nothing for :class:`LinkPassthrough` — children render in place.""" + + +def _depart_passthrough(self: t.Any, node: nodes.Node) -> None: + """Emit nothing on departure — children have already been rendered.""" + + +__all__ = [ + "LinkPassthrough", + "_depart_passthrough", + "_visit_passthrough", +] diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/_static/css/sphinx_ux_grid.css b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_static/css/sphinx_ux_grid.css new file mode 100644 index 00000000..9d9c237f --- /dev/null +++ b/packages/sphinx-ux-grid/src/sphinx_ux_grid/_static/css/sphinx_ux_grid.css @@ -0,0 +1,378 @@ +/* sphinx_ux_grid — CSS-Grid layout for {grid} and {grid-item-card}. + * + * Selectors live under the ``gp-sphinx-grid*`` namespace and stay inside + * ``@layer gp-sphinx`` so precedence is declarative against Furo's own + * layered styles. Per-directive overrides arrive as inline CSS custom + * properties on the container's ``style`` attribute — the rules below + * read those properties with sensible fallbacks so a grid without + * explicit options still renders. + */ +@layer gp-sphinx { + .gp-sphinx-grid { + display: grid; + gap: var(--gp-sphinx-grid-gutter, 1rem); + grid-template-columns: repeat( + var(--gp-sphinx-grid-cols-xs, 1), + minmax(0, 1fr) + ); + margin: var(--gp-sphinx-grid-margin-xs, 0 0 1rem 0); + padding: var(--gp-sphinx-grid-padding-xs, 0); + } + + @media (min-width: 576px) { + .gp-sphinx-grid { + grid-template-columns: repeat( + var(--gp-sphinx-grid-cols-sm, var(--gp-sphinx-grid-cols-xs, 1)), + minmax(0, 1fr) + ); + margin: var( + --gp-sphinx-grid-margin-sm, + var(--gp-sphinx-grid-margin-xs, 0 0 1rem 0) + ); + padding: var( + --gp-sphinx-grid-padding-sm, + var(--gp-sphinx-grid-padding-xs, 0) + ); + } + } + + @media (min-width: 768px) { + .gp-sphinx-grid { + grid-template-columns: repeat( + var(--gp-sphinx-grid-cols-md, var(--gp-sphinx-grid-cols-sm, 1)), + minmax(0, 1fr) + ); + margin: var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0 0 1rem 0)) + ); + padding: var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ); + } + } + + @media (min-width: 992px) { + .gp-sphinx-grid { + grid-template-columns: repeat( + var(--gp-sphinx-grid-cols-lg, var(--gp-sphinx-grid-cols-md, 1)), + minmax(0, 1fr) + ); + margin: var( + --gp-sphinx-grid-margin-lg, + var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0 0 1rem 0)) + ) + ); + padding: var( + --gp-sphinx-grid-padding-lg, + var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ) + ); + } + } + + .gp-sphinx-grid--reverse { + direction: rtl; + } + + .gp-sphinx-grid--reverse > * { + direction: ltr; + } + + .gp-sphinx-grid--outline { + padding: 0.5rem; + border: 1px solid var(--color-foreground-border, currentColor); + border-radius: 0.25rem; + } + + /* ── Grid item ──────────────────────────────────────── */ + .gp-sphinx-grid__item { + display: flex; + flex-direction: column; + min-width: 0; + grid-column: span var(--gp-sphinx-grid-item-span-xs, 1); + margin: var(--gp-sphinx-grid-margin-xs, 0); + padding: var(--gp-sphinx-grid-padding-xs, 0); + } + + @media (min-width: 576px) { + .gp-sphinx-grid__item { + grid-column: span + var( + --gp-sphinx-grid-item-span-sm, + var(--gp-sphinx-grid-item-span-xs, 1) + ); + margin: var( + --gp-sphinx-grid-margin-sm, + var(--gp-sphinx-grid-margin-xs, 0) + ); + padding: var( + --gp-sphinx-grid-padding-sm, + var(--gp-sphinx-grid-padding-xs, 0) + ); + } + } + + @media (min-width: 768px) { + .gp-sphinx-grid__item { + grid-column: span + var( + --gp-sphinx-grid-item-span-md, + var(--gp-sphinx-grid-item-span-sm, 1) + ); + margin: var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0)) + ); + padding: var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ); + } + } + + @media (min-width: 992px) { + .gp-sphinx-grid__item { + grid-column: span + var( + --gp-sphinx-grid-item-span-lg, + var(--gp-sphinx-grid-item-span-md, 1) + ); + margin: var( + --gp-sphinx-grid-margin-lg, + var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0)) + ) + ); + padding: var( + --gp-sphinx-grid-padding-lg, + var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ) + ); + } + } + + .gp-sphinx-grid__item--direction-row { + flex-direction: row; + } + + .gp-sphinx-grid__item--direction-column { + flex-direction: column; + } + + .gp-sphinx-grid__item--align-start { + justify-content: flex-start; + } + + .gp-sphinx-grid__item--align-end { + justify-content: flex-end; + } + + .gp-sphinx-grid__item--align-center { + justify-content: center; + } + + .gp-sphinx-grid__item--align-justify { + justify-content: space-between; + } + + .gp-sphinx-grid__item--align-spaced { + justify-content: space-around; + } + + .gp-sphinx-grid__item--outline { + border: 1px solid var(--color-foreground-border, currentColor); + border-radius: 0.25rem; + } + + /* ── Grid item card ─────────────────────────────────── */ + .gp-sphinx-grid-card { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + background: var(--color-background-secondary, transparent); + border: 1px solid var(--color-background-border, currentColor); + border-radius: 0.375rem; + overflow: hidden; + margin: var(--gp-sphinx-grid-margin-xs, 0); + padding: var(--gp-sphinx-grid-padding-xs, 0); + } + + @media (min-width: 576px) { + .gp-sphinx-grid-card { + margin: var( + --gp-sphinx-grid-margin-sm, + var(--gp-sphinx-grid-margin-xs, 0) + ); + padding: var( + --gp-sphinx-grid-padding-sm, + var(--gp-sphinx-grid-padding-xs, 0) + ); + } + } + + @media (min-width: 768px) { + .gp-sphinx-grid-card { + margin: var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0)) + ); + padding: var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ); + } + } + + @media (min-width: 992px) { + .gp-sphinx-grid-card { + margin: var( + --gp-sphinx-grid-margin-lg, + var( + --gp-sphinx-grid-margin-md, + var(--gp-sphinx-grid-margin-sm, var(--gp-sphinx-grid-margin-xs, 0)) + ) + ); + padding: var( + --gp-sphinx-grid-padding-lg, + var( + --gp-sphinx-grid-padding-md, + var(--gp-sphinx-grid-padding-sm, var(--gp-sphinx-grid-padding-xs, 0)) + ) + ); + } + } + + .gp-sphinx-grid-card--outline { + border-width: 2px; + } + + .gp-sphinx-grid-card--shadow-sm { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + } + + .gp-sphinx-grid-card--shadow-md { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08); + } + + .gp-sphinx-grid-card--shadow-lg { + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); + } + + .gp-sphinx-grid-card--hover { + transition: + transform 120ms ease-out, + box-shadow 120ms ease-out; + } + + .gp-sphinx-grid-card--hover:hover { + transform: translateY(-2px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12); + } + + .gp-sphinx-grid-card--text-left { + text-align: left; + } + + .gp-sphinx-grid-card--text-right { + text-align: right; + } + + .gp-sphinx-grid-card--text-center { + text-align: center; + } + + .gp-sphinx-grid-card--text-justify { + text-align: justify; + } + + .gp-sphinx-grid-card--width-auto { + width: auto; + } + + .gp-sphinx-grid-card--width-25 { + width: 25%; + } + + .gp-sphinx-grid-card--width-50 { + width: 50%; + } + + .gp-sphinx-grid-card--width-75 { + width: 75%; + } + + .gp-sphinx-grid-card--width-100 { + width: 100%; + } + + .gp-sphinx-grid-card__header, + .gp-sphinx-grid-card__footer { + padding: 0.5rem 1rem; + background: var(--color-background-hover, transparent); + border-bottom: 1px solid var(--color-background-border, currentColor); + } + + .gp-sphinx-grid-card__footer { + border-bottom: 0; + border-top: 1px solid var(--color-background-border, currentColor); + margin-top: auto; + } + + .gp-sphinx-grid-card__body { + flex: 1 1 auto; + padding: 1rem; + } + + .gp-sphinx-grid-card__title { + font-weight: 600; + margin-block-end: 0.5rem; + } + + .gp-sphinx-grid-card__img-top, + .gp-sphinx-grid-card__img-bottom { + display: block; + width: 100%; + height: auto; + } + + .gp-sphinx-grid-card__img-background { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; + } + + .gp-sphinx-grid-card__overlay { + position: relative; + z-index: 1; + background: rgba(0, 0, 0, 0.4); + color: #fff; + } + + .gp-sphinx-grid-card__link { + position: absolute; + inset: 0; + z-index: 1; + text-indent: -9999px; + overflow: hidden; + } + + .gp-sphinx-grid-card .gp-sphinx-grid-card__link::after { + content: ""; + position: absolute; + inset: 0; + } +} diff --git a/packages/sphinx-ux-grid/src/sphinx_ux_grid/py.typed b/packages/sphinx-ux-grid/src/sphinx_ux_grid/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 3eb89dab..ef1ea328 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ sphinx-autodoc-fastmcp = { workspace = true } sphinx-autodoc-typehints-gp = { workspace = true } sphinx-ux-badges = { workspace = true } sphinx-ux-octicons = { workspace = true } +sphinx-ux-grid = { workspace = true } sphinx-ux-autodoc-layout = { workspace = true } sphinx-gp-opengraph = { workspace = true } sphinx-gp-sitemap = { workspace = true } @@ -46,6 +47,7 @@ dev = [ "sphinx-autodoc-fastmcp", "sphinx-ux-badges", "sphinx-ux-octicons", + "sphinx-ux-grid", "sphinx-ux-autodoc-layout", "sphinx-gp-opengraph", "sphinx-gp-sitemap", @@ -170,6 +172,7 @@ known-first-party = [ "sphinx_autodoc_typehints_gp", "sphinx_ux_badges", "sphinx_ux_octicons", + "sphinx_ux_grid", "sphinx_ux_autodoc_layout", "sphinx_gp_opengraph", "sphinx_gp_sitemap", @@ -221,6 +224,7 @@ testpaths = [ "packages/sphinx-ux-autodoc-layout/src", "packages/sphinx-ux-badges/src", "packages/sphinx-ux-octicons/src", + "packages/sphinx-ux-grid/src", "packages/sphinx-gp-opengraph/src", "packages/sphinx-gp-sitemap/src", "packages/sphinx-vite-builder/src", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index d9b49d82..d4b98ddc 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -681,6 +681,29 @@ def smoke_sphinx_ux_octicons(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_ux_grid(dist_dir: pathlib.Path, version: str) -> None: + """Verify the ux-grid extension installs, imports, and exposes the directives.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_ux_grid; " + "from sphinx_ux_grid import (GridDirective, GridItemCardDirective, " + "GridItemDirective, SUG, setup); " + "assert callable(setup); " + "assert SUG.GRID == 'gp-sphinx-grid'; " + "assert GridDirective.has_content; " + "assert GridItemCardDirective.has_content; " + "assert GridItemDirective.has_content" + ), + ) + + def smoke_sphinx_ux_autodoc_layout(dist_dir: pathlib.Path, version: str) -> None: """Verify the ux-autodoc-layout extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -869,6 +892,7 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "sphinx-autodoc-api-style": smoke_sphinx_autodoc_api_style, "sphinx-ux-badges": smoke_sphinx_ux_badges, "sphinx-ux-octicons": smoke_sphinx_ux_octicons, + "sphinx-ux-grid": smoke_sphinx_ux_grid, "sphinx-autodoc-docutils": smoke_sphinx_autodoc_docutils, "sphinx-autodoc-fastmcp": smoke_sphinx_autodoc_fastmcp, "sphinx-ux-autodoc-layout": smoke_sphinx_ux_autodoc_layout, diff --git a/tests/ext/grid/__init__.py b/tests/ext/grid/__init__.py new file mode 100644 index 00000000..26ab4a5b --- /dev/null +++ b/tests/ext/grid/__init__.py @@ -0,0 +1,3 @@ +"""Tests for sphinx-ux-grid.""" + +from __future__ import annotations diff --git a/tests/ext/grid/test_directive_tree.py b/tests/ext/grid/test_directive_tree.py new file mode 100644 index 00000000..5d02a8e9 --- /dev/null +++ b/tests/ext/grid/test_directive_tree.py @@ -0,0 +1,429 @@ +"""Docutils-tree tests for sphinx_ux_grid directives. + +Invokes each directive against a minimal stub state machine and asserts on +the resulting :class:`docutils.nodes.container` tree — class names, inlined +``style=`` attributes, and (for ``{grid-item-card}`` with ``:link:``) the +chosen wrapper node type. +""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import frontend, nodes, utils +from docutils.parsers.rst import Parser +from docutils.statemachine import StringList +from sphinx import addnodes + +from sphinx_ux_grid._css import SUG +from sphinx_ux_grid._directives import ( + GridDirective, + GridItemCardDirective, + GridItemDirective, +) + + +def _make_document() -> nodes.document: + settings = frontend.OptionParser(components=(Parser,)).get_default_values() + return utils.new_document("", settings) + + +def _parse(source: str) -> nodes.document: + """Parse a small reST snippet into a document tree.""" + document = _make_document() + # Register the directives locally for parsing. + from docutils.parsers.rst import directives as rst_directives + + rst_directives.register_directive("grid", GridDirective) + rst_directives.register_directive("grid-item", GridItemDirective) + rst_directives.register_directive("grid-item-card", GridItemCardDirective) + Parser().parse(source, document) + return document + + +def _find_first( + document: nodes.Node, + matcher: t.Callable[[nodes.Node], bool], +) -> nodes.Node: + for node in document.findall(): + if matcher(node): + return node + msg = "no matching node found" + raise AssertionError(msg) + + +def _first_container_with_class( + document: nodes.Node, + class_name: str, +) -> nodes.container: + node = _find_first( + document, + lambda n: isinstance(n, nodes.container) and class_name in n.get("classes", []), + ) + assert isinstance(node, nodes.container) + return node + + +def test_grid_default_single_column() -> None: + """A bare ``{grid}`` defaults all breakpoints to one column.""" + document = _parse( + ".. grid::\n\n .. grid-item::\n\n placeholder body\n", + ) + grid = _first_container_with_class(document, SUG.GRID) + style = grid.get("style", "") + assert "--gp-sphinx-grid-cols-xs: 1" in style + assert "--gp-sphinx-grid-cols-lg: 1" in style + + +def test_grid_four_breakpoints_inlines_style() -> None: + """A four-int ``{grid}`` argument emits four cols-* custom properties.""" + document = _parse( + ".. grid:: 1 2 3 4\n\n .. grid-item::\n\n placeholder body\n", + ) + grid = _first_container_with_class(document, SUG.GRID) + style = grid.get("style", "") + assert "--gp-sphinx-grid-cols-xs: 1" in style + assert "--gp-sphinx-grid-cols-sm: 2" in style + assert "--gp-sphinx-grid-cols-md: 3" in style + assert "--gp-sphinx-grid-cols-lg: 4" in style + + +def test_grid_gutter_maps_to_css_length() -> None: + """``:gutter: 3`` resolves to ``--gp-sphinx-grid-gutter: 1rem``.""" + document = _parse( + ".. grid:: 1\n :gutter: 3\n\n .. grid-item::\n\n placeholder body\n", + ) + grid = _first_container_with_class(document, SUG.GRID) + style = grid.get("style", "") + assert "--gp-sphinx-grid-gutter: 1rem" in style + + +def test_grid_outline_reverse_classes() -> None: + """``:outline:`` and ``:reverse:`` flags add the matching modifier classes.""" + document = _parse( + ".. grid:: 1\n" + " :outline:\n" + " :reverse:\n\n" + " .. grid-item::\n\n" + " placeholder body\n", + ) + grid = _first_container_with_class(document, SUG.GRID) + classes: list[str] = grid.get("classes", []) + assert SUG.OUTLINE_GRID in classes + assert SUG.REVERSE in classes + + +def test_grid_class_container_extends_classes() -> None: + """``:class-container:`` extra classes survive on the container.""" + document = _parse( + ".. grid:: 1\n" + " :class-container: my-extra-class\n\n" + " .. grid-item::\n\n" + " placeholder body\n", + ) + grid = _first_container_with_class(document, SUG.GRID) + assert "my-extra-class" in grid.get("classes", []) + + +def test_grid_item_columns_inlines_span_style() -> None: + """``:columns: 6`` on a grid-item inlines four ``--…-item-span-*`` props.""" + document = _parse( + ".. grid:: 12\n\n" + " .. grid-item::\n" + " :columns: 6\n\n" + " placeholder body\n", + ) + item = _first_container_with_class(document, SUG.ITEM) + style = item.get("style", "") + assert "--gp-sphinx-grid-item-span-xs: 6" in style + assert "--gp-sphinx-grid-item-span-lg: 6" in style + + +def test_grid_item_card_emits_card_and_body() -> None: + """``{grid-item-card}`` emits a card container with a body and title.""" + document = _parse( + ".. grid:: 1\n\n .. grid-item-card:: My title\n\n Body text.\n", + ) + card = _first_container_with_class(document, SUG.CARD) + classes: list[str] = card.get("classes", []) + # Default shadow is sm. + assert SUG.shadow("sm") in classes + + body = _first_container_with_class(document, SUG.CARD_BODY) + assert body is not None + + title = _first_container_with_class(document, SUG.CARD_TITLE) + # The title text appears as a child Text node somewhere under title. + title_text = "".join(n.astext() for n in title.findall(nodes.Text)) + assert "My title" in title_text + + +def test_grid_item_card_header_and_footer_split() -> None: + """``^^^`` and ``+++`` split off header/footer sections.""" + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card:: Demo\n\n" + " Header text\n" + " ^^^\n" + " Body text\n" + " +++\n" + " Footer text\n", + ) + header = _first_container_with_class(document, SUG.CARD_HEADER) + footer = _first_container_with_class(document, SUG.CARD_FOOTER) + body = _first_container_with_class(document, SUG.CARD_BODY) + assert "Header text" in "".join(n.astext() for n in header.findall(nodes.Text)) + assert "Body text" in "".join(n.astext() for n in body.findall(nodes.Text)) + assert "Footer text" in "".join(n.astext() for n in footer.findall(nodes.Text)) + + +def test_grid_item_card_link_url_emits_stretched_reference() -> None: + """``:link: …`` with ``:link-type: url`` emits a stretched ``nodes.reference``. + + The reference lives inside the card as a stretched-link overlay + (positioned via the bundled CSS), not as a wrapper around the card. + """ + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card:: External\n" + " :link: https://example.com\n" + " :link-type: url\n\n" + " Body.\n", + ) + reference = _find_first( + document, + lambda n: ( + isinstance(n, nodes.reference) and SUG.CARD_LINK in n.get("classes", []) + ), + ) + assert isinstance(reference, nodes.reference) + assert reference["refuri"] == "https://example.com" + # The card carries the hover-affordance class. + card = _first_container_with_class(document, SUG.CARD) + assert SUG.CARD_HOVER in card.get("classes", []) + + +def test_grid_item_card_link_doc_emits_pending_xref() -> None: + """``:link-type: doc`` produces an :class:`addnodes.pending_xref` overlay.""" + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card:: Internal\n" + " :link: foo\n" + " :link-type: doc\n\n" + " Body.\n", + ) + xref = _find_first( + document, + lambda n: ( + isinstance(n, addnodes.pending_xref) + and SUG.CARD_LINK in n.get("classes", []) + ), + ) + assert isinstance(xref, addnodes.pending_xref) + assert xref["reftype"] == "doc" + assert xref["reftarget"] == "foo" + + +def test_grid_item_card_shadow_none_drops_class() -> None: + """``:shadow: none`` strips the shadow modifier entirely.""" + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card:: Plain\n" + " :shadow: none\n\n" + " Body.\n", + ) + card = _first_container_with_class(document, SUG.CARD) + classes: list[str] = card.get("classes", []) + assert not any(c.startswith("gp-sphinx-grid-card--shadow-") for c in classes) + + +@pytest.mark.parametrize( + ("level", "expected_class"), + [ + ("sm", "gp-sphinx-grid-card--shadow-sm"), + ("md", "gp-sphinx-grid-card--shadow-md"), + ("lg", "gp-sphinx-grid-card--shadow-lg"), + ], +) +def test_grid_item_card_shadow_levels(level: str, expected_class: str) -> None: + """Each named shadow level lands on the card as a single modifier.""" + document = _parse( + ".. grid:: 1\n\n" + f" .. grid-item-card::\n" + f" :shadow: {level}\n\n" + f" Body.\n", + ) + card = _first_container_with_class(document, SUG.CARD) + assert expected_class in card.get("classes", []) + + +def test_grid_item_card_image_top_attaches_image_node() -> None: + """``:img-top:`` attaches a ``nodes.image`` with the matching class.""" + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card::\n" + " :img-top: hero.png\n" + " :img-alt: alt-text\n\n" + " Body.\n", + ) + img = _find_first( + document, + lambda n: ( + isinstance(n, nodes.image) and SUG.CARD_IMG_TOP in n.get("classes", []) + ), + ) + assert isinstance(img, nodes.image) + assert img["uri"] == "hero.png" + assert img["alt"] == "alt-text" + + +def test_columns_to_style_round_trip() -> None: + """The four breakpoints are inlined in xs/sm/md/lg order.""" + from sphinx_ux_grid._directives import _columns_to_style + + style = _columns_to_style((2, 4, 6, 8)) + parts = [p.strip() for p in style.split(";")] + assert parts == [ + "--gp-sphinx-grid-cols-xs: 2", + "--gp-sphinx-grid-cols-sm: 4", + "--gp-sphinx-grid-cols-md: 6", + "--gp-sphinx-grid-cols-lg: 8", + ] + + +def test_split_card_content_no_markers() -> None: + """When neither marker is present, body == content and header/footer are None.""" + from sphinx_ux_grid._directives import _split_card_content + + content = StringList(["line1", "line2"], source="") + result = _split_card_content(content, offset=10) + assert result.header is None + assert result.footer is None + assert list(result.body) == ["line1", "line2"] + assert result.body_offset == 10 + + +class SpacingFixture(t.NamedTuple): + """Test case for ``:margin:`` / ``:padding:`` style emission.""" + + test_id: str + source: str + target_class: str + expected_fragments: tuple[str, ...] + + +_SPACING_FIXTURES: list[SpacingFixture] = [ + SpacingFixture( + test_id="grid-margin-single", + source=".. grid:: 1\n :margin: 3\n\n .. grid-item::\n\n body\n", + target_class=SUG.GRID, + expected_fragments=( + "--gp-sphinx-grid-margin-xs: 1rem", + "--gp-sphinx-grid-margin-sm: 1rem", + "--gp-sphinx-grid-margin-md: 1rem", + "--gp-sphinx-grid-margin-lg: 1rem", + ), + ), + SpacingFixture( + test_id="grid-margin-four-breakpoints", + source=".. grid:: 1\n :margin: 1 2 3 4\n\n .. grid-item::\n\n body\n", + target_class=SUG.GRID, + expected_fragments=( + "--gp-sphinx-grid-margin-xs: 0.25rem", + "--gp-sphinx-grid-margin-sm: 0.5rem", + "--gp-sphinx-grid-margin-md: 1rem", + "--gp-sphinx-grid-margin-lg: 1.5rem", + ), + ), + SpacingFixture( + test_id="grid-padding-single", + source=".. grid:: 1\n :padding: 2\n\n .. grid-item::\n\n body\n", + target_class=SUG.GRID, + expected_fragments=( + "--gp-sphinx-grid-padding-xs: 0.5rem", + "--gp-sphinx-grid-padding-lg: 0.5rem", + ), + ), + SpacingFixture( + test_id="item-margin-auto", + source=( + ".. grid:: 1\n\n .. grid-item::\n :margin: auto\n\n body\n" + ), + target_class=SUG.ITEM, + expected_fragments=( + "--gp-sphinx-grid-margin-xs: auto", + "--gp-sphinx-grid-margin-lg: auto", + ), + ), + SpacingFixture( + test_id="item-padding-single", + source=(".. grid:: 1\n\n .. grid-item::\n :padding: 4\n\n body\n"), + target_class=SUG.ITEM, + expected_fragments=( + "--gp-sphinx-grid-padding-xs: 1.5rem", + "--gp-sphinx-grid-padding-lg: 1.5rem", + ), + ), + SpacingFixture( + test_id="card-margin-single", + source=( + ".. grid:: 1\n\n .. grid-item-card::\n :margin: 5\n\n body\n" + ), + target_class=SUG.CARD, + expected_fragments=( + "--gp-sphinx-grid-margin-xs: 3rem", + "--gp-sphinx-grid-margin-lg: 3rem", + ), + ), + SpacingFixture( + test_id="card-margin-four-breakpoints", + source=( + ".. grid:: 1\n\n" + " .. grid-item-card::\n" + " :margin: 1 2 3 4\n\n" + " body\n" + ), + target_class=SUG.CARD, + expected_fragments=( + "--gp-sphinx-grid-margin-xs: 0.25rem", + "--gp-sphinx-grid-margin-sm: 0.5rem", + "--gp-sphinx-grid-margin-md: 1rem", + "--gp-sphinx-grid-margin-lg: 1.5rem", + ), + ), +] + + +@pytest.mark.parametrize( + list(SpacingFixture._fields), + _SPACING_FIXTURES, + ids=[f.test_id for f in _SPACING_FIXTURES], +) +def test_spacing_options_inline_custom_properties( + test_id: str, + source: str, + target_class: str, + expected_fragments: tuple[str, ...], +) -> None: + """:margin: / :padding: emit per-breakpoint custom properties on style=.""" + document = _parse(source) + container = _first_container_with_class(document, target_class) + style = container.get("style", "") + for fragment in expected_fragments: + assert fragment in style, f"{test_id}: expected {fragment!r} in style {style!r}" + + +def test_grid_item_card_direction_align_use_sug_helpers() -> None: + """``:child-direction:`` / ``:child-align:`` flow through SUG helpers.""" + document = _parse( + ".. grid:: 1\n\n" + " .. grid-item-card::\n" + " :child-direction: row\n" + " :child-align: center\n\n" + " body\n", + ) + item = _first_container_with_class(document, SUG.ITEM) + classes: list[str] = item.get("classes", []) + assert SUG.item_direction("row") in classes + assert SUG.item_align("center") in classes diff --git a/tests/ext/grid/test_integration.py b/tests/ext/grid/test_integration.py new file mode 100644 index 00000000..843bd3be --- /dev/null +++ b/tests/ext/grid/test_integration.py @@ -0,0 +1,146 @@ +"""Integration tests for sphinx_ux_grid. + +Builds a tiny MyST project that exercises every directive in the package +(``{grid}``, ``{grid-item-card}`` with both ``:link-type: doc`` and +``:link-type: url``) and asserts on the rendered HTML. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + extensions = [ + "myst_parser", + "sphinx_ux_grid", + ] + myst_enable_extensions = ["colon_fence"] + """, +) + +_INDEX_MD = textwrap.dedent( + """\ + # Demo + + ::::{grid} 1 2 3 4 + :gutter: 3 + :class-container: my-extra-grid + + :::{grid-item-card} Internal + :link: page-two + :link-type: doc + + Body text for the internal-link card. + + +++ + + Footer text. + ::: + + :::{grid-item-card} External + :link: https://example.com + :link-type: url + + Body for the external-link card. + ::: + + :::: + """, +) + +_PAGE_TWO_MD = textwrap.dedent( + """\ + # Page Two + + Second page content. + """, +) + + +@pytest.fixture(scope="module") +def grid_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a minimal MyST project exercising every grid directive.""" + cache_root = tmp_path_factory.mktemp("grid-html") + scenario = SphinxScenario( + buildername="html", + files=( + ScenarioFile("conf.py", _CONF_PY), + ScenarioFile("index.md", _INDEX_MD), + ScenarioFile("page-two.md", _PAGE_TWO_MD), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.mark.integration +def test_grid_container_renders_with_classes_and_style( + grid_html_result: SharedSphinxResult, +) -> None: + """The grid container carries gp-sphinx-grid* classes and inlined custom props.""" + html = read_output(grid_html_result, "index.html") + assert "gp-sphinx-grid" in html + # Class-container extension survives onto the container. + assert "my-extra-grid" in html + # Per-breakpoint column counts arrive as inline CSS custom properties. + assert "--gp-sphinx-grid-cols-xs: 1" in html + assert "--gp-sphinx-grid-cols-sm: 2" in html + assert "--gp-sphinx-grid-cols-md: 3" in html + assert "--gp-sphinx-grid-cols-lg: 4" in html + # Gutter scale resolves to 1rem. + assert "--gp-sphinx-grid-gutter: 1rem" in html + + +@pytest.mark.integration +def test_grid_item_card_link_doc_resolves_to_internal_href( + grid_html_result: SharedSphinxResult, +) -> None: + """``:link-type: doc`` resolves to the target docname's HTML URL.""" + html = read_output(grid_html_result, "index.html") + # Card body classes are present. + assert "gp-sphinx-grid-card" in html + assert "gp-sphinx-grid-card__body" in html + assert "gp-sphinx-grid-card__title" in html + # Footer is present from the +++ split. + assert "gp-sphinx-grid-card__footer" in html + # The :link-type: doc resolves to an HTML href targeting page-two. + assert "page-two.html" in html + + +@pytest.mark.integration +def test_grid_item_card_link_url_emits_external_href( + grid_html_result: SharedSphinxResult, +) -> None: + """``:link-type: url`` emits a plain external href on a reference node.""" + html = read_output(grid_html_result, "index.html") + assert "https://example.com" in html + + +@pytest.mark.integration +def test_grid_directive_emits_no_design_classes( + grid_html_result: SharedSphinxResult, +) -> None: + """The package never emits the sphinx-design ``sd-`` classes.""" + html = read_output(grid_html_result, "index.html") + # The grid container itself should not carry sd-grid-container or sd-row. + grid_html_start = html.find('class="gp-sphinx-grid') + assert grid_html_start != -1 + # Take a window around the grid and verify no sd- classes appear in it. + window = html[grid_html_start : grid_html_start + 4000] + assert "sd-grid-container" not in window + assert "sd-row" not in window + assert "sd-col" not in window diff --git a/tests/ext/grid/test_options.py b/tests/ext/grid/test_options.py new file mode 100644 index 00000000..4753f6d9 --- /dev/null +++ b/tests/ext/grid/test_options.py @@ -0,0 +1,111 @@ +"""Unit tests for the option-parsing helpers in :mod:`sphinx_ux_grid._directives`.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_ux_grid._directives import _columns_option, _gutter_to_length + + +class ColumnFixture(t.NamedTuple): + """Test case for :func:`_columns_option`.""" + + test_id: str + argument: str + expected: tuple[int, int, int, int] + + +_COLUMN_FIXTURES: list[ColumnFixture] = [ + ColumnFixture(test_id="single-int", argument="3", expected=(3, 3, 3, 3)), + ColumnFixture(test_id="four-ints", argument="1 2 3 4", expected=(1, 2, 3, 4)), + ColumnFixture( + test_id="extra-whitespace", argument=" 2 2 3 3 ", expected=(2, 2, 3, 3) + ), + ColumnFixture(test_id="min-bound", argument="1", expected=(1, 1, 1, 1)), + ColumnFixture(test_id="max-bound", argument="12", expected=(12, 12, 12, 12)), + ColumnFixture(test_id="mixed-range", argument="1 6 9 12", expected=(1, 6, 9, 12)), +] + + +@pytest.mark.parametrize( + list(ColumnFixture._fields), + _COLUMN_FIXTURES, + ids=[f.test_id for f in _COLUMN_FIXTURES], +) +def test_columns_option_parses( + test_id: str, + argument: str, + expected: tuple[int, int, int, int], +) -> None: + """_columns_option returns four ints clamped to ``[1..12]``.""" + assert _columns_option(argument) == expected + + +class InvalidColumnFixture(t.NamedTuple): + """Test case for invalid input to :func:`_columns_option`.""" + + test_id: str + argument: str | None + + +_INVALID_COLUMN_FIXTURES: list[InvalidColumnFixture] = [ + InvalidColumnFixture(test_id="none", argument=None), + InvalidColumnFixture(test_id="empty", argument=""), + InvalidColumnFixture(test_id="whitespace-only", argument=" "), + InvalidColumnFixture(test_id="two-values", argument="1 2"), + InvalidColumnFixture(test_id="three-values", argument="1 2 3"), + InvalidColumnFixture(test_id="five-values", argument="1 2 3 4 5"), + InvalidColumnFixture(test_id="below-min", argument="0"), + InvalidColumnFixture(test_id="above-max", argument="13"), + InvalidColumnFixture(test_id="non-int", argument="abc"), + InvalidColumnFixture(test_id="mixed-bad", argument="1 2 3 abc"), + InvalidColumnFixture(test_id="negative", argument="-1"), +] + + +@pytest.mark.parametrize( + list(InvalidColumnFixture._fields), + _INVALID_COLUMN_FIXTURES, + ids=[f.test_id for f in _INVALID_COLUMN_FIXTURES], +) +def test_columns_option_rejects(test_id: str, argument: str | None) -> None: + """_columns_option raises ValueError on malformed input.""" + with pytest.raises(ValueError): + _columns_option(argument) + + +class GutterFixture(t.NamedTuple): + """Test case for :func:`_gutter_to_length`.""" + + test_id: str + argument: str + expected: str + + +_GUTTER_FIXTURES: list[GutterFixture] = [ + GutterFixture(test_id="scale-0", argument="0", expected="0"), + GutterFixture(test_id="scale-1", argument="1", expected="0.25rem"), + GutterFixture(test_id="scale-2", argument="2", expected="0.5rem"), + GutterFixture(test_id="scale-3", argument="3", expected="1rem"), + GutterFixture(test_id="scale-4", argument="4", expected="1.5rem"), + GutterFixture(test_id="scale-5", argument="5", expected="3rem"), + GutterFixture(test_id="css-rem", argument="2rem", expected="2rem"), + GutterFixture(test_id="css-px", argument="16px", expected="16px"), +] + + +@pytest.mark.parametrize( + list(GutterFixture._fields), + _GUTTER_FIXTURES, + ids=[f.test_id for f in _GUTTER_FIXTURES], +) +def test_gutter_to_length_maps(test_id: str, argument: str, expected: str) -> None: + """_gutter_to_length maps 0..5 to a CSS scale and passes lengths through.""" + assert _gutter_to_length(argument) == expected + + +def test_gutter_to_length_takes_first_value() -> None: + """Multiple gutter values collapse to the first (cross-breakpoint scale).""" + assert _gutter_to_length("3 4") == "1rem" diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 69e85a67..7074f017 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -27,6 +27,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-autodoc-api-style", "sphinx-ux-badges", "sphinx-ux-octicons", + "sphinx-ux-grid", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", "sphinx-ux-autodoc-layout", diff --git a/uv.lock b/uv.lock index 41a6426e..c05ec02e 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,7 @@ members = [ "sphinx-gp-theme", "sphinx-ux-autodoc-layout", "sphinx-ux-badges", + "sphinx-ux-grid", "sphinx-ux-octicons", "sphinx-vite-builder", ] @@ -546,6 +547,7 @@ dev = [ { name = "sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges" }, + { name = "sphinx-ux-grid" }, { name = "sphinx-ux-octicons" }, { name = "sphinx-vite-builder" }, { name = "syrupy" }, @@ -586,6 +588,7 @@ dev = [ { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "sphinx-ux-autodoc-layout", editable = "packages/sphinx-ux-autodoc-layout" }, { name = "sphinx-ux-badges", editable = "packages/sphinx-ux-badges" }, + { name = "sphinx-ux-grid", editable = "packages/sphinx-ux-grid" }, { name = "sphinx-ux-octicons", editable = "packages/sphinx-ux-octicons" }, { name = "sphinx-vite-builder", editable = "packages/sphinx-vite-builder" }, { name = "syrupy", specifier = ">=5.1.0" }, @@ -1881,6 +1884,18 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] +[[package]] +name = "sphinx-ux-grid" +version = "0.0.1a18" +source = { editable = "packages/sphinx-ux-grid" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] + [[package]] name = "sphinx-ux-octicons" version = "0.0.1a18" From 626f2a9374616e9f58a81cfd8d24c43f750dadce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 09:33:46 -0500 Subject: [PATCH 04/11] sphinx-ux-tabs(feat[new-package]): Drop-in tabs replacement for sphinx-inline-tabs + sphinx-design why: Replace sphinx-inline-tabs and sphinx-design's tab-set/tab-item with a first-party package whose JS does not collide with spa-nav and whose CSS stays under the gp-sphinx-* namespace. what: - Add sphinx-ux-tabs: directives .. tab:: (with :new-set:), .. tab-set::, .. tab-item:: - Custom nodes TabContainer, TabSetNode, TabItemNode, TabInputNode, TabLabelNode - Two-pass TabsPostTransform: group consecutive .. tab:: siblings, then expand to radio-input HTML - CSS-only tab switching under @layer gp-sphinx with tokens for active/inactive states - SPA-aware sync JS hooked onto gp-sphinx:navigated, idempotent via data-gp-sphinx-tabs-bound guard - Tests: pure-docutils node tests, transform unit tests, integration build covering both authoring styles --- docs/_ext/package_reference.py | 1 + docs/conf.py | 4 + docs/packages/index.md | 1 + docs/packages/sphinx-ux-tabs/index.md | 6 + docs/redirects.txt | 1 + packages/sphinx-ux-tabs/README.md | 55 +++ packages/sphinx-ux-tabs/pyproject.toml | 40 ++ .../src/sphinx_ux_tabs/__init__.py | 122 ++++++ .../sphinx-ux-tabs/src/sphinx_ux_tabs/_css.py | 106 +++++ .../src/sphinx_ux_tabs/_directives.py | 231 ++++++++++ .../src/sphinx_ux_tabs/_nodes.py | 269 ++++++++++++ .../_static/css/sphinx_ux_tabs.css | 100 +++++ .../_static/js/sphinx_ux_tabs_sync.js | 69 +++ .../src/sphinx_ux_tabs/_transforms.py | 408 ++++++++++++++++++ .../src/sphinx_ux_tabs/_visitors.py | 163 +++++++ .../src/sphinx_ux_tabs/py.typed | 0 pyproject.toml | 4 + scripts/ci/package_tools.py | 25 ++ tests/ext/tabs/__init__.py | 3 + tests/ext/tabs/test_integration.py | 243 +++++++++++ tests/ext/tabs/test_nodes.py | 194 +++++++++ .../ext/tabs/test_post_transform_expansion.py | 261 +++++++++++ .../ext/tabs/test_post_transform_grouping.py | 217 ++++++++++ tests/test_package_reference.py | 1 + uv.lock | 15 + 25 files changed, 2539 insertions(+) create mode 100644 docs/packages/sphinx-ux-tabs/index.md create mode 100644 packages/sphinx-ux-tabs/README.md create mode 100644 packages/sphinx-ux-tabs/pyproject.toml create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/__init__.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_css.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_directives.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_nodes.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_static/css/sphinx_ux_tabs.css create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_static/js/sphinx_ux_tabs_sync.js create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_transforms.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_visitors.py create mode 100644 packages/sphinx-ux-tabs/src/sphinx_ux_tabs/py.typed create mode 100644 tests/ext/tabs/__init__.py create mode 100644 tests/ext/tabs/test_integration.py create mode 100644 tests/ext/tabs/test_nodes.py create mode 100644 tests/ext/tabs/test_post_transform_expansion.py create mode 100644 tests/ext/tabs/test_post_transform_grouping.py diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 9668773f..7a481662 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -202,6 +202,7 @@ class PackageDocsRecord: "sphinx-ux-badges": "ux", "sphinx-ux-octicons": "ux", "sphinx-ux-grid": "ux", + "sphinx-ux-tabs": "ux", "sphinx-ux-autodoc-layout": "ux", "sphinx-vite-builder": "build-seo", "sphinx-gp-opengraph": "build-seo", diff --git a/docs/conf.py b/docs/conf.py index 474c3530..c665bc22 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,6 +54,10 @@ 0, str(project_root / "packages" / "sphinx-ux-grid" / "src"), ) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-ux-tabs" / "src"), +) sys.path.insert( 0, str(project_root / "packages" / "sphinx-autodoc-fastmcp" / "src"), diff --git a/docs/packages/index.md b/docs/packages/index.md index 45c83642..6865cea2 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -17,6 +17,7 @@ The rendering pipeline every autodoc extension consumes: - [`sphinx-ux-badges`](sphinx-ux-badges/index.md) — badge primitives and colour palette - [`sphinx-ux-octicons`](sphinx-ux-octicons/index.md) — curated GitHub Octicons as a Sphinx `{octicon}` role - [`sphinx-ux-grid`](sphinx-ux-grid/index.md) — CSS-Grid `{grid}` and `{grid-item-card}` directives +- [`sphinx-ux-tabs`](sphinx-ux-tabs/index.md) — drop-in tabs replacement for sphinx-inline-tabs and sphinx-design - [`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 diff --git a/docs/packages/sphinx-ux-tabs/index.md b/docs/packages/sphinx-ux-tabs/index.md new file mode 100644 index 00000000..1913dfd8 --- /dev/null +++ b/docs/packages/sphinx-ux-tabs/index.md @@ -0,0 +1,6 @@ +(sphinx-ux-tabs)= + +# sphinx-ux-tabs + +```{package-landing} sphinx-ux-tabs +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index 3e337ccf..03eacc59 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -10,6 +10,7 @@ extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style extensions/sphinx-ux-badges packages/sphinx-ux-badges extensions/sphinx-ux-octicons packages/sphinx-ux-octicons extensions/sphinx-ux-grid packages/sphinx-ux-grid +extensions/sphinx-ux-tabs packages/sphinx-ux-tabs extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp extensions/sphinx-ux-autodoc-layout packages/sphinx-ux-autodoc-layout extensions/sphinx-fonts packages/sphinx-fonts diff --git a/packages/sphinx-ux-tabs/README.md b/packages/sphinx-ux-tabs/README.md new file mode 100644 index 00000000..b6e94158 --- /dev/null +++ b/packages/sphinx-ux-tabs/README.md @@ -0,0 +1,55 @@ +# sphinx-ux-tabs + +CSS-only tabbed-content directives under the `gp-sphinx-tabs` CSS +namespace. The package is a first-party drop-in for the +`.. tab::` directive shipped by sphinx-inline-tabs and the +`.. tab-set::` / `.. tab-item::` directives shipped by sphinx-design, +emitting a single radio-input HTML structure that both authoring +styles share. + +The bundled JavaScript syncs tab selection across same-`:sync:` +groups and re-binds itself after every gp-sphinx SPA navigation +via the `gp-sphinx:navigated` event from `sphinx-gp-theme`. + +## Install + +```console +$ pip install sphinx-ux-tabs +``` + +## Usage + +Add the extension to your `conf.py`: + +```python +extensions = ["sphinx_ux_tabs"] +``` + +Then write tabs in either reStructuredText (sphinx-inline-tabs style): + +```rst +.. tab:: Python + + Python content. + +.. tab:: Rust + + Rust content. +``` + +…or MyST + sphinx-design style: + +```markdown +::::{tab-set} + +:::{tab-item} Python +Python content. +::: + +:::{tab-item} Rust +:selected: +Rust content. +::: + +:::: +``` diff --git a/packages/sphinx-ux-tabs/pyproject.toml b/packages/sphinx-ux-tabs/pyproject.toml new file mode 100644 index 00000000..2ede0396 --- /dev/null +++ b/packages/sphinx-ux-tabs/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-ux-tabs" +version = "0.0.1a18" +description = "Drop-in tabs replacement under the gp-sphinx-* namespace for sphinx-inline-tabs and sphinx-design" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "tabs", "tab-set", "documentation"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_ux_tabs"] diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/__init__.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/__init__.py new file mode 100644 index 00000000..d44fa8db --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/__init__.py @@ -0,0 +1,122 @@ +"""Drop-in tabs replacement for sphinx-inline-tabs and sphinx-design. + +Provides three directives — ``.. tab::`` (sphinx-inline-tabs compatible +with ``:new-set:``), ``.. tab-set::`` and ``.. tab-item::`` +(sphinx-design compatible) — backed by a two-pass post-transform that +groups consecutive ``.. tab::`` siblings and then expands every tab set +into a flat sequence of ``[input, label, panel]`` triples the bundled +CSS can switch with the ``:checked + label + .__panel`` adjacency +selector. No runtime JavaScript is required for the basic switching +behavior; the bundled JS only powers cross-set ``:sync:`` +synchronization and re-binds itself after every gp-sphinx SPA +navigation via the ``gp-sphinx:navigated`` event. + +Examples +-------- +>>> from sphinx_ux_tabs import SUT, setup +>>> SUT.TABS +'gp-sphinx-tabs' + +>>> callable(setup) +True +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +from sphinx.application import Sphinx + +from sphinx_ux_tabs._css import SUT +from sphinx_ux_tabs._directives import ( + TabDirective, + TabItemDirective, + TabSetDirective, +) +from sphinx_ux_tabs._nodes import ( + TabContainer, + TabInputNode, + TabItemNode, + TabLabelNode, + TabSetNode, +) +from sphinx_ux_tabs._transforms import TabsPostTransform +from sphinx_ux_tabs._visitors import ( + depart_tab_input_html, + depart_tab_item_html, + depart_tab_label_html, + depart_tab_set_html, + visit_tab_input_html, + visit_tab_item_html, + visit_tab_label_html, + visit_tab_set_html, +) + +__all__ = [ + "SUT", + "TabContainer", + "TabDirective", + "TabInputNode", + "TabItemDirective", + "TabItemNode", + "TabLabelNode", + "TabSetDirective", + "TabSetNode", + "TabsPostTransform", + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "0.0.1a18" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the three tab directives, custom nodes, and bundled assets. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> from sphinx_ux_tabs import setup + >>> callable(setup) + True + """ + # Nodes that materialise in the final doctree need HTML visitors; the + # transient containers (TabContainer, TabItemNode) fall back to the + # default container visitor of their docutils base for non-HTML output. + app.add_node(TabSetNode, html=(visit_tab_set_html, depart_tab_set_html)) + app.add_node(TabItemNode, html=(visit_tab_item_html, depart_tab_item_html)) + app.add_node(TabInputNode, html=(visit_tab_input_html, depart_tab_input_html)) + app.add_node(TabLabelNode, html=(visit_tab_label_html, depart_tab_label_html)) + + app.add_directive("tab", TabDirective) + app.add_directive("tab-set", TabSetDirective) + app.add_directive("tab-item", TabItemDirective) + app.add_post_transform(TabsPostTransform) + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_ux_tabs.css") + app.add_js_file("js/sphinx_ux_tabs_sync.js") + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_css.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_css.py new file mode 100644 index 00000000..0f069ee4 --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_css.py @@ -0,0 +1,106 @@ +"""Shared CSS class name constants for sphinx_ux_tabs. + +Examples +-------- +>>> SUT.TABS +'gp-sphinx-tabs' + +>>> SUT.INPUT +'gp-sphinx-tabs__input' + +>>> SUT.LABEL +'gp-sphinx-tabs__label' + +>>> SUT.PANEL +'gp-sphinx-tabs__panel' + +>>> SUT.SET_NAME_PREFIX +'gp-sphinx-tab-set-' + +>>> SUT.input_id(0, 1) +'gp-sphinx-tab-set-0-input-1' + +>>> SUT.set_name(3) +'gp-sphinx-tab-set-3' +""" + +from __future__ import annotations + + +class SUT: + """CSS class constants for sphinx_ux_tabs under the ``gp-sphinx-`` namespace. + + Tier-B package-owned BEM classes (``gp-sphinx-tabs__input``, + ``gp-sphinx-tabs__label``, …) carry the radio-input tab structure. + Tier-A modifier classes follow the ``--axis-value`` convention. + + Examples + -------- + >>> SUT.TABS + 'gp-sphinx-tabs' + + >>> SUT.LABEL + 'gp-sphinx-tabs__label' + """ + + # Container that holds the radio-input + label + panel triples. + TABS = "gp-sphinx-tabs" + + # Per-tab elements. + INPUT = "gp-sphinx-tabs__input" + LABEL = "gp-sphinx-tabs__label" + PANEL = "gp-sphinx-tabs__panel" + + # Prefix used to build per-tab-set radio ``name`` and id values so that + # input/label associations stay scoped to a single tab group. + SET_NAME_PREFIX = "gp-sphinx-tab-set-" + + @staticmethod + def set_name(set_index: int) -> str: + """Return the unique radio ``name`` for tab set ``set_index``. + + Parameters + ---------- + set_index : int + Document-wide tab-set counter, starting at ``0``. + + Returns + ------- + str + Radio group name, e.g. ``"gp-sphinx-tab-set-0"``. + + Examples + -------- + >>> SUT.set_name(0) + 'gp-sphinx-tab-set-0' + + >>> SUT.set_name(5) + 'gp-sphinx-tab-set-5' + """ + return f"{SUT.SET_NAME_PREFIX}{set_index}" + + @staticmethod + def input_id(set_index: int, item_index: int) -> str: + """Return the unique ``id`` for the radio input of one tab. + + Parameters + ---------- + set_index : int + Document-wide tab-set counter, starting at ``0``. + item_index : int + Per-set tab index, starting at ``0``. + + Returns + ------- + str + DOM ``id`` of the radio input. + + Examples + -------- + >>> SUT.input_id(0, 0) + 'gp-sphinx-tab-set-0-input-0' + + >>> SUT.input_id(2, 3) + 'gp-sphinx-tab-set-2-input-3' + """ + return f"{SUT.SET_NAME_PREFIX}{set_index}-input-{item_index}" diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_directives.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_directives.py new file mode 100644 index 00000000..7d81d035 --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_directives.py @@ -0,0 +1,231 @@ +"""Directive implementations for ``tab``, ``tab-set``, and ``tab-item``. + +The package ships three directives: + +* :class:`TabDirective` — sphinx-inline-tabs-compatible ``.. tab::`` + with an optional ``:new-set:`` flag. Emits a transient + :class:`~sphinx_ux_tabs._nodes.TabContainer` that the post-transform + grouping pass folds into a :class:`TabSetNode`. +* :class:`TabSetDirective` — sphinx-design-compatible ``.. tab-set::``. + Parses its children, validates that each is a :class:`TabItemNode`, + and emits a :class:`TabSetNode` directly (skipping the grouping pass). +* :class:`TabItemDirective` — sphinx-design-compatible ``.. tab-item::`` + with ``:selected:``, ``:sync:``, ``:name:``, and ``:class-*:`` options. + +Examples +-------- +>>> TabDirective.has_content +True + +>>> TabSetDirective.has_content +True + +>>> TabItemDirective.required_arguments +1 +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective +from sphinx.util.logging import getLogger + +from sphinx_ux_tabs._css import SUT +from sphinx_ux_tabs._nodes import ( + TabContainer, + TabItemNode, + TabSetNode, +) + +_LOGGER = getLogger(__name__) +_WARNING_TYPE = "gp-sphinx-tabs" + + +class TabDirective(SphinxDirective): + """The ``.. tab::`` directive — sphinx-inline-tabs compatible. + + The argument is the tab label (may contain inline markup). The + content is the tab's body. ``:new-set:`` forces the post-transform + to break a tab-set run at this directive — useful when two tab + groups would otherwise be glued together by the grouping pass. + + Examples + -------- + >>> TabDirective.has_content + True + + >>> TabDirective.required_arguments + 1 + """ + + required_arguments = 1 + final_argument_whitespace = True + has_content = True + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + "new-set": directives.flag, + "selected": directives.flag, + } + + def run(self) -> list[nodes.Node]: + """Build a :class:`TabContainer` from the directive arguments. + + Examples + -------- + >>> TabDirective.run.__qualname__ + 'TabDirective.run' + """ + self.assert_has_content() + container = TabContainer( + new_set="new-set" in self.options, + selected="selected" in self.options, + ) + self.set_source_info(container) + + # Label — preserve inline markup the author wrote. + textnodes, _messages = self.state.inline_text(self.arguments[0], self.lineno) + label = nodes.label("", "", *textnodes) + container += label + + # Panel body — nested-parsed into a plain container. + panel = nodes.container("", is_div=True) + self.state.nested_parse(self.content, self.content_offset, panel) + container += panel + + return [container] + + +class TabSetDirective(SphinxDirective): + """The ``.. tab-set::`` directive — sphinx-design compatible. + + Wraps a sequence of ``.. tab-item::`` children in a + :class:`TabSetNode`. Children that are not :class:`TabItemNode` are + dropped with a warning (matching sphinx-design's behavior). + + Examples + -------- + >>> TabSetDirective.has_content + True + """ + + has_content = True + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + "sync-group": directives.unchanged_required, + "class": directives.class_option, + } + + def run(self) -> list[nodes.Node]: + """Build a :class:`TabSetNode` from the directive content. + + Examples + -------- + >>> TabSetDirective.run.__qualname__ + 'TabSetDirective.run' + """ + self.assert_has_content() + tab_set = TabSetNode(classes=list(self.options.get("class", []))) + self.set_source_info(tab_set) + self.state.nested_parse(self.content, self.content_offset, tab_set) + + sync_group = self.options.get("sync-group", "tab") + valid_children: list[nodes.Node] = [] + for child in tab_set.children: + if not isinstance(child, TabItemNode): + _LOGGER.warning( + "all children of a 'tab-set' should be 'tab-item' [%s.tab]", + _WARNING_TYPE, + location=child, + type=_WARNING_TYPE, + subtype="tab", + ) + continue + # If the child carries a sync_id, surface the resolved sync-group. + if child.get("sync_id"): + child["sync_group"] = sync_group + valid_children.append(child) + tab_set.children = valid_children + return [tab_set] + + +class TabItemDirective(SphinxDirective): + """The ``.. tab-item::`` directive — sphinx-design compatible. + + Parses its argument (the tab label) and content (the tab body) into + a :class:`TabItemNode`. Options: + + * ``:selected:`` — flag; marks this tab as the initially-checked + radio of its enclosing set. + * ``:sync:`` — string; opt-in cross-set synchronisation key. Two + labels in different sets sharing the same ``:sync:`` value will + stay in lockstep (the bundled JS handles this at runtime). + * ``:name:`` — passed through to ``add_name`` so the tab is + cross-referenceable. + * ``:class-label:`` / ``:class-content:`` — extra CSS classes for + the label and content nodes. + + Examples + -------- + >>> TabItemDirective.required_arguments + 1 + + >>> TabItemDirective.has_content + True + """ + + required_arguments = 1 + final_argument_whitespace = True + has_content = True + option_spec: t.ClassVar[dict[str, t.Callable[..., t.Any]]] = { + "selected": directives.flag, + "sync": directives.unchanged_required, + "name": directives.unchanged, + "class-label": directives.class_option, + "class-content": directives.class_option, + } + + def run(self) -> list[nodes.Node]: + """Build a :class:`TabItemNode` from the directive arguments. + + Examples + -------- + >>> TabItemDirective.run.__qualname__ + 'TabItemDirective.run' + """ + self.assert_has_content() + item = TabItemNode( + selected="selected" in self.options, + sync_id=self.options.get("sync", ""), + ) + self.set_source_info(item) + + # Label — preserve inline markup the author wrote. + textnodes, _messages = self.state.inline_text(self.arguments[0], self.lineno) + label = nodes.label("", "", *textnodes) + for extra_class in self.options.get("class-label", []): + if extra_class and extra_class not in label["classes"]: + label["classes"].append(extra_class) + self.add_name(label) + item += label + + # Panel content. + panel = nodes.container("", is_div=True) + for extra_class in self.options.get("class-content", []): + if extra_class and extra_class not in panel["classes"]: + panel["classes"].append(extra_class) + # The expansion pass tags the panel with SUT.PANEL — anticipate it + # here so other passes (and unit tests) see the same shape. + if SUT.PANEL not in panel["classes"]: + panel["classes"].append(SUT.PANEL) + self.state.nested_parse(self.content, self.content_offset, panel) + item += panel + + return [item] + + +__all__ = [ + "TabDirective", + "TabItemDirective", + "TabSetDirective", +] diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_nodes.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_nodes.py new file mode 100644 index 00000000..c1cb8f23 --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_nodes.py @@ -0,0 +1,269 @@ +"""Custom docutils nodes for sphinx_ux_tabs. + +The package defines five custom nodes covering both the authoring-time +shape and the expanded HTML-ready shape: + +* :class:`TabContainer` — emitted by the ``.. tab::`` directive. Holds + ``[label, panel]`` children and is replaced by the post-transform. +* :class:`TabSetNode` — the persistent container that wraps a tab set's + ``[input, label, panel]`` triples in the final tree. +* :class:`TabItemNode` — emitted by ``.. tab-set::`` / ``.. tab-item::`` + for the sphinx-design-compatible authoring style. Holds the same + ``[label, panel]`` children as :class:`TabContainer` but does not + participate in the consecutive-sibling grouping pass. +* :class:`TabInputNode` — void HTML ``
containing a + * flat sequence of [input, label, panel] triples per tab. The + * :checked + label + .__panel adjacency selectors drive the active + * styling and panel visibility without any JavaScript. + */ + +@layer gp-sphinx { + .gp-sphinx-tabs { + --gp-sphinx-tabs-border: var(--color-background-border, #d0d7de); + --gp-sphinx-tabs-active-color: var(--color-brand-primary, #0969da); + --gp-sphinx-tabs-inactive-color: var(--color-foreground-muted, #57606a); + --gp-sphinx-tabs-panel-bg: var(--color-background-secondary, transparent); + --gp-sphinx-tabs-label-gap: 1.25rem; + --gp-sphinx-tabs-label-padding-y: 0.4rem; + --gp-sphinx-tabs-label-padding-x: 0.2rem; + + display: flex; + flex-wrap: wrap; + position: relative; + margin: 1rem 0; + border-bottom: 1px solid var(--gp-sphinx-tabs-border); + } + + /* Hide the underlying radio inputs — labels carry the affordance. */ + .gp-sphinx-tabs__input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + border: 0; + } + + .gp-sphinx-tabs__label { + order: 1; + margin: 0 var(--gp-sphinx-tabs-label-gap) -1px 0; + padding: var(--gp-sphinx-tabs-label-padding-y) + var(--gp-sphinx-tabs-label-padding-x); + cursor: pointer; + color: var(--gp-sphinx-tabs-inactive-color); + font-weight: 500; + border-bottom: 2px solid transparent; + transition: color 120ms ease, border-color 120ms ease; + } + + .gp-sphinx-tabs__label:hover { + color: var(--gp-sphinx-tabs-active-color); + } + + /* Panels sit below the labels (flex order 2) and stay hidden by default. */ + .gp-sphinx-tabs__panel { + order: 2; + width: 100%; + display: none; + padding: 1rem 0 0; + background: var(--gp-sphinx-tabs-panel-bg); + } + + /* + * The checked input is a sibling of the matching label and panel in + * source order: input → label → panel. Adjacent-sibling combinator + * (+) connects each checked input to its label and panel. + */ + .gp-sphinx-tabs__input:checked + .gp-sphinx-tabs__label { + color: var(--gp-sphinx-tabs-active-color); + border-bottom-color: var(--gp-sphinx-tabs-active-color); + } + + .gp-sphinx-tabs__input:checked + + .gp-sphinx-tabs__label + + .gp-sphinx-tabs__panel { + display: block; + } + + /* Focus ring on labels matches workspace focus styling. */ + .gp-sphinx-tabs__input:focus-visible + .gp-sphinx-tabs__label { + outline: 2px solid var(--gp-sphinx-tabs-active-color); + outline-offset: 2px; + border-radius: 0.125rem; + } + + /* Last label loses its right gap so the underline tracks neatly. */ + .gp-sphinx-tabs__label:last-of-type { + margin-right: 0; + } + + /* Tighten code blocks living inside a panel so they don't double-pad. */ + .gp-sphinx-tabs__panel > :first-child { + margin-top: 0; + } + + .gp-sphinx-tabs__panel > :last-child { + margin-bottom: 0; + } +} diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_static/js/sphinx_ux_tabs_sync.js b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_static/js/sphinx_ux_tabs_sync.js new file mode 100644 index 00000000..82264e7c --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_static/js/sphinx_ux_tabs_sync.js @@ -0,0 +1,69 @@ +/* + * sphinx-ux-tabs sync. + * + * Listens for `gp-sphinx:navigated` from sphinx-gp-theme/spa-nav.js and + * re-binds tab-sync click handlers on freshly-swapped labels. spa-nav + * replaces the entire .article-container, destroying old labels and + * inserting new ones without the `data-gp-sphinx-tabs-bound` marker — + * the idempotent guard ensures we only bind once per label. + * + * Cross-tab-set synchronization: clicking a label that carries + * `data-sync-id` activates every label sharing the same sync-id within + * the same sync-group, regardless of tab-set. This is the runtime + * counterpart of sphinx-design's `:sync:` option. + */ + +(function () { + "use strict"; + + function onSyncClick(event) { + var label = event.currentTarget; + var syncId = label.getAttribute("data-sync-id"); + var syncGroup = label.getAttribute("data-sync-group") || "tab"; + if (!syncId) { + return; + } + var selector = + 'label.gp-sphinx-tabs__label[data-sync-id="' + + syncId + + '"][data-sync-group="' + + syncGroup + + '"]'; + var peers = document.querySelectorAll(selector); + peers.forEach(function (peer) { + if (peer === label) { + return; + } + var forAttr = peer.getAttribute("for"); + if (!forAttr) { + return; + } + var input = document.getElementById(forAttr); + if (input && !input.checked) { + input.checked = true; + } + }); + } + + function bindLabels() { + var labels = document.querySelectorAll( + "label.gp-sphinx-tabs__label[data-sync-id]:not([data-gp-sphinx-tabs-bound])", + ); + labels.forEach(function (label) { + label.dataset.gpSphinxTabsBound = "1"; + label.addEventListener("click", onSyncClick); + }); + } + + window.gpSphinxTabsSync = bindLabels; + + // Initial bind on first parse. + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindLabels); + } else { + bindLabels(); + } + + // Re-bind after each SPA navigation. + document.addEventListener("gp-sphinx:navigated", bindLabels); +})(); diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_transforms.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_transforms.py new file mode 100644 index 00000000..c7437308 --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_transforms.py @@ -0,0 +1,408 @@ +"""Post-transforms for sphinx_ux_tabs. + +The package runs two passes during HTML post-transform: + +1. **Grouping pass** — walks the doctree, collects consecutive + :class:`~sphinx_ux_tabs._nodes.TabContainer` siblings, and folds each + run into a :class:`~sphinx_ux_tabs._nodes.TabSetNode` whose children + are :class:`~sphinx_ux_tabs._nodes.TabItemNode` clones of the source + containers. A ``:new-set:`` flag on any container forces the run to + restart at that node. + +2. **Expansion pass** — replaces every :class:`TabSetNode` with the + sequence of ``[TabInputNode, TabLabelNode, panel]`` triples the HTML + visitors recognise. Selection follows sphinx-design semantics: the + first item with ``selected=True`` wins; absent any selected, the + first item is checked; multiple selected items emit a warning and + only the first is honoured. + +Both passes run inside one :class:`TabsPostTransform.run`. + +Examples +-------- +>>> TabsPostTransform.default_priority +200 + +>>> TabsPostTransform.formats +('html',) +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx.transforms.post_transforms import SphinxPostTransform +from sphinx.util.logging import getLogger + +from sphinx_ux_tabs._css import SUT +from sphinx_ux_tabs._nodes import ( + TabContainer, + TabInputNode, + TabItemNode, + TabLabelNode, + TabSetNode, +) + +WARNING_TYPE = "gp-sphinx-tabs" + +_LOGGER = getLogger(__name__) + + +def _is_tab_container(node: nodes.Node) -> bool: + """Return ``True`` when ``node`` is a :class:`TabContainer`. + + Examples + -------- + >>> _is_tab_container(TabContainer()) + True + + >>> from docutils import nodes + >>> _is_tab_container(nodes.paragraph()) + False + """ + return isinstance(node, TabContainer) + + +def _tab_container_to_tab_item(container: TabContainer) -> TabItemNode: + """Convert a :class:`TabContainer` into a :class:`TabItemNode`. + + The original ``[label, panel]`` children are detached from the + source container and re-parented onto the new :class:`TabItemNode`. + The ``selected`` flag is carried across. + + Parameters + ---------- + container : TabContainer + The source container. Its children must be ``[label, panel]``. + + Returns + ------- + TabItemNode + A new tab item carrying the same children and ``selected`` flag. + + Examples + -------- + >>> from docutils import nodes + >>> tc = TabContainer(selected=True) + >>> tc += nodes.label("", "Python") + >>> tc += nodes.container("", nodes.paragraph("", "body"), is_div=True) + >>> item = _tab_container_to_tab_item(tc) + >>> isinstance(item, TabItemNode), item["selected"] + (True, True) + + >>> len(item.children) + 2 + """ + item = TabItemNode(selected=container.get("selected", False)) + # Detach children — list copy so we can mutate during iteration. + for child in list(container.children): + container.remove(child) + item += child + return item + + +def _group_tab_containers(document: nodes.document) -> None: + """Fold each run of consecutive :class:`TabContainer` siblings into a set. + + Walks every parent that owns a :class:`TabContainer` child and + rewrites that parent's children so each maximal run of consecutive + containers (modulo ``:new-set:`` breaks) becomes a single + :class:`TabSetNode`. + + Parameters + ---------- + document : nodes.document + The doctree to mutate in place. + + Examples + -------- + >>> from docutils import nodes + >>> from docutils.utils import new_document + >>> from docutils.frontend import OptionParser + >>> from docutils.parsers.rst import Parser + >>> doc = new_document( + ... "", + ... OptionParser(components=(Parser,)).get_default_values(), + ... ) + >>> tc1 = TabContainer() + >>> tc1 += nodes.label("", "A") + >>> tc1 += nodes.container("", is_div=True) + >>> doc += tc1 + >>> _group_tab_containers(doc) + >>> isinstance(doc.children[0], TabSetNode) + True + """ + # Collect every distinct parent that owns at least one TabContainer. + parents: list[nodes.Element] = [] + seen: set[int] = set() + for tc in document.findall(TabContainer): + parent = tc.parent + if parent is None: + continue + if id(parent) in seen: + continue + seen.add(id(parent)) + parents.append(parent) + + for parent in parents: + _rewrite_parent(parent) + + +def _rewrite_parent(parent: nodes.Element) -> None: + """Replace consecutive TabContainer runs in ``parent`` with TabSetNodes. + + Mutates ``parent.children`` in place. + + Parameters + ---------- + parent : nodes.Element + The parent element to rewrite. + """ + new_children: list[nodes.Node] = [] + run: list[TabContainer] = [] + + def flush_run() -> None: + if not run: + return + tab_set = TabSetNode() + # Carry source-line info from the first container so that warnings + # the expansion pass emits point at the source location. + tab_set.source = run[0].source + tab_set.line = run[0].line + tab_set.parent = parent + for container in run: + item = _tab_container_to_tab_item(container) + item.source = container.source + item.line = container.line + tab_set += item + new_children.append(tab_set) + run.clear() + + for child in parent.children: + if isinstance(child, TabContainer): + if child.get("new_set", False) and run: + flush_run() + run.append(child) + else: + flush_run() + new_children.append(child) + flush_run() + + parent.children = new_children + for child in parent.children: + child.parent = parent + + +def _expand_tab_sets(document: nodes.document) -> None: + """Expand every :class:`TabSetNode` into ``[input, label, panel]`` triples. + + Each :class:`TabSetNode` is mutated in place: its existing + :class:`TabItemNode` children are replaced with a flat sequence of + ``[TabInputNode, TabLabelNode, panel]`` for each tab. The expansion + pass also picks the initially-checked tab using sphinx-design's + selection precedence. + + Parameters + ---------- + document : nodes.document + The doctree to mutate in place. + + Examples + -------- + >>> from docutils import nodes + >>> from docutils.utils import new_document + >>> from docutils.frontend import OptionParser + >>> from docutils.parsers.rst import Parser + >>> doc = new_document( + ... "", + ... OptionParser(components=(Parser,)).get_default_values(), + ... ) + >>> ts = TabSetNode() + >>> ti = TabItemNode() + >>> ti += nodes.label("", "A") + >>> ti += nodes.container("", is_div=True) + >>> ts += ti + >>> doc += ts + >>> _expand_tab_sets(doc) + >>> isinstance(ts.children[0], TabInputNode) + True + >>> ts.children[0]["checked"] + True + """ + # Snapshot the list — we mutate the children of each tab set in place, + # but enumerate gives stable indexes for set_name/input_id generation. + tab_sets = list(document.findall(TabSetNode)) + for set_index, tab_set in enumerate(tab_sets): + _expand_one_tab_set(tab_set, set_index) + + +def _expand_one_tab_set(tab_set: TabSetNode, set_index: int) -> None: + """Expand a single :class:`TabSetNode` in place. + + Parameters + ---------- + tab_set : TabSetNode + The tab-set node whose :class:`TabItemNode` children are + replaced with the radio-input expansion. + set_index : int + Document-wide tab-set counter (used to build the radio group + name and tab-item ids). + """ + items: list[TabItemNode] = [ + child for child in tab_set.children if isinstance(child, TabItemNode) + ] + if not items: + return + + set_name = SUT.set_name(set_index) + selected_idx = _resolve_selected_index(items) + + new_children: list[nodes.Node] = [] + for item_index, item in enumerate(items): + if len(item.children) != 2: + _LOGGER.warning( + "malformed tab-item: expected 2 children, got %d [%s.tab]", + len(item.children), + WARNING_TYPE, + location=item, + type=WARNING_TYPE, + subtype="tab", + ) + continue + label_src, panel = item.children + if not isinstance(label_src, nodes.Element) or not isinstance( + panel, nodes.Element + ): + _LOGGER.warning( + "tab-item children are not Element nodes [%s.tab]", + WARNING_TYPE, + location=item, + type=WARNING_TYPE, + subtype="tab", + ) + continue + input_id = SUT.input_id(set_index, item_index) + + input_node = TabInputNode( + input_id=input_id, + set_name=set_name, + checked=(item_index == selected_idx), + ) + input_node.source = item.source + input_node.line = item.line + new_children.append(input_node) + + # Label preserves the text-children of the original label so any + # inline markup the author wrote (e.g. ``:bash:`` literal) survives. + label_children = list(label_src.children) + label_node = TabLabelNode( + "", + "", + *label_children, + input_id=input_id, + sync_id=item.get("sync_id", ""), + sync_group=item.get("sync_group", ""), + ) + # Preserve cross-reference anchors registered via ``add_name`` on the + # source label — the label is the node Sphinx's StandardDomain points + # ``{ref}`` resolution at. + label_node["ids"] = list(label_src.get("ids", [])) + label_node["names"] = list(label_src.get("names", [])) + label_node.source = item.source + label_node.line = item.line + new_children.append(label_node) + + # Panel: detach from the source item, then re-parent. Tag with the + # panel namespace class so the CSS selectors can target it. + panel_classes = list(panel.get("classes", [])) + if SUT.PANEL not in panel_classes: + panel_classes.append(SUT.PANEL) + panel["classes"] = panel_classes + new_children.append(panel) + + tab_set.children = [] + for child in new_children: + tab_set += child + + +def _resolve_selected_index(items: list[TabItemNode]) -> int: + """Pick the index of the initially-checked tab. + + Selection precedence: + + 1. The first item with ``selected=True`` wins. + 2. Absent any selected item, index ``0`` is chosen. + 3. Multiple selected items emit a warning; only the first wins. + + Parameters + ---------- + items : list[TabItemNode] + The tab items (already filtered to :class:`TabItemNode`). + + Returns + ------- + int + The index of the initially-checked tab. + + Examples + -------- + >>> _resolve_selected_index([TabItemNode(), TabItemNode()]) + 0 + + >>> _resolve_selected_index([TabItemNode(), TabItemNode(selected=True)]) + 1 + """ + selected_idx: int | None = None + for idx, item in enumerate(items): + if not item.get("selected", False): + continue + if selected_idx is None: + selected_idx = idx + else: + _LOGGER.warning( + "multiple selected tab items in one tab-set [%s.tab]", + WARNING_TYPE, + location=item, + type=WARNING_TYPE, + subtype="tab", + ) + return 0 if selected_idx is None else selected_idx + + +class TabsPostTransform(SphinxPostTransform): + """Two-pass post-transform: group consecutive tabs, then expand to HTML. + + Runs only for HTML builders — other output formats fall back to the + default container rendering of :class:`TabSetNode` / + :class:`TabItemNode`, which is correct (label + body). + + Examples + -------- + >>> TabsPostTransform.default_priority + 200 + + >>> "html" in TabsPostTransform.formats + True + """ + + default_priority = 200 + formats = ("html",) + + def run(self, **kwargs: t.Any) -> None: + """Run both passes on the post-transform document. + + Examples + -------- + >>> TabsPostTransform.run.__qualname__ + 'TabsPostTransform.run' + """ + del kwargs + _group_tab_containers(self.document) + _expand_tab_sets(self.document) + + +__all__ = [ + "WARNING_TYPE", + "TabsPostTransform", +] diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_visitors.py b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_visitors.py new file mode 100644 index 00000000..df01a302 --- /dev/null +++ b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/_visitors.py @@ -0,0 +1,163 @@ +"""HTML5 visitors for sphinx_ux_tabs nodes. + +The HTML output is a single radio-input container per tab set: + +.. code-block:: html + +
+ + +
..
+ + +
..
+
+ +The bundled CSS uses the ``:checked + label + .__panel`` adjacency +selector to show the active panel and style the active label. No JS +is required for the basic switching behavior. + +Examples +-------- +>>> callable(visit_tab_set_html) +True + +>>> callable(visit_tab_input_html) +True +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from sphinx.writers.html5 import HTML5Translator + + from sphinx_ux_tabs._nodes import ( + TabInputNode, + TabItemNode, + TabLabelNode, + TabSetNode, + ) + + +def visit_tab_set_html(self: HTML5Translator, node: TabSetNode) -> None: + """Open the outer ``
``. + + Uses :meth:`starttag`, which auto-emits ``class="..."`` from + ``node["classes"]``. + + Examples + -------- + >>> visit_tab_set_html.__name__ + 'visit_tab_set_html' + """ + self.body.append(self.starttag(node, "div")) + + +def depart_tab_set_html(self: HTML5Translator, node: TabSetNode) -> None: + """Close the outer tab-set ``
``. + + Examples + -------- + >>> depart_tab_set_html.__name__ + 'depart_tab_set_html' + """ + del node + self.body.append("
") + + +def visit_tab_item_html(self: HTML5Translator, node: TabItemNode) -> None: + """Open a fallback ``
`` around the children. + + Reached only if a :class:`TabItemNode` survives the post-transform + (a bug in :class:`TabsPostTransform`), but graceful so the build + doesn't crash. In normal flow every :class:`TabItemNode` is + replaced by an ``[input, label, panel]`` triple before HTML + rendering, so this fallback emits nothing visible by itself. + + Examples + -------- + >>> visit_tab_item_html.__name__ + 'visit_tab_item_html' + """ + del node + self.body.append('
') + + +def depart_tab_item_html(self: HTML5Translator, node: TabItemNode) -> None: + """Close the fallback ``
`` opened in :func:`visit_tab_item_html`.""" + del node + self.body.append("
") + + +def visit_tab_input_html(self: HTML5Translator, node: TabInputNode) -> None: + """Emit ```` — void element, no closing tag. + + Examples + -------- + >>> visit_tab_input_html.__name__ + 'visit_tab_input_html' + """ + attrs: dict[str, t.Any] = { + "type": "radio", + "ids": [node["input_id"]], + "name": node["set_name"], + } + if node["checked"]: + attrs["checked"] = "checked" + # ``emptytag`` would be ideal but ``self.starttag`` is well-tested for + # the same shape; the lack of a depart-side write keeps it void. + self.body.append(self.starttag(node, "input", "", **attrs)) + + +def depart_tab_input_html(self: HTML5Translator, node: TabInputNode) -> None: + """Do nothing — ```` is a void HTML element. + + Examples + -------- + >>> depart_tab_input_html.__name__ + 'depart_tab_input_html' + """ + del node + + +def visit_tab_label_html(self: HTML5Translator, node: TabLabelNode) -> None: + """Open ````. + + Examples + -------- + >>> depart_tab_label_html.__name__ + 'depart_tab_label_html' + """ + del node + self.body.append("") + + +__all__ = [ + "depart_tab_input_html", + "depart_tab_item_html", + "depart_tab_label_html", + "depart_tab_set_html", + "visit_tab_input_html", + "visit_tab_item_html", + "visit_tab_label_html", + "visit_tab_set_html", +] diff --git a/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/py.typed b/packages/sphinx-ux-tabs/src/sphinx_ux_tabs/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index ef1ea328..e1ca3abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ sphinx-autodoc-typehints-gp = { workspace = true } sphinx-ux-badges = { workspace = true } sphinx-ux-octicons = { workspace = true } sphinx-ux-grid = { workspace = true } +sphinx-ux-tabs = { workspace = true } sphinx-ux-autodoc-layout = { workspace = true } sphinx-gp-opengraph = { workspace = true } sphinx-gp-sitemap = { workspace = true } @@ -48,6 +49,7 @@ dev = [ "sphinx-ux-badges", "sphinx-ux-octicons", "sphinx-ux-grid", + "sphinx-ux-tabs", "sphinx-ux-autodoc-layout", "sphinx-gp-opengraph", "sphinx-gp-sitemap", @@ -173,6 +175,7 @@ known-first-party = [ "sphinx_ux_badges", "sphinx_ux_octicons", "sphinx_ux_grid", + "sphinx_ux_tabs", "sphinx_ux_autodoc_layout", "sphinx_gp_opengraph", "sphinx_gp_sitemap", @@ -225,6 +228,7 @@ testpaths = [ "packages/sphinx-ux-badges/src", "packages/sphinx-ux-octicons/src", "packages/sphinx-ux-grid/src", + "packages/sphinx-ux-tabs/src", "packages/sphinx-gp-opengraph/src", "packages/sphinx-gp-sitemap/src", "packages/sphinx-vite-builder/src", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index d4b98ddc..6d70e17d 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -704,6 +704,30 @@ def smoke_sphinx_ux_grid(dist_dir: pathlib.Path, version: str) -> None: ) +def smoke_sphinx_ux_tabs(dist_dir: pathlib.Path, version: str) -> None: + """Verify the ux-tabs extension installs, imports, and exposes the directives.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_ux_tabs; " + "from sphinx_ux_tabs import (TabDirective, TabSetDirective, " + "TabItemDirective, TabsPostTransform, SUT, setup); " + "assert callable(setup); " + "assert SUT.TABS == 'gp-sphinx-tabs'; " + "assert TabDirective.has_content; " + "assert TabSetDirective.has_content; " + "assert TabItemDirective.has_content; " + "assert 'html' in TabsPostTransform.formats" + ), + ) + + def smoke_sphinx_ux_autodoc_layout(dist_dir: pathlib.Path, version: str) -> None: """Verify the ux-autodoc-layout extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -893,6 +917,7 @@ def smoke_sphinx_vite_builder(dist_dir: pathlib.Path, version: str) -> None: "sphinx-ux-badges": smoke_sphinx_ux_badges, "sphinx-ux-octicons": smoke_sphinx_ux_octicons, "sphinx-ux-grid": smoke_sphinx_ux_grid, + "sphinx-ux-tabs": smoke_sphinx_ux_tabs, "sphinx-autodoc-docutils": smoke_sphinx_autodoc_docutils, "sphinx-autodoc-fastmcp": smoke_sphinx_autodoc_fastmcp, "sphinx-ux-autodoc-layout": smoke_sphinx_ux_autodoc_layout, diff --git a/tests/ext/tabs/__init__.py b/tests/ext/tabs/__init__.py new file mode 100644 index 00000000..7cb23611 --- /dev/null +++ b/tests/ext/tabs/__init__.py @@ -0,0 +1,3 @@ +"""Tests for sphinx_ux_tabs.""" + +from __future__ import annotations diff --git a/tests/ext/tabs/test_integration.py b/tests/ext/tabs/test_integration.py new file mode 100644 index 00000000..cfbb82ba --- /dev/null +++ b/tests/ext/tabs/test_integration.py @@ -0,0 +1,243 @@ +"""Integration tests for sphinx_ux_tabs. + +Builds a tiny project that exercises both authoring styles — two +consecutive ``.. tab::`` directives (RST, sphinx-inline-tabs style) and +a ``{tab-set}`` block with two ``{tab-item}`` children (MyST, +sphinx-design style) — and asserts on the rendered HTML. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + extensions = [ + "myst_parser", + "sphinx_ux_tabs", + ] + myst_enable_extensions = ["colon_fence"] + """, +) + +_INDEX_RST = textwrap.dedent( + """\ + Tabs RST demo + ============= + + .. tab:: First + + Body of the first tab. + + .. tab:: Second + + Body of the second tab. + """, +) + +_PAGE_MYST_MD = textwrap.dedent( + """\ + # Tabs MyST demo + + ::::{tab-set} + + :::{tab-item} Python + Python body. + ::: + + :::{tab-item} Rust + :selected: + Rust body. + ::: + + :::: + """, +) + +_PAGE_SYNC_MD = textwrap.dedent( + """\ + # Tabs sync-group demo + + ::::{tab-set} + :sync-group: shell + + :::{tab-item} Bash + :sync: bash + echo hi + ::: + + :::{tab-item} Zsh + :sync: zsh + print -P %~ + ::: + + :::: + """, +) + +_PAGE_REF_MD = textwrap.dedent( + """\ + # Tabs ref demo + + ::::{tab-set} + + :::{tab-item} Python + :name: py-tab + Python body. + ::: + + :::{tab-item} Rust + Rust body. + ::: + + :::: + + See {ref}`the python tab ` for details. + """, +) + + +@pytest.fixture(scope="module") +def tabs_html_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build a small MyST + RST project exercising every tab authoring style.""" + cache_root = tmp_path_factory.mktemp("tabs-html") + scenario = SphinxScenario( + buildername="html", + files=( + ScenarioFile("conf.py", _CONF_PY), + ScenarioFile("index.rst", _INDEX_RST), + ScenarioFile("page-myst.md", _PAGE_MYST_MD), + ScenarioFile("page-sync.md", _PAGE_SYNC_MD), + ScenarioFile("page-ref.md", _PAGE_REF_MD), + ), + ) + return build_shared_sphinx_result(cache_root, scenario) + + +@pytest.mark.integration +def test_consecutive_tab_directives_render_one_tab_set( + tabs_html_result: SharedSphinxResult, +) -> None: + """Two consecutive ``.. tab::`` directives produce one tab-set div.""" + html = read_output(tabs_html_result, "index.html") + assert html.count('class="gp-sphinx-tabs"') == 1 + # Two radio inputs (one per tab) within the same name group. + assert html.count('type="radio"') >= 2 + assert 'name="gp-sphinx-tab-set-' in html + + +@pytest.mark.integration +def test_tab_set_block_renders_input_label_panel_triple( + tabs_html_result: SharedSphinxResult, +) -> None: + """``{tab-set}`` + ``{tab-item}`` emits the radio-input HTML structure.""" + html = read_output(tabs_html_result, "page-myst.html") + assert 'class="gp-sphinx-tabs"' in html + assert 'type="radio"' in html + # Two tab labels with their classes. + assert html.count("gp-sphinx-tabs__label") >= 2 + # Panel container class is applied. + assert "gp-sphinx-tabs__panel" in html + + +@pytest.mark.integration +def test_label_for_attribute_matches_input_id( + tabs_html_result: SharedSphinxResult, +) -> None: + """Every ``