Skip to content

Commit 7e061fc

Browse files
committed
docs(widgets): run Pygments via Jinja highlight filter for copybutton parity
Code blocks inside the mcp-install widget now go through Sphinx's PygmentsBridge via a new ``highlight`` Jinja filter (registered in BaseWidget.render). Output is byte-identical to Sphinx's native visit_literal_block, so the theme's highlighting CSS and sphinx-copybutton's default selector + gp-sphinx's prompt-strip regex apply automatically — "$ " is tagged ``<span class="gp">`` and dropped from the copied text. The widget data renames the CLI language from "shell" to "console" (ShellSessionLexer) and prepends "$ " to each CLI body; the prereq ``pip install`` block and the client-config JSON now both render through the same filter path. Tests gain a HighlightCase NamedTuple + three parametrized tests (``console-claude-code-uvx``, ``console-pip-prereq``, ``json-mcp-config-uvx``) that build a real ``.. code-block::`` via SphinxTestApp, extract the rendered block, and assert byte equality with the widget filter output. Syrupy captures snapshots of the shared HTML for regression catch. Helpers ported from gp-sphinx's ``tests/_snapshots.py`` as ``tests/docs/_snapshots.py`` with a Protocol-typed fixture.
1 parent 5766c92 commit 7e061fc

8 files changed

Lines changed: 300 additions & 33 deletions

File tree

docs/_ext/widgets/_base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,20 @@
88
import typing as t
99

1010
import jinja2
11+
import markupsafe
1112
from docutils import nodes
1213

1314
if t.TYPE_CHECKING:
1415
from sphinx.environment import BuildEnvironment
1516
from sphinx.writers.html5 import HTML5Translator
1617

1718

19+
class HighlightFilter(t.Protocol):
20+
"""Callable signature for the Jinja ``highlight`` filter."""
21+
22+
def __call__(self, code: str, language: str = "default") -> markupsafe.Markup: ...
23+
24+
1825
class widget_container(nodes.container): # type: ignore[misc] # docutils nodes are untyped
1926
"""Wraps a widget's rendered HTML; visit/depart emit the outer div."""
2027

@@ -90,6 +97,7 @@ def render(
9097
trim_blocks=True,
9198
lstrip_blocks=True,
9299
)
100+
jenv.filters["highlight"] = make_highlight_filter(env)
93101
template = jenv.from_string(source)
94102
context: dict[str, t.Any] = {
95103
**cls.default_options,
@@ -98,3 +106,30 @@ def render(
98106
"widget_name": cls.name,
99107
}
100108
return template.render(**context)
109+
110+
111+
def make_highlight_filter(env: BuildEnvironment) -> HighlightFilter:
112+
r"""Return a Jinja filter that runs Sphinx's Pygments highlighter.
113+
114+
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
119+
(``div.highlight pre``) matches and the prompt-strip regex from gp-sphinx's
120+
``DEFAULT_COPYBUTTON_PROMPT_TEXT`` works automatically.
121+
"""
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+
)
134+
135+
return _highlight

