Skip to content

Commit 6b46a51

Browse files
committed
docs(widgets): introduce reusable widget framework + {mcp-install} picker
New in-tree Sphinx extension at docs/_ext/widgets/ that autodiscovers BaseWidget subclasses and renders each via a Jinja2 template shipped at docs/_widgets/<name>/widget.{html,js,css}. Assets are copied into _static/widgets/<name>/ at build time and registered globally via app.add_{css,js}_file. First concrete widget is MCPInstallWidget (`{mcp-install}` directive). It replaces the ~200-line nested sphinx-inline-tabs block in quickstart.md with a single 5×3 client/method picker, and drops a compact variant above the index.md grid. Tab state syncs across widgets on the same page via CustomEvent, persists per-user via localStorage, and survives gp-sphinx SPA navigation via document- level event delegation + gp-sphinx:navigated listener. Tests at tests/docs/test_widgets.py cover autodiscovery, the client× method matrix, rendering, asset copy, missing-template handling, option validation, and env.note_dependency tracking. Root conftest registers sphinx.testing.fixtures so the Sphinx build fixtures are available to pytest.
1 parent 837face commit 6b46a51

16 files changed

Lines changed: 1105 additions & 209 deletions

File tree

conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
if t.TYPE_CHECKING:
2525
import pathlib
2626

27-
pytest_plugins = ["pytester"]
27+
pytest_plugins = ["pytester", "sphinx.testing.fixtures"]
2828

2929

3030
@pytest.fixture(autouse=True)

docs/_ext/widgets/__init__.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Reusable widget framework for Sphinx docs.
2+
3+
Each widget is a ``BaseWidget`` subclass in a sibling module (e.g.
4+
``mcp_install.py``) plus a ``<docs>/_widgets/<name>/widget.{html,js,css}``
5+
asset directory. Widgets autodiscover at ``setup()`` time — adding a new one
6+
requires no registry edits. Usage from Markdown/RST:
7+
8+
.. code-block:: markdown
9+
10+
```{mcp-install}
11+
:variant: compact
12+
```
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import functools
18+
import typing as t
19+
20+
from ._assets import install_widget_assets
21+
from ._base import (
22+
BaseWidget,
23+
depart_widget_container,
24+
visit_widget_container,
25+
widget_container,
26+
)
27+
from ._directive import make_widget_directive
28+
from ._discovery import discover
29+
30+
if t.TYPE_CHECKING:
31+
from sphinx.application import Sphinx
32+
33+
__version__ = "0.1.0"
34+
35+
__all__ = [
36+
"BaseWidget",
37+
"__version__",
38+
"setup",
39+
"widget_container",
40+
]
41+
42+
43+
def setup(app: Sphinx) -> dict[str, t.Any]:
44+
"""Register every discovered widget and wire the asset pipeline."""
45+
widgets = discover()
46+
47+
app.add_node(
48+
widget_container,
49+
html=(visit_widget_container, depart_widget_container),
50+
)
51+
52+
for name, widget_cls in widgets.items():
53+
app.add_directive(name, make_widget_directive(widget_cls))
54+
55+
app.connect(
56+
"builder-inited",
57+
functools.partial(install_widget_assets, widgets=widgets),
58+
)
59+
60+
return {
61+
"version": __version__,
62+
"parallel_read_safe": True,
63+
"parallel_write_safe": True,
64+
}

docs/_ext/widgets/_assets.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Copy widget assets into ``_static/widgets/<name>/`` and register them."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import typing as t
7+
8+
from sphinx.util import logging
9+
from sphinx.util.fileutil import copy_asset_file
10+
11+
from ._base import BaseWidget
12+
13+
if t.TYPE_CHECKING:
14+
from sphinx.application import Sphinx
15+
16+
logger = logging.getLogger(__name__)
17+
18+
STATIC_SUBDIR = "widgets"
19+
20+
21+
def install_widget_assets(
22+
app: Sphinx,
23+
widgets: dict[str, type[BaseWidget]],
24+
) -> None:
25+
"""Copy each widget's ``widget.{css,js}`` into ``_static/widgets/<name>/``.
26+
27+
Assets are then registered via ``app.add_css_file`` / ``app.add_js_file`` so
28+
every page includes them (same pattern as ``sphinx-copybutton``). This is
29+
intentionally simpler than per-page inclusion — the files are small and the
30+
docs are not bandwidth-constrained.
31+
"""
32+
if app.builder.format != "html":
33+
return
34+
35+
srcdir = pathlib.Path(app.srcdir)
36+
outdir_static = pathlib.Path(app.outdir) / "_static" / STATIC_SUBDIR
37+
38+
for name, widget_cls in widgets.items():
39+
asset_dir = widget_cls.assets_dir(srcdir)
40+
dest = outdir_static / name
41+
42+
for filename, register in (
43+
("widget.css", app.add_css_file),
44+
("widget.js", app.add_js_file),
45+
):
46+
source = asset_dir / filename
47+
if not source.is_file():
48+
continue
49+
dest.mkdir(parents=True, exist_ok=True)
50+
copy_asset_file(str(source), str(dest))
51+
register(f"{STATIC_SUBDIR}/{name}/{filename}")

