diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba7441..63181cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 🐛 FIX: Inline icon roles no longer leak SVG markup into toctree labels and the search index ({pr}`279`, {issue}`99`) +- 🐛 FIX: `article-info` octicons regain their `sd-pr-2` spacing class (previously silently dropped by the HTML writer) ({pr}`279`) - 🐛 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`) - ✨ NEW: `sphinx_design.testing` module with the `normalize_doctree_xml` helper, for downstream extensions' doctree regression tests ({pr}`277`, {issue}`260`) - ♻️ IMPROVE: Static assets (CSS/JS) are now served via Sphinx's standard diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index c2f4831..c688587 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -180,6 +180,19 @@ By default the icon will be of height `1em` (i.e. the height of the font). A specific height can be set after a semi-colon (`;`) with units of either `px`, `em` or `rem`. Additional CSS classes can also be added to the SVG after a second semi-colon (`;`) delimiter. +Icon roles can be used within section titles, and the icon is preserved when the +title is referenced from a `toctree` (whilst no longer leaking its SVG markup +into plain-text contexts such as the search index). + +:::{note} +Icon roles cannot be used inside a `toctree` *entry title* +(the `Title ` form written directly in the `toctree` directive), +because Sphinx parses those titles as plain text, so roles are never processed. +To show an icon next to a page's toctree entry, place the icon role in that +page's own top-level heading instead, and reference the page without an explicit +title. +::: + ### Octicon Icons A coloured icon: {octicon}`report;1em;sd-text-info`, some more text. diff --git a/sphinx_design/article_info.py b/sphinx_design/article_info.py index d003bd3..6f6cfac 100644 --- a/sphinx_design/article_info.py +++ b/sphinx_design/article_info.py @@ -2,7 +2,7 @@ from docutils.parsers.rst import directives from sphinx.application import Sphinx -from .icons import get_octicon +from .icons import create_icon_node, get_octicon from .shared import SEMANTIC_COLORS, SdDirective, create_component, make_choice @@ -148,11 +148,8 @@ def run_with_defaults(self) -> list[nodes.Node]: # noqa: PLR0915 ["sd-col", "sd-col-auto", "sd-d-flex-row", "sd-align-minor-center"], ) self.set_source_info(date_column) - date_icon = nodes.raw( - "", - nodes.Text(get_octicon("calendar", height="16px")), - classes=["sd-pr-2"], - format="html", + date_icon = create_icon_node( + get_octicon("calendar", height="16px", classes=["sd-pr-2"]) ) date_nodes = self._parse_text(date_text, icon=date_icon, parse=parse_fields) date_column.extend(date_nodes) @@ -165,11 +162,8 @@ def run_with_defaults(self) -> list[nodes.Node]: # noqa: PLR0915 ["sd-col", "sd-col-auto", "sd-d-flex-row", "sd-align-minor-center"], ) self.set_source_info(read_time_column) - read_time_icon = nodes.raw( - "", - nodes.Text(get_octicon("clock", height="16px")), - classes=["sd-pr-2"], - format="html", + read_time_icon = create_icon_node( + get_octicon("clock", height="16px", classes=["sd-pr-2"]) ) read_time_nodes = self._parse_text( read_time_text, icon=read_time_icon, parse=parse_fields diff --git a/sphinx_design/icons.py b/sphinx_design/icons.py index 09a153f..a9101c3 100644 --- a/sphinx_design/icons.py +++ b/sphinx_design/icons.py @@ -27,6 +27,14 @@ def setup_icons(app: Sphinx) -> None: for style in ["regular", "outlined", "round", "sharp", "twotone"]: app.add_role("material-" + style, MaterialRole(style)) app.connect("config-inited", add_fontawesome_pkg) + app.add_node( + sd_icon, + html=(visit_sd_icon_html, None), + latex=(visit_sd_icon_skip, None), + text=(visit_sd_icon_skip, None), + man=(visit_sd_icon_skip, None), + texinfo=(visit_sd_icon_skip, None), + ) app.add_node( fontawesome, html=(visit_fontawesome_html, depart_fontawesome_html), @@ -37,6 +45,39 @@ def setup_icons(app: Sphinx) -> None: ) +class sd_icon(nodes.inline, nodes.General): # noqa: N801 + """Inline node for an SVG icon (octicon or material design). + + The rendered ```` markup is carried in the ``svg`` attribute. + + The node deliberately has **no** ``Text`` children, so that ``astext()`` + returns an empty string. This keeps the SVG markup out of plain-text + contexts derived via ``clean_astext`` (toctree labels, the search index, + HTML page titles, ...), which would otherwise be polluted by the raw SVG + when an icon role starts a section title. + """ + + +def create_icon_node(svg: str) -> sd_icon: + """Create an inline icon node carrying the given SVG markup. + + :param svg: The rendered ```` markup for the icon. + :return: An :class:`sd_icon` node with no text children. + """ + return sd_icon("", svg=svg) + + +def visit_sd_icon_html(self, node: nodes.Element) -> None: + """Write the icon SVG markup directly into the HTML output.""" + self.body.append(node["svg"]) + raise nodes.SkipNode + + +def visit_sd_icon_skip(self, node: nodes.Element) -> None: + """Skip the (decorative) icon for non-HTML builders.""" + raise nodes.SkipNode + + @lru_cache(1) def get_octicon_data() -> dict[str, Any]: """Load all octicon data.""" @@ -128,7 +169,7 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: ) prb = self.inliner.problematic(self.rawtext, self.rawtext, msg) return [prb], [msg] - node = nodes.raw("", nodes.Text(svg), format="html") + node = create_icon_node(svg) self.set_source_info(node) return [node], [] @@ -164,11 +205,7 @@ def run_with_defaults(self) -> list[nodes.Node]: cell += nodes.literal(icon, icon) cell = nodes.entry() row += cell - cell += nodes.raw( - "", - get_octicon(icon, classes=classes), - format="html", - ) + cell += create_icon_node(get_octicon(icon, classes=classes)) return [table] @@ -327,6 +364,6 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: ) prb = self.inliner.problematic(self.rawtext, self.rawtext, msg) return [prb], [msg] - node = nodes.raw("", nodes.Text(svg), format="html") + node = create_icon_node(svg) self.set_source_info(node) return [node], [] diff --git a/tests/test_icons.py b/tests/test_icons.py new file mode 100644 index 0000000..1663de7 --- /dev/null +++ b/tests/test_icons.py @@ -0,0 +1,185 @@ +"""Tests for the inline icon roles (octicon / material-* / fontawesome). + +Regression tests for https://github.com/executablebooks/sphinx-design/issues/99: +an inline icon role starting a section title must not leak its ```` markup +into plain-text contexts (toctree labels, the search index), while the icon is +still rendered (as inline SVG) in the HTML output. + +These tests are written to also run in the ``py311-no-myst`` environment: the +core assertions use reStructuredText, and the MyST variants are guarded by +``MYST_PARAM`` (skipped when myst-parser is not installed). +""" + +import json +import re + +import pytest +from sphinx import version_info as sphinx_version_info + +from sphinx_design.icons import get_octicon + +try: + import myst_parser # noqa: F401 + + MYST_INSTALLED = True +except ImportError: + MYST_INSTALLED = False + +MYST_PARAM = pytest.param( + "myst", + marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"), +) + +# A minimal project: a page whose section title *starts* with an octicon role, +# referenced from another page's toctree (the #99 reproduction). +ICON_TITLE_TOCTREE = { + "rst": { + "index.rst": "Home\n====\n\n.. toctree::\n\n page1\n", + "page1.rst": ( + ":octicon:`rocket` Rocket Page\n" + "=================================\n\n" + "Body content here.\n" + ), + }, + "myst": { + "index.md": "# Home\n\n```{toctree}\npage1\n```\n", + "page1.md": "# {octicon}`rocket` Rocket Page\n\nBody content here.\n", + }, +} + + +def _build(sphinx_builder, fmt, files): + """Build a project from a ``{filename: content}`` mapping.""" + if fmt == "rst": + builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]}) + else: + builder = sphinx_builder() + for name, content in files.items(): + builder.src_path.joinpath(name).write_text(content, encoding="utf8") + builder.build() # asserts no warnings + return builder + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_icon_title_in_toctree(fmt, sphinx_builder): + """An icon-leading title renders cleanly in a toctree entry (#99). + + The toctree label must contain the title text exactly once (not mangled or + emptied), and the icon is preserved as an inline ```` inside the entry. + """ + builder = _build(sphinx_builder, fmt, ICON_TITLE_TOCTREE[fmt]) + index_html = (builder.out_path / "index.html").read_text(encoding="utf8") + + # the resolved toctree, rendered inline in the referring page's body + wrapper = re.search( + r'
.*?
', index_html, re.S + ) + assert wrapper, "resolved toctree not found in index.html" + region = wrapper.group(0) + + entries = re.findall(r'
  • .*?
  • ', region, re.S) + assert len(entries) == 1, "expected exactly one toctree entry" + entry = entries[0] + + # the icon is preserved in the toc entry (rendered as inline SVG) + assert "]+>", "", entry).split()) + assert label == "Rocket Page" + assert region.count("Rocket Page") == 1 + + # and the raw SVG never leaks as *text* into the label + assert "<svg" not in region + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_icon_title_search_index_clean(fmt, sphinx_builder): + """No SVG junk leaks into the search index for an icon-leading title (#99). + + The ``titleterms`` (indexed words) are clean on all supported Sphinx + versions — that is the actual #99 fix. The ``titles`` *display* field is + only plain text on Sphinx >= 9: older Sphinx stores the rendered title + HTML there (icon SVG included), which is upstream behaviour that predates + and is unchanged by this fix. + """ + builder = _build(sphinx_builder, fmt, ICON_TITLE_TOCTREE[fmt]) + raw = (builder.out_path / "searchindex.js").read_text(encoding="utf8") + data = json.loads(raw[len("Search.setIndex(") : -1]) + + # the search terms derived from the title are the real words only, + # not SVG attribute names or path-data fragments (all sphinx versions) + titleterms = set(data.get("titleterms", {})) + assert titleterms <= {"home", "page", "rocket"}, titleterms + for junk in ("svg", "path", "viewbox", "width", "height", "aria", "hidden"): + assert junk not in titleterms + + if sphinx_version_info >= (9,): + # sphinx >= 9 stores plain-text titles: no SVG markup anywhere + assert "``. + + Previously the class was set on a ``nodes.raw`` wrapper and silently dropped + by the HTML writer; it must now land on the rendered ```` element. + """ + if fmt == "rst": + files = { + "index.rst": ( + "Article\n=======\n\n" + ".. article-info::\n" + " :author: Jane Doe\n" + " :date: Jan 1, 2026\n" + " :read-time: 5 min read\n" + ) + } + else: + files = { + "index.md": ( + "# Article\n\n" + "```{article-info}\n" + ":author: Jane Doe\n" + ":date: Jan 1, 2026\n" + ":read-time: 5 min read\n" + "```\n" + ) + } + builder = _build(sphinx_builder, fmt, files) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + + for name in ("calendar", "clock"): + svg = re.search(rf"]*sd-octicon-{name}[^>]*>", html) + assert svg, f"{name} octicon not rendered" + assert "sd-pr-2" in svg.group(0), f"sd-pr-2 missing on {name} svg" diff --git a/tests/test_snippets/snippet_post_article-info.xml b/tests/test_snippets/snippet_post_article-info.xml index 878cfe2..debff03 100644 --- a/tests/test_snippets/snippet_post_article-info.xml +++ b/tests/test_snippets/snippet_post_article-info.xml @@ -15,11 +15,9 @@ Executable Books - - + "> Jul 24, 2021 - - +