Skip to content

Commit e7bf0b9

Browse files
committed
docs(widgets): survive non-HTML Sphinx builders in the highlight filter
``make_highlight_filter`` dereferenced ``env.app.builder.highlighter`` at filter-registration time, but that attribute only exists on ``StandaloneHTMLBuilder`` and its subclasses. Running ``sphinx-build -b text|linkcheck|gettext|man`` on a page that uses the ``{mcp-install}`` directive crashed with ``AttributeError: 'TextBuilder' object has no attribute 'highlighter'`` because ``SphinxDirective.run()`` executes during doctree construction for every builder. The prior comment ("Callers are guaranteed an HTML builder by the ``builder.format == 'html'`` guard in ``install_widget_assets``") was wrong -- that guard protects only the asset-copy hook, not the render path. Fix: narrow via ``isinstance(builder, StandaloneHTMLBuilder)`` (which also covers DirectoryHTMLBuilder + SingleFileHTMLBuilder). For non-HTML builders, fall back to an HTML-escaped ``<pre>`` block. The isinstance narrow eliminates the ``# type: ignore[attr-defined]`` that previously covered the bare attribute access. Adds ``test_widget_renders_with_text_builder`` which drives the text builder end-to-end and would AttributeError before this fix.
1 parent 906dc40 commit e7bf0b9

2 files changed

Lines changed: 41 additions & 16 deletions

File tree

docs/_ext/widgets/_base.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import jinja2
1111
import markupsafe
1212
from docutils import nodes
13+
from sphinx.builders.html import StandaloneHTMLBuilder
1314

1415
if t.TYPE_CHECKING:
1516
from sphinx.environment import BuildEnvironment
@@ -112,24 +113,33 @@ def make_highlight_filter(env: BuildEnvironment) -> HighlightFilter:
112113
r"""Return a Jinja filter that runs Sphinx's Pygments highlighter.
113114
114115
Output matches ``sphinx.writers.html5.HTML5Translator.visit_literal_block``
115-
byte-for-byte (sphinx/writers/html5.py:604-630): the inner ``highlight_block``
116-
call already returns ``<div class="highlight"><pre>...</pre></div>\n``; we
117-
wrap it with the ``<div class="highlight-{lang} notranslate">...</div>\n``
118-
starttag Sphinx produces. This means sphinx-copybutton's default selector
116+
byte-for-byte: the inner ``highlight_block`` call already returns
117+
``<div class="highlight"><pre>...</pre></div>\n``; we wrap it with the
118+
``<div class="highlight-{lang} notranslate">...</div>\n`` starttag Sphinx
119+
produces. This means sphinx-copybutton's default selector
119120
(``div.highlight pre``) matches and the prompt-strip regex from gp-sphinx's
120121
``DEFAULT_COPYBUTTON_PROMPT_TEXT`` works automatically.
122+
123+
``highlighter`` is declared on ``StandaloneHTMLBuilder`` and its subclasses
124+
(``DirectoryHTMLBuilder``, ``SingleFileHTMLBuilder``), not on the ``Builder``
125+
base. For non-HTML builders (``text``, ``linkcheck``, ``gettext``, ``man``,
126+
...), fall back to an HTML-escaped ``<pre>`` block; it still flows through
127+
the ``nodes.raw("html", ...)`` output path and is harmlessly ignored by
128+
non-HTML writers.
121129
"""
122-
# ``highlighter`` is declared on StandaloneHTMLBuilder
123-
# (sphinx/builders/html/__init__.py:246), not on the Builder base mypy
124-
# sees here -- hence the type-ignore. Callers are guaranteed an HTML
125-
# builder by the ``builder.format == "html"`` guard in
126-
# ``install_widget_assets``.
127-
highlighter = env.app.builder.highlighter # type: ignore[attr-defined]
128-
129-
def _highlight(code: str, language: str = "default") -> markupsafe.Markup:
130-
inner = highlighter.highlight_block(code, language)
131-
return markupsafe.Markup(
132-
f'<div class="highlight-{language} notranslate">{inner}</div>\n'
133-
)
130+
builder = env.app.builder
131+
if isinstance(builder, StandaloneHTMLBuilder):
132+
highlighter = builder.highlighter
133+
134+
def _highlight(code: str, language: str = "default") -> markupsafe.Markup:
135+
inner = highlighter.highlight_block(code, language)
136+
return markupsafe.Markup(
137+
f'<div class="highlight-{language} notranslate">{inner}</div>\n'
138+
)
139+
else:
140+
141+
def _highlight(code: str, language: str = "default") -> markupsafe.Markup:
142+
escaped = markupsafe.escape(code)
143+
return markupsafe.Markup(f"<pre>{escaped}</pre>\n")
134144

135145
return _highlight

tests/docs/test_widgets.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ def test_widget_dependency_noted(
200200
assert any("mcp-install" in d and "widget.html" in d for d in dep_strs)
201201

202202

203+
def test_widget_renders_with_text_builder(
204+
make_app: MakeApp,
205+
real_widget_srcdir: pathlib.Path,
206+
) -> None:
207+
"""``{mcp-install}`` must not crash under non-HTML builders (text)."""
208+
(real_widget_srcdir / "index.md").write_text(
209+
"# Home\n\n```{mcp-install}\n```\n",
210+
encoding="utf-8",
211+
)
212+
app = make_app("text", srcdir=real_widget_srcdir, freshenv=True)
213+
app.build() # would AttributeError before the highlight-filter isinstance fix
214+
assert app.statuscode == 0
215+
assert (pathlib.Path(app.outdir) / "index.txt").is_file()
216+
217+
203218
# ---------- parity: highlight filter vs. Sphinx native literal_block ------
204219

205220

0 commit comments

Comments
 (0)