docs/_ext/widgets/mcp_install.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,17 @@ def build_panels(
152152
panels: list[Panel] = []
153153
for client_index, client in enumerate(clients):
154154
for method_index, method in enumerate(methods):
155+
is_json = client.kind == "json"
156+
raw = _body_for(client, method)
155157
panels.append(
156158
Panel(
157159
client=client,
158160
method=method,
159-
language="json" if client.kind == "json" else "shell",
160-
body=_body_for(client, method),
161+
# "console" = ShellSessionLexer — recognises the leading
162+
# ``$ `` as Generic.Prompt and emits ``<span class="gp">``,
163+
# which the gp-sphinx copybutton regex strips on copy.
164+
language="json" if is_json else "console",
165+
body=raw if is_json else f"$ {raw}",
161166
is_default=(client_index == 0 and method_index == 0),
162167
)
163168
)

docs/_widgets/mcp-install/widget.css

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
/* MCP install widget — Furo-var driven. Dark mode follows automatically. */
1+
/* MCP install widget — Furo-var driven. Dark mode follows automatically.
2+
*
3+
* Code blocks (`.highlight-console`, `.highlight-json`) inherit the theme's
4+
* Pygments styling so there's no per-widget code styling here — we only
5+
* position/space them and draw the prereq accent.
6+
*/
27

38
.lm-mcp-install {
49
border: 1px solid var(--color-background-border, var(--color-foreground-border, #ccc));
@@ -77,28 +82,15 @@
7782
text-underline-offset: 2px;
7883
}
7984

80-
.lm-mcp-install__code {
81-
background: var(--color-background-secondary);
82-
border: 1px solid var(--color-background-border, var(--color-foreground-border, #ccc));
83-
border-radius: 0.25rem;
84-
padding: 0.6rem 0.8rem;
85+
/* Prereq accent: blue left-border on the `pip install` prerequisite block. */
86+
.lm-mcp-install__code--prereq {
87+
border-left: 3px solid var(--color-brand-primary);
8588
margin: 0 0 0.6rem 0;
86-
overflow-x: auto;
87-
font-family: var(--font-stack--monospace);
88-
font-size: 0.88em;
89-
line-height: 1.5;
9089
}
9190

92-
.lm-mcp-install__code code {
93-
background: transparent;
94-
padding: 0;
95-
border: 0;
96-
font-size: inherit;
97-
color: var(--color-foreground-primary);
98-
}
99-
100-
.lm-mcp-install__code--prereq {
101-
border-left: 3px solid var(--color-brand-primary);
91+
.lm-mcp-install__code--prereq .highlight {
92+
border-top-left-radius: 0;
93+
border-bottom-left-radius: 0;
10294
}
10395

10496
.lm-mcp-install__config-file {
@@ -120,8 +112,3 @@
120112
.lm-mcp-install--compact .lm-mcp-install__body {
121113
padding: 0.7rem 0.8rem;
122114
}
123-
124-
.lm-mcp-install--compact .lm-mcp-install__code {
125-
padding: 0.45rem 0.7rem;
126-
font-size: 0.85em;
127-
}

docs/_widgets/mcp-install/widget.html

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
MCP install widget: server-rendered client × method matrix.
33
widget_name, clients, methods, panels, pip_prereq, variant are provided by
44
MCPInstallWidget.context() / the directive's option merge.
5+
6+
Each code block runs through the `highlight` filter (defined in
7+
widgets._base.make_highlight_filter) which wraps Sphinx's PygmentsBridge —
8+
so the output is byte-identical to a native ``.. code-block::`` block,
9+
meaning sphinx-copybutton + its prompt-strip regex work automatically.
510
#}
611
<div class="lm-mcp-install lm-mcp-install--{{ variant }}">
712
<div class="lm-mcp-install__tabs lm-mcp-install__tabs--clients"
@@ -51,14 +56,15 @@
5156
</p>
5257
{% elif panel.method.id == 'pip' %}
5358
<p class="lm-mcp-install__preamble">Install the packages first:</p>
54-
<pre class="lm-mcp-install__code lm-mcp-install__code--prereq"><code>$ {{ pip_prereq }}</code></pre>
59+
<div class="lm-mcp-install__code--prereq">
60+
{{ ("$ " ~ pip_prereq) | highlight("console") }}
61+
</div>
5562
<p class="lm-mcp-install__preamble">
5663
Then {{ 'register' if panel.client.kind == 'cli' else 'use this config' }}:
5764
</p>
5865
{% endif %}
5966

60-
<pre class="lm-mcp-install__code"
61-
data-language="{{ panel.language }}"><code>{% if panel.language == 'shell' %}$ {% endif %}{{ panel.body }}</code></pre>
67+
{{ panel.body | highlight(panel.language) }}
6268

6369
{% if variant != 'compact' %}
6470
<p class="lm-mcp-install__config-file">
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# serializer version: 1
2+
# name: test_highlight_filter_matches_sphinx_native[console-claude-code-uvx][highlight_console-claude-code-uvx]
3+
'''
4+
<div class="highlight-console notranslate"><div class="highlight"><pre><span></span><span class="gp">$ </span>claude<span class="w"> </span>mcp<span class="w"> </span>add<span class="w"> </span>libtmux<span class="w"> </span>--<span class="w"> </span>uvx<span class="w"> </span>libtmux-mcp
5+
</pre></div>
6+
</div>
7+
'''
8+
# ---
9+
# name: test_highlight_filter_matches_sphinx_native[console-pip-prereq][highlight_console-pip-prereq]
10+
'''
11+
<div class="highlight-console notranslate"><div class="highlight"><pre><span></span><span class="gp">$ </span>pip<span class="w"> </span>install<span class="w"> </span>--user<span class="w"> </span>--upgrade<span class="w"> </span>libtmux<span class="w"> </span>libtmux-mcp
12+
</pre></div>
13+
</div>
14+
'''
15+
# ---
16+
# name: test_highlight_filter_matches_sphinx_native[json-mcp-config-uvx][highlight_json-mcp-config-uvx]
17+
'''
18+
<div class="highlight-json notranslate"><div class="highlight"><pre><span></span><span class="p">{</span>
19+
<span class="w"> </span><span class="nt">&quot;mcpServers&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
20+
<span class="w"> </span><span class="nt">&quot;libtmux&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
21+
<span class="w"> </span><span class="nt">&quot;command&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;uvx&quot;</span><span class="p">,</span>
22+
<span class="w"> </span><span class="nt">&quot;args&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;libtmux-mcp&quot;</span><span class="p">]</span>
23+
<span class="w"> </span><span class="p">}</span>
24+
<span class="w"> </span><span class="p">}</span>
25+
<span class="p">}</span>
26+
</pre></div>
27+
</div>
28+
'''
29+
# ---

tests/docs/_snapshots.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Snapshot helpers for HTML fragment assertions.
2+
3+
Mirrors the pattern from ``gp-sphinx/tests/_snapshots.py`` — a thin normaliser
4+
plus a ``snapshot_html_fragment`` fixture that wraps syrupy's ``snapshot``
5+
assertion. Keep this file dependency-light: no Sphinx imports.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pathlib
11+
import typing as t
12+
13+
import pytest
14+
15+
if t.TYPE_CHECKING:
16+
from syrupy.assertion import SnapshotAssertion
17+
18+
19+
def _replace_roots(text: str, roots: tuple[pathlib.Path, ...]) -> str:
20+
"""Replace concrete filesystem roots with stable placeholders."""
21+
normalized = text
22+
for index, root in enumerate(roots, start=1):
23+
normalized = normalized.replace(str(root), f"<root-{index}>")
24+
return normalized
25+
26+
27+
def normalize_html_fragment(
28+
fragment: str,
29+
*,
30+
roots: tuple[pathlib.Path, ...] = (),
31+
) -> str:
32+
"""Return a stable HTML fragment string for snapshot assertions."""
33+
normalized = fragment.strip().replace("\r\n", "\n")
34+
return _replace_roots(normalized, roots)
35+
36+
37+
class _HTMLFragmentSnapshot(t.Protocol):
38+
"""Callable signature for the ``snapshot_html_fragment`` fixture."""
39+
40+
def __call__(
41+
self,
42+
fragment: str,
43+
*,
44+
name: str | None = None,
45+
roots: tuple[pathlib.Path, ...] = (),
46+
) -> None: ...
47+
48+
49+
@pytest.fixture
50+
def snapshot_html_fragment(snapshot: SnapshotAssertion) -> _HTMLFragmentSnapshot:
51+
"""Assert a normalized HTML fragment snapshot (see ``gp-sphinx`` pattern)."""
52+
base_snapshot = snapshot.with_defaults()
53+
54+
def _assert(
55+
fragment: str,
56+
*,
57+
name: str | None = None,
58+
roots: tuple[pathlib.Path, ...] = (),
59+
) -> None:
60+
expected = base_snapshot(name=name) if name is not None else base_snapshot
61+
assert normalize_html_fragment(fragment, roots=roots) == expected
62+
63+
return _assert

tests/docs/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import pytest
1515

16+
from ._snapshots import snapshot_html_fragment # noqa: F401 — re-export as fixture
17+
1618
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
1719
_DOCS_DIR = _REPO_ROOT / "docs"
1820
_EXT_DIR = _DOCS_DIR / "_ext"

0 commit comments

Comments
 (0)