docs/_ext/widgets/_base.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Base class for widgets and the docutils node that wraps rendered output."""
2+
3+
from __future__ import annotations
4+
5+
import abc
6+
import collections.abc
7+
import pathlib
8+
import typing as t
9+
10+
import jinja2
11+
from docutils import nodes
12+
13+
if t.TYPE_CHECKING:
14+
from sphinx.environment import BuildEnvironment
15+
from sphinx.writers.html5 import HTML5Translator
16+
17+
18+
class widget_container(nodes.container): # type: ignore[misc] # docutils nodes are untyped
19+
"""Wraps a widget's rendered HTML; visit/depart emit the outer div."""
20+
21+
22+
def visit_widget_container(
23+
translator: HTML5Translator,
24+
node: widget_container,
25+
) -> None:
26+
"""Open ``<div class="lm-widget lm-widget-{name}">`` for the widget."""
27+
name = node["widget_name"]
28+
translator.body.append(
29+
f'<div class="lm-widget lm-widget-{name}" data-widget="{name}">'
30+
)
31+
32+
33+
def depart_widget_container(
34+
translator: HTML5Translator,
35+
node: widget_container,
36+
) -> None:
37+
"""Close the widget wrapper div."""
38+
translator.body.append("</div>")
39+
40+
41+
ASSET_FILES: tuple[str, ...] = ("widget.html", "widget.js", "widget.css")
42+
43+
44+
class BaseWidget(abc.ABC):
45+
"""Base class every concrete widget subclasses.
46+
47+
Subclasses declare ``name`` plus optional ``option_spec`` / ``default_options``
48+
and may override ``context(env)`` to feed data into the Jinja template.
49+
Assets (``widget.html``, ``widget.js``, ``widget.css``) live at
50+
``<srcdir>/_widgets/<name>/``; only ``widget.html`` is required.
51+
"""
52+
53+
name: t.ClassVar[str]
54+
option_spec: t.ClassVar[
55+
collections.abc.Mapping[str, collections.abc.Callable[[str], t.Any]]
56+
] = {}
57+
default_options: t.ClassVar[collections.abc.Mapping[str, t.Any]] = {}
58+
59+
@classmethod
60+
def assets_dir(cls, srcdir: pathlib.Path) -> pathlib.Path:
61+
return srcdir / "_widgets" / cls.name
62+
63+
@classmethod
64+
def template_path(cls, srcdir: pathlib.Path) -> pathlib.Path:
65+
return cls.assets_dir(srcdir) / "widget.html"
66+
67+
@classmethod
68+
def has_asset(cls, srcdir: pathlib.Path, filename: str) -> bool:
69+
return (cls.assets_dir(srcdir) / filename).is_file()
70+
71+
@classmethod
72+
def context(cls, env: BuildEnvironment) -> collections.abc.Mapping[str, t.Any]:
73+
"""Return extra Jinja context. Override in subclasses for widget data."""
74+
return {}
75+
76+
@classmethod
77+
def render(
78+
cls,
79+
*,
80+
options: collections.abc.Mapping[str, t.Any],
81+
env: BuildEnvironment,
82+
) -> str:
83+
"""Render the Jinja template with merged context, return HTML."""
84+
template_path = cls.template_path(pathlib.Path(env.srcdir))
85+
source = template_path.read_text(encoding="utf-8")
86+
jenv = jinja2.Environment(
87+
undefined=jinja2.StrictUndefined,
88+
autoescape=jinja2.select_autoescape(["html"]),
89+
keep_trailing_newline=False,
90+
trim_blocks=True,
91+
lstrip_blocks=True,
92+
)
93+
template = jenv.from_string(source)
94+
context: dict[str, t.Any] = {
95+
**cls.default_options,
96+
**options,
97+
**cls.context(env),
98+
"widget_name": cls.name,
99+
}
100+
return template.render(**context)

