Skip to content

Commit c0acc48

Browse files
authored
🐛 Stamp sd-card-text on direct paragraphs only, never replace classes (#278)
Fixes two related bugs in how `sd-card-text` is applied to paragraphs in cards and dropdowns. ## Changes - **Dropdown transform no longer wipes user classes**: an inverted conditional (`[] if "classes" in para else para["classes"]` — always true for docutils elements) meant every paragraph in every dropdown body had its existing classes *replaced* by `["sd-card-text"]`. Any user class (e.g. via `rst-class` or MyST attrs) was silently destroyed. Now appends. - **Stamping is limited to direct child paragraphs** in both the dropdown transform and `CardDirective.add_card_child_classes`: previously all *descendant* paragraphs (inside nested admonitions, lists, nested tab-sets, …) were stamped, causing the spacing artefacts in #40. Nested content now renders with its natural spacing, consistent with the same content outside a card. ## Verification - New `tests/test_card_text.py` (rst + myst): user-class preservation, direct-vs-nested stamping for `card`, `dropdown`, and `grid-item-card` (the delegated path), and the #40 two-paragraph reproduction. The regression cases were verified failing against the unfixed code. - Zero existing regression fixtures change: audited — existing fixtures only ever contained direct-child `sd-card-text` paragraphs (which is why #40 went undetected). - Shipped-docs impact audit: the only rendering delta across the whole docs build is two tab-panel paragraphs inside the "tabs in dropdown" example losing the class — provably pixel-identical, since `.sd-tab-content>:last-child{margin-bottom:0 !important}` already forces the same margin either way. - No CSS changes. Follow-up recorded for the card redesign: consider moving stamping to a CSS child selector entirely. Closes #40
1 parent 6e06c98 commit c0acc48

4 files changed

Lines changed: 323 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- 🐛 FIX: Paragraphs inside dropdowns no longer have user classes overwritten by `sd-card-text`, and card/dropdown body styling is applied only to direct child paragraphs, not nested content ({pr}`278`, {issue}`40`)
56
- ✨ NEW: `sphinx_design.testing` module with the `normalize_doctree_xml` helper, for downstream extensions' doctree regression tests ({pr}`277`, {issue}`260`)
67
- ♻️ IMPROVE: Static assets (CSS/JS) are now served via Sphinx's standard
78
`html_static_path` mechanism, rather than being written directly into the

sphinx_design/cards.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,11 @@ def _create_component(
246246
@staticmethod
247247
def add_card_child_classes(node):
248248
"""Add classes to specific child nodes."""
249-
for para in node.findall(nodes.paragraph):
250-
para["classes"] = [*para.get("classes", []), "sd-card-text"]
249+
# only stamp direct child paragraphs of the component (see #40),
250+
# not paragraphs nested inside admonitions, lists, nested cards, etc.
251+
for para in node.children:
252+
if isinstance(para, nodes.paragraph):
253+
para["classes"] = [*para.get("classes", []), "sd-card-text"]
251254
# for title in node.findall(nodes.title):
252255
# title["classes"] = ([] if "classes" not in title else title["classes"]) + [
253256
# "sd-card-title"

sphinx_design/dropdown.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,11 @@ def run(self, **kwargs: Any) -> None:
229229
children=body_children,
230230
)
231231
if use_card:
232-
for para in body_node.findall(nodes.paragraph):
233-
para["classes"] = ([] if "classes" in para else para["classes"]) + [
234-
"sd-card-text"
235-
]
232+
# only stamp direct child paragraphs of the body (see #40),
233+
# and append the class rather than replacing existing classes
234+
for para in body_node.children:
235+
if isinstance(para, nodes.paragraph):
236+
para["classes"] = [*para.get("classes", []), "sd-card-text"]
236237
newnode += body_node
237238
# newnode += open_marker
238239
node.replace_self(newnode)

tests/test_card_text.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
"""Tests for how ``sd-card-text`` is stamped on card and dropdown paragraphs.
2+
3+
Covers two bugs:
4+
5+
- the dropdown HTML transform used to *replace* (rather than append to) the
6+
``classes`` of every body paragraph, silently destroying user-authored
7+
classes;
8+
- both cards and dropdowns used to stamp ``sd-card-text`` on *all* descendant
9+
paragraphs (via ``findall``), including paragraphs nested inside admonitions,
10+
lists, nested cards, etc., causing the spacing artefacts in
11+
https://github.com/executablebooks/sphinx-design/issues/40.
12+
13+
Both sites now stamp only *direct child* paragraphs, and always append.
14+
"""
15+
16+
from collections.abc import Callable
17+
18+
from docutils import nodes
19+
import pytest
20+
21+
from sphinx_design.shared import is_component
22+
23+
from .conftest import SphinxBuilder
24+
25+
try:
26+
import myst_parser # noqa: F401
27+
28+
MYST_INSTALLED = True
29+
except ImportError:
30+
MYST_INSTALLED = False
31+
32+
MYST_PARAM = pytest.param(
33+
"myst",
34+
marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"),
35+
)
36+
37+
38+
def _build(
39+
sphinx_builder: Callable[..., SphinxBuilder],
40+
fmt: str,
41+
rst: str,
42+
myst: str,
43+
myst_extensions: tuple[str, ...] = ("colon_fence",),
44+
) -> SphinxBuilder:
45+
"""Build ``rst`` or ``myst`` source depending on ``fmt``."""
46+
if fmt == "rst":
47+
builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
48+
builder.src_path.joinpath("index.rst").write_text(rst, encoding="utf8")
49+
else:
50+
builder = sphinx_builder(
51+
conf_kwargs={
52+
"extensions": ["myst_parser", "sphinx_design"],
53+
"myst_enable_extensions": list(myst_extensions),
54+
}
55+
)
56+
builder.src_path.joinpath("index.md").write_text(myst, encoding="utf8")
57+
builder.build() # asserts no warnings
58+
return builder
59+
60+
61+
def _direct_paragraphs(node: nodes.Element) -> list[nodes.paragraph]:
62+
"""Return the direct child paragraphs of ``node``."""
63+
return [child for child in node.children if isinstance(child, nodes.paragraph)]
64+
65+
66+
DROPDOWN_USER_CLASS = {
67+
"rst": """
68+
Title
69+
=====
70+
71+
.. dropdown:: My drop
72+
73+
.. rst-class:: my-user-class
74+
75+
A paragraph with a user class.
76+
""",
77+
"myst": """
78+
# Title
79+
80+
````{dropdown} My drop
81+
{.my-user-class}
82+
A paragraph with a user class.
83+
````
84+
""",
85+
}
86+
87+
88+
@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
89+
def test_dropdown_paragraph_keeps_user_class(
90+
fmt: str, sphinx_builder: Callable[..., SphinxBuilder]
91+
):
92+
"""A user class on a dropdown-body paragraph must survive the HTML transform,
93+
alongside ``sd-card-text`` (regression test for the class-wiping bug).
94+
"""
95+
builder = _build(
96+
sphinx_builder,
97+
fmt,
98+
DROPDOWN_USER_CLASS["rst"],
99+
DROPDOWN_USER_CLASS["myst"],
100+
myst_extensions=("colon_fence", "attrs_block"),
101+
)
102+
doctree = builder.get_doctree("index", post_transforms=True)
103+
tagged = [
104+
para
105+
for para in doctree.findall(nodes.paragraph)
106+
if "my-user-class" in para.get("classes", [])
107+
]
108+
assert len(tagged) == 1, [p.pformat() for p in doctree.findall(nodes.paragraph)]
109+
classes = tagged[0]["classes"]
110+
# the user class is preserved, and sd-card-text is appended (not a replacement)
111+
assert "my-user-class" in classes
112+
assert "sd-card-text" in classes
113+
114+
115+
DROPDOWN_NESTED = {
116+
"rst": """
117+
Title
118+
=====
119+
120+
.. dropdown:: My drop
121+
122+
Direct dropdown paragraph.
123+
124+
.. note::
125+
126+
Nested paragraph inside admonition.
127+
""",
128+
"myst": """
129+
# Title
130+
131+
::::{dropdown} My drop
132+
Direct dropdown paragraph.
133+
134+
:::{note}
135+
Nested paragraph inside admonition.
136+
:::
137+
::::
138+
""",
139+
}
140+
141+
142+
@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
143+
def test_dropdown_nested_paragraph_not_stamped(
144+
fmt: str, sphinx_builder: Callable[..., SphinxBuilder]
145+
):
146+
"""``sd-card-text`` is stamped on direct dropdown-body paragraphs only,
147+
not on paragraphs nested inside an admonition (regression test for #40).
148+
"""
149+
builder = _build(
150+
sphinx_builder, fmt, DROPDOWN_NESTED["rst"], DROPDOWN_NESTED["myst"]
151+
)
152+
doctree = builder.get_doctree("index", post_transforms=True)
153+
body = next(doctree.findall(lambda n: is_component(n, "dropdown-body")))
154+
155+
direct = _direct_paragraphs(body)
156+
assert len(direct) == 1
157+
assert "sd-card-text" in direct[0]["classes"]
158+
159+
note = next(body.findall(nodes.note))
160+
nested = list(note.findall(nodes.paragraph))
161+
assert nested
162+
for para in nested:
163+
assert "sd-card-text" not in para.get("classes", [])
164+
165+
166+
CARD_NESTED = {
167+
"rst": """
168+
Title
169+
=====
170+
171+
.. card:: My card
172+
173+
Direct card paragraph.
174+
175+
.. note::
176+
177+
Nested paragraph inside admonition.
178+
""",
179+
"myst": """
180+
# Title
181+
182+
::::{card} My card
183+
Direct card paragraph.
184+
185+
:::{note}
186+
Nested paragraph inside admonition.
187+
:::
188+
::::
189+
""",
190+
}
191+
192+
193+
@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
194+
def test_card_nested_paragraph_not_stamped(
195+
fmt: str, sphinx_builder: Callable[..., SphinxBuilder]
196+
):
197+
"""``sd-card-text`` is stamped on direct card-body paragraphs only,
198+
not on paragraphs nested inside an admonition (regression test for #40).
199+
"""
200+
builder = _build(sphinx_builder, fmt, CARD_NESTED["rst"], CARD_NESTED["myst"])
201+
doctree = builder.get_doctree("index", post_transforms=True)
202+
body = next(doctree.findall(lambda n: is_component(n, "card-body")))
203+
204+
direct = _direct_paragraphs(body)
205+
assert len(direct) == 1
206+
assert "sd-card-text" in direct[0]["classes"]
207+
208+
note = next(body.findall(nodes.note))
209+
nested = list(note.findall(nodes.paragraph))
210+
assert nested
211+
for para in nested:
212+
assert "sd-card-text" not in para.get("classes", [])
213+
214+
215+
DROPDOWN_TWO_PARAS = {
216+
"rst": """
217+
Title
218+
=====
219+
220+
.. dropdown:: My drop
221+
222+
First paragraph.
223+
224+
Second paragraph.
225+
""",
226+
"myst": """
227+
# Title
228+
229+
::::{dropdown} My drop
230+
First paragraph.
231+
232+
Second paragraph.
233+
::::
234+
""",
235+
}
236+
237+
238+
@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
239+
def test_dropdown_two_paragraphs_identical(
240+
fmt: str, sphinx_builder: Callable[..., SphinxBuilder]
241+
):
242+
"""The #40 reproduction: two plain dropdown-body paragraphs should be
243+
stamped identically, so their spacing is consistent.
244+
"""
245+
builder = _build(
246+
sphinx_builder, fmt, DROPDOWN_TWO_PARAS["rst"], DROPDOWN_TWO_PARAS["myst"]
247+
)
248+
doctree = builder.get_doctree("index", post_transforms=True)
249+
body = next(doctree.findall(lambda n: is_component(n, "dropdown-body")))
250+
251+
direct = _direct_paragraphs(body)
252+
assert len(direct) == 2
253+
assert direct[0]["classes"] == direct[1]["classes"] == ["sd-card-text"]
254+
255+
256+
GRID_ITEM_CARD_NESTED = {
257+
"rst": """
258+
Title
259+
=====
260+
261+
.. grid:: 1
262+
263+
.. grid-item-card:: My card
264+
265+
Direct card paragraph.
266+
267+
.. note::
268+
269+
Nested paragraph inside admonition.
270+
""",
271+
"myst": """
272+
# Title
273+
274+
:::::{grid} 1
275+
276+
::::{grid-item-card} My card
277+
Direct card paragraph.
278+
279+
:::{note}
280+
Nested paragraph inside admonition.
281+
:::
282+
::::
283+
:::::
284+
""",
285+
}
286+
287+
288+
@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
289+
def test_grid_item_card_nested_paragraph_not_stamped(
290+
fmt: str, sphinx_builder: Callable[..., SphinxBuilder]
291+
):
292+
"""``grid-item-card`` delegates to the card builder, so it must show the
293+
same behaviour: direct body paragraphs stamped, nested ones not (#40).
294+
"""
295+
builder = _build(
296+
sphinx_builder,
297+
fmt,
298+
GRID_ITEM_CARD_NESTED["rst"],
299+
GRID_ITEM_CARD_NESTED["myst"],
300+
)
301+
doctree = builder.get_doctree("index", post_transforms=True)
302+
body = next(doctree.findall(lambda n: is_component(n, "card-body")))
303+
304+
direct = _direct_paragraphs(body)
305+
assert len(direct) == 1
306+
assert "sd-card-text" in direct[0]["classes"]
307+
308+
note = next(body.findall(nodes.note))
309+
nested = list(note.findall(nodes.paragraph))
310+
assert nested
311+
for para in nested:
312+
assert "sd-card-text" not in para.get("classes", [])

0 commit comments

Comments
 (0)