Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/badges_buttons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>` 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.
Expand Down
16 changes: 5 additions & 11 deletions sphinx_design/article_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
51 changes: 44 additions & 7 deletions sphinx_design/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 ``<svg>`` 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 ``<svg>`` 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."""
Expand Down Expand Up @@ -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], []

Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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], []
185 changes: 185 additions & 0 deletions tests/test_icons.py
Original file line number Diff line number Diff line change
@@ -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 ``<svg>`` 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 ``<svg>`` 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'<div class="toctree-wrapper compound">.*?</div>', index_html, re.S
)
assert wrapper, "resolved toctree not found in index.html"
region = wrapper.group(0)

entries = re.findall(r'<li class="toctree-l1">.*?</li>', 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 "<svg" in entry
assert "sd-octicon-rocket" in entry

# the visible label is the clean title text, exactly once, not mangled/empty
label = " ".join(re.sub(r"<[^>]+>", "", entry).split())
assert label == "Rocket Page"
assert region.count("Rocket Page") == 1

# and the raw SVG never leaks as *text* into the label
assert "&lt;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 "<svg" not in raw
assert any(title.strip() == "Rocket Page" for title in data["titles"]), data[
"titles"
]
assert not any("svg" in title.lower() for title in data["titles"])


@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
def test_icon_body_svg_unchanged(fmt, sphinx_builder):
"""An icon in normal body text renders the exact ``get_octicon`` SVG markup.

This locks the (byte-identical) body rendering that the previous
``nodes.raw`` implementation produced.
"""
content = "A coloured icon: {octicon}`report;1em;sd-text-info`, some text."
if fmt == "rst":
files = {
"index.rst": (
"Heading\n=======\n\n"
"A coloured icon: :octicon:`report;1em;sd-text-info`, some text.\n"
)
}
else:
files = {"index.md": f"# Heading\n\n{content}\n"}
builder = _build(sphinx_builder, fmt, files)
html = (builder.out_path / "index.html").read_text(encoding="utf8")

expected_svg = get_octicon("report", height="1em", classes=["sd-text-info"])
assert expected_svg in html


@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
def test_article_info_icon_spacing_class(fmt, sphinx_builder):
"""Article-info octicons carry the ``sd-pr-2`` spacing class on the ``<svg>``.

Previously the class was set on a ``nodes.raw`` wrapper and silently dropped
by the HTML writer; it must now land on the rendered ``<svg>`` 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"<svg[^>]*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"
6 changes: 2 additions & 4 deletions tests/test_snippets/snippet_post_article-info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@
Executable Books
<container classes="sd-col sd-col-auto sd-d-flex-row sd-align-minor-center" design_component="grid-item" is_div="True">
<paragraph classes="sd-p-0 sd-m-0">
<raw classes="sd-pr-2" format="html" xml:space="preserve">
<svg version="1.1" width="16.0px" height="16.0px" class="sd-octicon sd-octicon-calendar" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z"></path></svg>
<sd_icon svg="<svg version="1.1" width="16.0px" height="16.0px" class="sd-octicon sd-octicon-calendar sd-pr-2" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z"></path></svg>">
Jul 24, 2021
<container classes="sd-col sd-col-auto sd-d-flex-row sd-align-minor-center" design_component="grid-item" is_div="True">
<paragraph classes="sd-p-0 sd-m-0">
<raw classes="sd-pr-2" format="html" xml:space="preserve">
<svg version="1.1" width="16.0px" height="16.0px" class="sd-octicon sd-octicon-clock" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"></path></svg>
<sd_icon svg="<svg version="1.1" width="16.0px" height="16.0px" class="sd-octicon sd-octicon-clock sd-pr-2" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"></path></svg>">
5 min read
Loading
Loading