docs/_ext/widgets/_directive.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Factory that manufactures a Sphinx Directive class for a given widget."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import typing as t
7+
8+
from docutils import nodes
9+
from sphinx.util.docutils import SphinxDirective
10+
11+
from ._base import ASSET_FILES, BaseWidget, widget_container
12+
13+
14+
def make_widget_directive(widget_cls: type[BaseWidget]) -> type[SphinxDirective]:
15+
"""Create a ``SphinxDirective`` subclass bound to ``widget_cls``.
16+
17+
Each widget gets its own Directive subclass (not a single dispatcher) because
18+
docutils parses ``:option:`` lines against ``option_spec`` *before* calling
19+
``run()`` -- so the spec must be static per directive name.
20+
"""
21+
22+
class _WidgetDirective(SphinxDirective):
23+
has_content = False
24+
required_arguments = 0
25+
optional_arguments = 0
26+
final_argument_whitespace = False
27+
# Copy the widget's option_spec so per-directive mutations don't leak.
28+
option_spec: t.ClassVar[dict[str, t.Any]] = dict(widget_cls.option_spec)
29+
30+
def run(self) -> list[nodes.Node]:
31+
"""Render the widget and return a single ``widget_container`` node."""
32+
merged: dict[str, t.Any] = {
33+
**widget_cls.default_options,
34+
**self.options,
35+
}
36+
self._note_asset_dependencies()
37+
html = self._render(merged)
38+
container = widget_container(widget_name=widget_cls.name)
39+
container += nodes.raw("", html, format="html")
40+
self.set_source_info(container)
41+
return [container]
42+
43+
def _render(self, options: dict[str, t.Any]) -> str:
44+
try:
45+
return widget_cls.render(options=options, env=self.env)
46+
except FileNotFoundError as exc:
47+
msg = (
48+
f"widget {widget_cls.name!r}: template not found -- "
49+
f"expected {exc.filename}"
50+
)
51+
raise self.severe(msg) from exc
52+
except Exception as exc: # Jinja UndefinedError, etc.
53+
msg = f"widget {widget_cls.name!r} render failed: {exc}"
54+
raise self.error(msg) from exc
55+
56+
def _note_asset_dependencies(self) -> None:
57+
assets_dir = widget_cls.assets_dir(pathlib.Path(self.env.srcdir))
58+
for filename in ASSET_FILES:
59+
path = assets_dir / filename
60+
if path.is_file():
61+
self.env.note_dependency(str(path))
62+
63+
_WidgetDirective.__name__ = f"{widget_cls.__name__}Directive"
64+
_WidgetDirective.__qualname__ = _WidgetDirective.__name__
65+
return _WidgetDirective

docs/_ext/widgets/_discovery.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Autodiscover widget classes from sibling modules in this package."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
import pkgutil
7+
8+
from ._base import BaseWidget
9+
10+
11+
def discover() -> dict[str, type[BaseWidget]]:
12+
"""Import every non-underscore submodule; collect ``BaseWidget`` subclasses.
13+
14+
Adding a new widget means: drop ``mywidget.py`` next to ``mcp_install.py`` with a
15+
``MyWidget(BaseWidget)`` that sets ``name = "mywidget"`` -- the discovery sweep
16+
at ``setup()`` time registers it automatically.
17+
"""
18+
from . import __name__ as pkg_name, __path__ as pkg_path
19+
20+
registry: dict[str, type[BaseWidget]] = {}
21+
for info in pkgutil.iter_modules(pkg_path):
22+
if info.name.startswith("_"):
23+
continue
24+
module = importlib.import_module(f"{pkg_name}.{info.name}")
25+
for obj in vars(module).values():
26+
if not _is_widget_class(obj):
27+
continue
28+
existing = registry.get(obj.name)
29+
if existing is not None and existing is not obj:
30+
msg = (
31+
f"Duplicate widget name {obj.name!r}: "
32+
f"{existing.__module__} vs {obj.__module__}"
33+
)
34+
raise RuntimeError(msg)
35+
registry[obj.name] = obj
36+
return registry
37+
38+
39+
def _is_widget_class(obj: object) -> bool:
40+
"""Return True iff ``obj`` is a concrete ``BaseWidget`` subclass with a name."""
41+
return (
42+
isinstance(obj, type)
43+
and issubclass(obj, BaseWidget)
44+
and obj is not BaseWidget
45+
and getattr(obj, "name", None) is not None
46+
)

0 commit comments

Comments
 (0)