From fb30797749ae2e052fdf4689788163c020df67b6 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 14 Jul 2026 16:14:30 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20tooltip=20suffix=20syntax=20t?= =?UTF-8?q?o=20badge=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badge roles now accept a trailing `; tooltip` suffix, rendered as a native HTML `title` attribute (closes #81). The suffix is split on the last unescaped `;` (a literal `;` is written `\;`), mirroring the icon roles' string grammar via the new `split_tooltip` helper. Since `;` is a legal character in URLs and reference targets, the `bdg-link-*`/`bdg-ref-*` families only accept the suffix after a complete explicit `title ` form (the `;` must follow the closing `>`); a bare target is never split, so existing targets containing `;` render byte-identically to before. The `\;` escape is resolved uniformly for both parsers (docutils NUL-encodes escapes, MyST forwards backslashes verbatim). - plain badges use a new `sd_badge` node (a `nodes.inline` whose HTML visitor emits `title` when set, with explicit passthrough visitors for latex/text/man/texinfo); output without a tooltip is byte-identical to before (only the doctree node name changes, hence the badge-basic fixture regeneration) - `bdg-link-*` set `reftitle` on the reference (native `title`) - `bdg-ref-*` carry the tooltip across cross-reference resolution using the #281 marker-class + document-stash pattern (an arbitrary attribute does not survive resolution), applied as `reftitle` by a post-transform that runs after the resolver; no marker/attribute leaks into output Also removes a stale `TODO escape HTML` on the button `:tooltip:` option (the HTML writer already escapes attribute values) and the dead commented-out `reftitle` code in the link badge role. --- CHANGELOG.md | 5 + docs/badges_buttons.md | 43 ++ sphinx_design/badges_buttons.py | 298 ++++++++++- tests/test_badge_tooltips.py | 467 ++++++++++++++++++ .../snippet_post_badge-basic.xml | 46 +- .../test_snippets/snippet_pre_badge-basic.xml | 46 +- 6 files changed, 852 insertions(+), 53 deletions(-) create mode 100644 tests/test_badge_tooltips.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17656ed..4db2da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- ✨ NEW: Badge roles accept a trailing `` ; tooltip `` suffix, rendered as a + native HTML `title` tooltip on all `bdg-*` families; for the link/ref badges + the suffix applies only after the explicit `text ` form (semicolons + are valid in URLs and reference targets), and a literal `;` in badge text is + escaped as `` \; `` ({pr}`286`, {issue}`81`) - ✨ NEW: FontAwesome v6 roles (`fa-solid`/`fa-brands`/`fa-regular`) and declarative CSS loading via the new `sd_fontawesome_source` / `sd_fontawesome_cdn_url` options (the supported path for FontAwesome Pro diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index bd4ff01..3a2c410 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -53,6 +53,49 @@ The syntax is the same as for the `ref` role. ```` ````` +### Badge tooltips + +Any badge can be given a tooltip (shown on hover, via the HTML `title` +attribute) by appending a `; tooltip` suffix to its text. +This works for every badge family: + +{bdg-primary}`stable ; A released, supported version` +{bdg-link-info}`docs ; Opens the documentation` +{bdg-ref-primary}`badges ; Jump to the badges section` + +````{tab-set-code} +```markdown +{bdg-primary}`stable ; A released, supported version` +{bdg-link-info}`docs ; Opens the documentation` +{bdg-ref-primary}`badges ; Jump to the badges section` +``` +```rst +:bdg-primary:`stable ; A released, supported version` +:bdg-link-info:`docs ; Opens the documentation` +:bdg-ref-primary:`badges ; Jump to the badges section` +``` +```` + +The tooltip is the text after the **last** unescaped semicolon; both the badge +text and the tooltip are stripped of surrounding whitespace. +To include a literal semicolon in the badge text, escape it as `\;` +(for example `` {bdg}`step 1\; step 2` ``); a trailing bare `;` (with nothing +after it) is not treated as a tooltip and is kept in the badge text. + +Because semicolons are valid in URLs and reference targets, the link and +reference badges (`bdg-link-*`, `bdg-ref-*`) only recognise the tooltip suffix +after the explicit `text ` form -- a bare target such as +`` {bdg-link-primary}`https://example.com/a;b` `` is never split. To add a +tooltip to a link/ref badge, use the explicit form: +`` {bdg-link-primary}`docs ; Opens the docs` ``. +A `bdg-ref` tooltip overrides the reference's automatic title. + +```{warning} +`title` tooltips are **not** accessible to keyboard or touch users, and are +not surfaced by all screen readers. Do not put essential information in a +tooltip alone -- keep it in the visible badge text (or nearby prose) as well. +``` + See [Bootstrap badges](https://getbootstrap.com/docs/5.0/components/badge/) for more information, and related [Material Design chips](https://material.io/components/chip). (buttons)= diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index 1d228fa..ceb2e67 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -1,7 +1,11 @@ +from collections.abc import Sequence +import re from typing import Any from docutils import nodes from docutils.parsers.rst import directives +from docutils.parsers.rst.states import Inliner +from docutils.utils import unescape from sphinx import addnodes from sphinx.application import Sphinx from sphinx.transforms.post_transforms import SphinxPostTransform @@ -43,10 +47,21 @@ def setup_badges_and_buttons(app: Sphinx) -> None: XRefBadgeRole(color, outline=True), ) + app.add_node( + sd_badge, + html=(visit_sd_badge_html, depart_sd_badge_html), + latex=(_visit_sd_badge_passthrough, _depart_sd_badge_passthrough), + text=(_visit_sd_badge_passthrough, _depart_sd_badge_passthrough), + man=(_visit_sd_badge_passthrough, _depart_sd_badge_passthrough), + texinfo=(_visit_sd_badge_passthrough, _depart_sd_badge_passthrough), + ) + app.add_directive(DIRECTIVE_NAME_BUTTON_LINK, ButtonLinkDirective) app.add_directive(DIRECTIVE_NAME_BUTTON_REF, ButtonRefDirective) app.add_post_transform(ButtonRefContentStash) app.add_post_transform(ButtonRefContentGraft) + app.add_post_transform(BadgeRefTooltipStash) + app.add_post_transform(BadgeRefTooltipGraft) def create_bdg_classes(color: str | None, outline: bool) -> list[str]: @@ -64,7 +79,180 @@ def create_bdg_classes(color: str | None, outline: bool) -> list[str]: return classes -class BadgeRole(SphinxRole): +_ESCAPE_RE = re.compile(r"\\(.)", re.DOTALL) + + +def _resolve_escapes(text: str) -> str: + """Resolve backslash escapes (``\\x`` -> ``x``, so ``\\;`` -> ``;``).""" + return _ESCAPE_RE.sub(r"\1", text) + + +def _last_unescaped_semicolon(text: str) -> int | None: + """Return the index of the last ``;`` not escaped by a backslash, or ``None``. + + A ``;`` counts as escaped when it is preceded by an odd number of + backslashes (so ``\\;`` is literal, but ``\\\\;`` is an escaped backslash + followed by an unescaped ``;``). + """ + index = len(text) + while (index := text.rfind(";", 0, index)) != -1: + before = text[:index] + backslashes = len(before) - len(before.rstrip("\\")) + if backslashes % 2 == 0: + return index + return None + + +def split_tooltip(text: str) -> tuple[str, str | None]: + r"""Split a badge label into its text and an optional tooltip suffix. + + This is a small, parser-portable string grammar, mirroring the + ``name;height;classes`` grammar of the icon roles:: + + main -> (main, None) + main ; tooltip -> (main, tooltip) + + The tooltip is the part after the **last unescaped** semicolon (``;``). + A literal semicolon is written ``\;`` (a backslash escapes the following + character); these escapes are resolved in both returned parts, and both + parts are stripped of surrounding whitespace. + + There is no tooltip when the text contains no unescaped ``;``, or when the + tooltip part is empty (e.g. a trailing ``;``); in that case the whole + (escape-resolved, stripped) text is returned as ``main``. + + Note: the link/ref badge roles constrain this grammar further, accepting a + tooltip suffix only after a complete explicit ``title `` form, + because ``;`` is a legal character in URLs and reference targets (see + :class:`_TooltipRoleMixin`). + + :param text: the raw label text (using ``\;`` for a literal ``;``). + :return: a ``(main, tooltip)`` tuple; ``tooltip`` is ``None`` when no + non-empty tooltip suffix is present. + """ + index = _last_unescaped_semicolon(text) + if index is None: + return _resolve_escapes(text.strip()), None + tooltip = _resolve_escapes(text[index + 1 :].strip()) + if not tooltip: + # a trailing (empty) tooltip is treated as no tooltip at all, leaving + # the text - including that final ``;`` - as the label + return _resolve_escapes(text.strip()), None + return _resolve_escapes(text[:index].strip()), tooltip + + +class sd_badge(nodes.inline, nodes.General): # noqa: N801 + """Inline node for a badge. + + Identical to a plain :class:`docutils.nodes.inline` (a ```` in HTML), + except that when its ``tooltip`` attribute is set the HTML visitor emits a + ``title`` attribute for a native tooltip. + """ + + +def visit_sd_badge_html(self: Any, node: nodes.Element) -> None: + """Open the badge ````, adding a ``title`` when a tooltip is set.""" + if node.get("tooltip"): + # ``starttag`` HTML-escapes attribute values (via ``attval``) + self.body.append(self.starttag(node, "span", "", title=node["tooltip"])) + else: + # byte-identical to the default ``visit_inline`` for a badge: the badge + # classes never match ``supported_inline_tags``, so ``inline`` also just + # emits ``self.starttag(node, "span", "")`` + self.body.append(self.starttag(node, "span", "")) + + +def depart_sd_badge_html(self: Any, node: nodes.Element) -> None: + """Close the badge ````.""" + self.body.append("") + + +def _visit_sd_badge_passthrough(self: Any, node: nodes.Element) -> None: + """Render badge children verbatim for non-HTML builders (no wrapper).""" + + +def _depart_sd_badge_passthrough(self: Any, node: nodes.Element) -> None: + """Counterpart to :func:`_visit_sd_badge_passthrough`.""" + + +_EXPLICIT_TARGET_RE = re.compile(r"^(.+?)\s*(?$", re.DOTALL) +"""The explicit ``title `` reference form: docutils'/sphinx's +``explicit_title_re``, with backslash- (rather than NUL-) escape semantics, +for matching against backslash-restored role text.""" + +_RAW_ESCAPED_SEMICOLON_RE = re.compile(r"(?`` form - i.e. the ``;`` follows the closing + ``>`` - because ``;`` is a legal character in URLs and reference targets; + the bare form (where the whole text is the target) is never split. + + The parsed tooltip (or ``None``) is stored on :attr:`tooltip`, and the + remaining text - with the suffix removed - is forwarded to the base role, + so that ``ReferenceRole``'s title/target parsing still applies to it. + """ + + #: the tooltip parsed from the current role invocation, if any + tooltip: str | None = None + + #: when true, only accept a tooltip suffix after an explicit + #: ``title `` form (set by the link/ref badge roles, whose bare + #: form is a target in which ``;`` is a legal character) + tooltip_requires_explicit_target: bool = False + + def __call__( # noqa: PLR0913 + self, + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: Inliner, + options: dict[str, Any] | None = None, + content: Sequence[str] = (), + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Split off any tooltip, then defer to the base role.""" + # ``text`` differs by parser: docutils (rST) encodes a backslash escape + # as a NUL marker (``\x00``) + the escaped char, while myst-parser + # passes backslashes through verbatim. Restoring NULs to backslashes - + # a 1:1, index-preserving replacement - yields the documented ``\;`` + # grammar form for both parsers. + self.tooltip = None + grammar = unescape(text, restore_backslashes=True) + index = _last_unescaped_semicolon(grammar) + while index is not None: + if not self.tooltip_requires_explicit_target or _EXPLICIT_TARGET_RE.match( + grammar[:index].rstrip() + ): + break + # the ``;`` is part of a (bare or explicit) reference target, + # where it is a legal character: try any earlier candidate + index = _last_unescaped_semicolon(grammar[:index]) + if index is not None: + tooltip = _resolve_escapes(grammar[index + 1 :].strip()) + if tooltip: + self.tooltip = tooltip + # slice the *original* text, so rST keeps its NUL-escaped form + # and the base role unescapes / parses it exactly as usual + text = text[:index].strip() + # resolve raw (MyST-style) ``\;`` escapes in the forwarded text; a + # no-op for rST, whose escapes are NUL-encoded and resolved downstream + text = _RAW_ESCAPED_SEMICOLON_RE.sub(";", text) + return super().__call__(name, rawtext, text, lineno, inliner, options, content) + + +class BadgeRole(_TooltipRoleMixin): """Role to display a badge.""" def __init__(self, color: str | None = None, *, outline: bool = False) -> None: @@ -74,18 +262,23 @@ def __init__(self, color: str | None = None, *, outline: bool = False) -> None: def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Run the role.""" - node = nodes.inline( + node = sd_badge( self.rawtext, self.text, classes=create_bdg_classes(self.color, self.outline), ) + if self.tooltip: + node["tooltip"] = self.tooltip self.set_source_info(node) return [node], [] -class LinkBadgeRole(ReferenceRole): +class LinkBadgeRole(_TooltipRoleMixin, ReferenceRole): """Role to display a badge with an external link.""" + # ``;`` is legal in URLs: tooltips only after ``text `` + tooltip_requires_explicit_target = True + def __init__(self, color: str | None = None, *, outline: bool = False) -> None: super().__init__() self.color = color @@ -100,15 +293,19 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: ) # TODO open in new tab self.set_source_info(node) - # if self.target != self.title: - # node["reftitle"] = self.target + if self.tooltip: + # a reference renders ``reftitle`` as a native ``title`` attribute + node["reftitle"] = self.tooltip node += nodes.inline(self.title, self.title) return [node], [] -class XRefBadgeRole(ReferenceRole): +class XRefBadgeRole(_TooltipRoleMixin, ReferenceRole): """Role to display a badge with an internal link.""" + # ``;`` is legal in reference targets: tooltips only after ``text `` + tooltip_requires_explicit_target = True + def __init__(self, color: str | None = None, *, outline: bool = False) -> None: super().__init__() self.color = color @@ -126,6 +323,13 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: "refwarn": True, } node = addnodes.pending_xref(self.rawtext, **options) + if self.tooltip: + # ``reftitle`` cannot be set here: cross-reference resolution builds + # a fresh reference and only copies "basic" attributes (ids/classes/ + # names) onto it, so an arbitrary attribute would be lost. Carry the + # tooltip via a transient attribute that :class:`BadgeRefTooltipStash` + # converts (before resolution) into the #281 marker-class mechanism. + node["sd_tooltip"] = self.tooltip self.set_source_info(node) node += nodes.inline(self.title, self.title, classes=["xref", "any"]) return [node], [] @@ -183,7 +387,9 @@ def run_with_defaults(self) -> list[nodes.Node]: # TODO open in new tab self.set_source_info(node) if "tooltip" in self.options: - node["reftitle"] = self.options["tooltip"] # TODO escape HTML + # ``reftitle`` is rendered as the HTML ``title`` attribute, whose + # value the writer HTML-escapes (via ``starttag``/``attval``) + node["reftitle"] = self.options["tooltip"] if self.content: textnodes, _ = self.state.inline_text( @@ -369,3 +575,81 @@ def run(self, **kwargs: Any) -> None: # graft is a no-op: such resolvers build on the passed # contnode, which was never flattened by the std domain. setattr(self.document, _BUTTON_REF_STASH_ATTR, {}) + + +_BADGE_REF_TOOLTIP_STASH_ATTR = "sd_badge_ref_tooltip" +"""Name of the (transient) python attribute on the ``document`` object, +mapping marker class names to stashed ``bdg-ref`` tooltip strings.""" + +_BADGE_REF_TOOLTIP_MARKER_PREFIX = "sd-badge-ref-tooltip-" +"""Prefix of the transient marker classes used to correlate a ``bdg-ref`` +``pending_xref`` with its resolved reference; never present in final output.""" + + +class BadgeRefTooltipStash(SphinxPostTransform): + """Stash each ``bdg-ref`` tooltip before cross-reference resolution. + + An arbitrary node attribute (such as the ``sd_tooltip`` set by + :class:`XRefBadgeRole`) does not survive cross-reference resolution: the + resolver builds a fresh reference and docutils' ``update_basic_atts`` only + copies the "basic" attributes (ids/classes/names). Mirroring the #281 + machinery, we therefore move the tooltip into a document-level stash keyed + by a unique marker *class* appended to the ``pending_xref`` - classes + *are* copied onto the resolved node - and drop the transient attribute so + nothing leaks into the (pre-resolution) doctree. + """ + + # must run before every cross-reference resolver (built-in + # ``ReferencesResolver`` is priority 10; myst-parser's is priority 9) + default_priority = 5 + + def run(self, **kwargs: Any) -> None: + """Tag and stash every ``bdg-ref`` tooltip.""" + stash: dict[str, str] = {} + for index, node in enumerate(self.document.findall(addnodes.pending_xref)): + if "sd_tooltip" not in node: + continue + marker = f"{_BADGE_REF_TOOLTIP_MARKER_PREFIX}{index}" + node["classes"].append(marker) + stash[marker] = node["sd_tooltip"] + # the attribute has served its purpose; remove it so it can never + # leak into a pickled doctree or (XML) serialisation + del node["sd_tooltip"] + # a plain (transient) python attribute, as for #281 + setattr(self.document, _BADGE_REF_TOOLTIP_STASH_ATTR, stash) + + +class BadgeRefTooltipGraft(SphinxPostTransform): + """Apply stashed ``bdg-ref`` tooltips as ``reftitle`` after resolution. + + Counterpart to :class:`BadgeRefTooltipStash`: find the node that inherited + each marker class from its ``pending_xref``, strip the marker, and, for a + resolved reference, set ``reftitle`` (rendered as the HTML ``title``) to the + stashed tooltip. A ``bdg-ref`` without a tooltip has no stash entry, so an + auto-generated ``reftitle`` (e.g. a section title) is left untouched. + """ + + # run after the built-in ``ReferencesResolver`` (priority 10) and the #281 + # graft (priority 11); ordering relative to the latter is immaterial (they + # act on disjoint marker sets and attributes), but running after resolution + # is essential, so the marker has reached the resolved reference + default_priority = 12 + + def run(self, **kwargs: Any) -> None: + """Move each stashed tooltip onto its resolved reference.""" + stash: dict[str, str] = getattr( + self.document, _BADGE_REF_TOOLTIP_STASH_ATTR, {} + ) + if not stash: + return + for element in list(self.document.findall(nodes.Element)): + classes = element.get("classes", []) + for marker in [cls for cls in classes if cls in stash]: + classes.remove(marker) + if isinstance(element, nodes.reference): + # the tooltip overrides any auto-generated reftitle + element["reftitle"] = stash[marker] + # an unresolved xref leaves the (non-reference) content node + # carrying the marker: stripping it is enough (no link, so no + # tooltip target) + setattr(self.document, _BADGE_REF_TOOLTIP_STASH_ATTR, {}) diff --git a/tests/test_badge_tooltips.py b/tests/test_badge_tooltips.py new file mode 100644 index 0000000..57d505a --- /dev/null +++ b/tests/test_badge_tooltips.py @@ -0,0 +1,467 @@ +"""Tests for ``title``-attribute tooltips on the badge roles (#81). + +Every ``bdg-*`` role family accepts a trailing ``; tooltip`` suffix (see +:func:`sphinx_design.badges_buttons.split_tooltip`), rendered as a native HTML +``title`` attribute: + +* plain badges (``bdg-*``) via a new ``sd_badge`` node, +* external-link badges (``bdg-link-*``) via ``reference`` ``reftitle``, +* cross-reference badges (``bdg-ref-*``) via a ``reftitle`` grafted onto the + *resolved* reference (the pre-resolution attribute would not survive). + +Since ``;`` is a legal character in URLs and reference targets, the link/ref +families only accept the suffix after a complete explicit ``title `` +form (the ``;`` must follow the closing ``>``); a bare target is never split, +so pre-existing targets containing ``;`` render exactly as before. + +The suffix must not change output when absent, must be HTML-escaped, and must +never leak its transient carry mechanism (marker classes / ``sd_tooltip`` +attribute) into the HTML or the post-transform doctree. + +Written to also run under ``py311-no-myst``: core assertions use +reStructuredText, and the MyST variants are guarded by ``MYST_PARAM``. +""" + +from docutils import nodes +import pytest +from sphinx import addnodes + +from sphinx_design.badges_buttons import ( + _BADGE_REF_TOOLTIP_MARKER_PREFIX, + sd_badge, + split_tooltip, +) + +try: + import myst_parser # noqa: F401 + + MYST_INSTALLED = True +except ImportError: + MYST_INSTALLED = False + +# guard MyST variants so the ``py311-no-myst`` environment still passes +MYST_PARAM = pytest.param( + "myst", + marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"), +) + + +def _build(sphinx_builder, fmt, rst, myst, *, assert_pass=True): + """Build a single-document project written in ``rst`` or ``myst``.""" + if fmt == "rst": + builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]}) + builder.src_path.joinpath("index.rst").write_text(rst, encoding="utf8") + else: + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["myst_parser", "sphinx_design"], + "myst_enable_extensions": ["colon_fence"], + } + ) + builder.src_path.joinpath("index.md").write_text(myst, encoding="utf8") + builder.build(assert_pass=assert_pass) + return builder + + +# --------------------------------------------------------------------------- +# split_tooltip unit tests (parser-portable string grammar) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + # no ; -> no tooltip, text unchanged + ("plain badge", ("plain badge", None)), + ("", ("", None)), + # simple split, both parts stripped + ("stable ; A released version", ("stable", "A released version")), + (" a ; b ", ("a", "b")), + # split on the LAST unescaped ; + ("a ; b ; c", ("a ; b", "c")), + # \; escapes a literal ; (resolved in the output) + (r"a\;b", ("a;b", None)), + (r"a\;b ; tip", ("a;b", "tip")), + (r"main ; tip\;with semi", ("main", "tip;with semi")), + (r"a\;b\;c", ("a;b;c", None)), + # a trailing (empty) tooltip is ignored: no tooltip, ; kept in text + ("trailing ;", ("trailing ;", None)), + ("trailing ; ", ("trailing ;", None)), + ], +) +def test_split_tooltip(text, expected): + """``split_tooltip`` implements the documented ``; tooltip`` grammar.""" + assert split_tooltip(text) == expected + + +# --------------------------------------------------------------------------- +# plain badges (BadgeRole -> sd_badge) +# --------------------------------------------------------------------------- + +PLAIN = { + "rst": """ +Heading +======= + +Here is a :bdg-primary:`stable ; A released version` badge. + +And a plain :bdg:`no tooltip` badge. +""", + "myst": """ +# Heading + +Here is a {bdg-primary}`stable ; A released version` badge. + +And a plain {bdg}`no tooltip` badge. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_plain_badge_tooltip(fmt, sphinx_builder): + """A plain badge renders its tooltip as a ``title`` on the ````.""" + builder = _build(sphinx_builder, fmt, PLAIN["rst"], PLAIN["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # the tooltipped badge: title set, suffix stripped from the visible text + assert ( + 'stable' in html + ) + # the plain badge is unchanged: a bare span, no title + assert 'no tooltip' in html + + # the doctree uses the sd_badge node, carrying the tooltip only when set + doctree = builder.get_doctree("index") + badges = list(doctree.findall(sd_badge)) + assert len(badges) == 2 + assert badges[0]["tooltip"] == "A released version" + assert "tooltip" not in badges[1] + + +ESCAPE = { + "rst": r""" +Heading +======= + +Danger :bdg-danger:`x ; a & "q"` here. +""", + "myst": r""" +# Heading + +Danger {bdg-danger}`x ; a & "q"` here. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_plain_badge_tooltip_escaped(fmt, sphinx_builder): + """Special characters in a badge tooltip are HTML-escaped in ``title``.""" + builder = _build(sphinx_builder, fmt, ESCAPE["rst"], ESCAPE["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert 'title="a <b> & "q""' in html + # the raw (unescaped) characters never appear inside the attribute + assert 'title="a ' not in html + + +ESCAPED_SEMICOLON = { + "rst": r""" +Heading +======= + +E1 :bdg:`step 1\; step 2` here. + +E2 :bdg-secondary:`a\;b ; tip` there. +""", + "myst": r""" +# Heading + +E1 {bdg}`step 1\; step 2` here. + +E2 {bdg-secondary}`a\;b ; tip` there. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_plain_badge_escaped_semicolon_label(fmt, sphinx_builder): + r"""``\;`` renders as a literal ``;`` in the badge text, in both parsers. + + reStructuredText NUL-encodes backslash escapes while MyST forwards them + verbatim; both must end up as a plain ``;`` (no backslash) in the output, + and an escaped ``;`` must never act as a tooltip separator. + """ + builder = _build( + sphinx_builder, fmt, ESCAPED_SEMICOLON["rst"], ESCAPED_SEMICOLON["myst"] + ) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # the escaped ; is literal text (not a separator) and loses its backslash + assert 'step 1; step 2' in html + assert "step 1\\; step 2" not in html + # an escaped ; in the label coexists with a real tooltip suffix + assert ( + 'a;b' in html + ) + + +# --------------------------------------------------------------------------- +# external-link badges (LinkBadgeRole -> reference reftitle) +# --------------------------------------------------------------------------- + +LINK = { + "rst": """ +Heading +======= + +Docs :bdg-link-info:`docs ; Opens the documentation`. + +Plain :bdg-link-primary:`https://example.com`. +""", + "myst": """ +# Heading + +Docs {bdg-link-info}`docs ; Opens the documentation`. + +Plain {bdg-link-primary}`https://example.com`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_link_badge_tooltip(fmt, sphinx_builder): + """An external-link badge renders its tooltip as ``title`` on the ````.""" + builder = _build(sphinx_builder, fmt, LINK["rst"], LINK["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # the explicit title, the target and the tooltip are all handled + assert 'href="https://example.com"' in html + assert 'title="Opens the documentation"' in html + assert ">docs" in html + # the no-tooltip link badge gains no title attribute + doctree = builder.get_doctree("index") + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 2 + assert refs[0]["reftitle"] == "Opens the documentation" + assert "reftitle" not in refs[1] + + +LINK_SEMI = { + "rst": """ +Heading +======= + +L1 :bdg-link-primary:`https://example.com/a;b` bare. + +L2 :bdg-link-info:`docs ` explicit. + +L3 :bdg-link-success:`docs ; Opens the docs` tooltip. + +L4 :bdg-link-warning:`https://example.com ; tip` bare-suffix. +""", + "myst": """ +# Heading + +L1 {bdg-link-primary}`https://example.com/a;b` bare. + +L2 {bdg-link-info}`docs ` explicit. + +L3 {bdg-link-success}`docs ; Opens the docs` tooltip. + +L4 {bdg-link-warning}`https://example.com ; tip` bare-suffix. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_link_badge_semicolon_in_target(fmt, sphinx_builder): + """``;`` is legal in URLs: a link badge target is never corrupted by the + tooltip grammar. + + Only a ``;`` *after* the closing ``>`` of the explicit ``text `` + form starts a tooltip; bare targets (with or without a would-be suffix) + pass through byte-identically to the pre-tooltip behaviour. + """ + builder = _build(sphinx_builder, fmt, LINK_SEMI["rst"], LINK_SEMI["myst"]) + doctree = builder.get_doctree("index") + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 4 + # L1: bare URL containing ';' - never split, no tooltip + assert refs[0]["refuri"] == "https://example.com/a;b" + assert "reftitle" not in refs[0] + assert refs[0].astext() == "https://example.com/a;b" + # L2: explicit form, ';' inside - never split + assert refs[1]["refuri"] == "https://example.com/a;b" + assert "reftitle" not in refs[1] + assert refs[1].astext() == "docs" + # L3: explicit form, ';' after '>' - tooltip split, target intact + assert refs[2]["refuri"] == "https://example.com/a;b" + assert refs[2]["reftitle"] == "Opens the docs" + assert refs[2].astext() == "docs" + # L4: bare form with a would-be suffix - no explicit target, no split + # (the whole text is the target, exactly as before this feature) + assert refs[3]["refuri"] == "https://example.com ; tip" + assert "reftitle" not in refs[3] + + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert html.count('href="https://example.com/a;b"') == 3 + + +# --------------------------------------------------------------------------- +# cross-reference badges (XRefBadgeRole -> reftitle grafted post-resolution) +# --------------------------------------------------------------------------- + +REF = { + "rst": """ +Heading +======= + +.. _my-target: + +My Target Section +----------------- + +See :bdg-ref-primary:`Target ; Jump to the target` for details. + +Also :bdg-ref-primary:`my-target` without a tooltip. +""", + "myst": """ +# Heading + +(my-target)= + +## My Target Section + +See {bdg-ref-primary}`Target ; Jump to the target` for details. + +Also {bdg-ref-primary}`my-target` without a tooltip. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_ref_badge_tooltip_survives_resolution(fmt, sphinx_builder): + """A ``bdg-ref`` tooltip is applied to the *resolved* reference (#81). + + The tooltip (given after the explicit ``text `` form) is carried + across cross-reference resolution and rendered as a ``title``; a + ``bdg-ref`` *without* a tooltip keeps the resolver's behaviour (no + ``title`` is invented). + """ + builder = _build(sphinx_builder, fmt, REF["rst"], REF["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + + # both badges resolve to the target anchor + assert html.count('href="#my-target"') == 2 + # the tooltipped one carries the title; it survived resolution + assert ( + '' in html + ) + + doctree = builder.get_doctree("index", post_transforms=True) + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 2 + # one has the tooltip (with its explicit title as the badge text); the + # other was left untouched (no reftitle invented for the tooltip-less one) + with_tooltip = [r for r in refs if r.get("reftitle") == "Jump to the target"] + without = [r for r in refs if "reftitle" not in r] + assert len(with_tooltip) == 1 + assert with_tooltip[0].astext() == "Target" + assert len(without) == 1 + + +REF_SEMI = { + "rst": """ +Heading +======= + +R1 :bdg-ref-primary:`my-target ; tip` bare-suffix. + +R2 :bdg-ref-info:`text ` semi-in-target. +""", + "myst": """ +# Heading + +R1 {bdg-ref-primary}`my-target ; tip` bare-suffix. + +R2 {bdg-ref-info}`text ` semi-in-target. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_ref_badge_semicolon_target_never_split(fmt, sphinx_builder): + """``;`` is legal in reference targets: a ``bdg-ref`` target is never + corrupted by the tooltip grammar. + + A bare target with a would-be suffix, and a ``;`` inside an explicit + ```` group, are both passed through intact (here: unresolved, + exactly as on the pre-tooltip code, since no such labels exist). + """ + builder = _build( + sphinx_builder, fmt, REF_SEMI["rst"], REF_SEMI["myst"], assert_pass=False + ) + # the *full* target strings reach the resolver (proof of no split) ... + doctree = builder.get_doctree("index") + targets = [x["reftarget"] for x in doctree.findall(addnodes.pending_xref)] + assert targets == ["my-target ; tip", "my;label"] + # ... and its missing-reference warnings name them verbatim + assert "my-target ; tip" in builder.warnings + assert "my;label" in builder.warnings + + # no tooltip was produced anywhere, and nothing transient leaks + html = (builder.out_path / "index.html").read_text(encoding="utf8") + post = builder.get_doctree("index", post_transforms=True) + assert 'title="tip"' not in html + assert not [r for r in post.findall(nodes.reference) if "reftitle" in r] + assert not [n for n in post.findall(nodes.Element) if "sd_tooltip" in n] + for haystack in (html, post.pformat()): + assert _BADGE_REF_TOOLTIP_MARKER_PREFIX not in haystack + assert "sd_tooltip" not in haystack + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_ref_badge_no_marker_or_attr_leak(fmt, sphinx_builder): + """The transient tooltip carry mechanism never leaks into any output.""" + builder = _build(sphinx_builder, fmt, REF["rst"], REF["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + doctree = builder.get_doctree("index", post_transforms=True) + pformat = doctree.pformat() + + for haystack in (html, pformat): + assert _BADGE_REF_TOOLTIP_MARKER_PREFIX not in haystack + assert "sd_tooltip" not in haystack + + +# --------------------------------------------------------------------------- +# buttons: existing :tooltip: option must be HTML-escaped +# --------------------------------------------------------------------------- + +BUTTON = { + "rst": """ +Heading +======= + +.. button-link:: https://example.com + :tooltip: a & "q" + + Button +""", + "myst": """ +# Heading + +```{button-link} https://example.com +:tooltip: a & "q" + +Button +``` +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_button_tooltip_escaped(fmt, sphinx_builder): + """A ``button`` ``:tooltip:`` is HTML-escaped in the ``title`` attribute.""" + builder = _build(sphinx_builder, fmt, BUTTON["rst"], BUTTON["myst"]) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert 'title="a <b> & "q""' in html + assert 'title="a ' not in html diff --git a/tests/test_snippets/snippet_post_badge-basic.xml b/tests/test_snippets/snippet_post_badge-basic.xml index 09c828c..07cfdaf 100644 --- a/tests/test_snippets/snippet_post_badge-basic.xml +++ b/tests/test_snippets/snippet_post_badge-basic.xml @@ -3,71 +3,71 @@ Heading <paragraph> - <inline classes="sd-sphinx-override sd-badge"> + <sd_badge classes="sd-sphinx-override sd-badge"> plain badge <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-primary sd-bg-text-primary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-primary sd-bg-text-primary"> primary , - <inline classes="sd-sphinx-override sd-badge sd-outline-primary sd-text-primary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-primary sd-text-primary"> primary-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-secondary sd-bg-text-secondary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-secondary sd-bg-text-secondary"> secondary , - <inline classes="sd-sphinx-override sd-badge sd-outline-secondary sd-text-secondary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-secondary sd-text-secondary"> secondary-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-success sd-bg-text-success"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-success sd-bg-text-success"> success , - <inline classes="sd-sphinx-override sd-badge sd-outline-success sd-text-success"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-success sd-text-success"> success-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-info sd-bg-text-info"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-info sd-bg-text-info"> info , - <inline classes="sd-sphinx-override sd-badge sd-outline-info sd-text-info"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-info sd-text-info"> info-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-warning sd-bg-text-warning"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-warning sd-bg-text-warning"> warning , - <inline classes="sd-sphinx-override sd-badge sd-outline-warning sd-text-warning"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-warning sd-text-warning"> warning-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-danger sd-bg-text-danger"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-danger sd-bg-text-danger"> danger , - <inline classes="sd-sphinx-override sd-badge sd-outline-danger sd-text-danger"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-danger sd-text-danger"> danger-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-light sd-bg-text-light"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-light sd-bg-text-light"> light , - <inline classes="sd-sphinx-override sd-badge sd-outline-light sd-text-light"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-light sd-text-light"> light-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-muted sd-bg-text-muted"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-muted sd-bg-text-muted"> muted , - <inline classes="sd-sphinx-override sd-badge sd-outline-muted sd-text-muted"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-muted sd-text-muted"> muted-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-dark sd-bg-text-dark"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-dark sd-bg-text-dark"> dark , - <inline classes="sd-sphinx-override sd-badge sd-outline-dark sd-text-dark"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-dark sd-text-dark"> dark-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-white sd-bg-text-white"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-white sd-bg-text-white"> white , - <inline classes="sd-sphinx-override sd-badge sd-outline-white sd-text-white"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-white sd-text-white"> white-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-black sd-bg-text-black"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-black sd-bg-text-black"> black , - <inline classes="sd-sphinx-override sd-badge sd-outline-black sd-text-black"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-black sd-text-black"> black-line diff --git a/tests/test_snippets/snippet_pre_badge-basic.xml b/tests/test_snippets/snippet_pre_badge-basic.xml index 09c828c..07cfdaf 100644 --- a/tests/test_snippets/snippet_pre_badge-basic.xml +++ b/tests/test_snippets/snippet_pre_badge-basic.xml @@ -3,71 +3,71 @@ <title> Heading <paragraph> - <inline classes="sd-sphinx-override sd-badge"> + <sd_badge classes="sd-sphinx-override sd-badge"> plain badge <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-primary sd-bg-text-primary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-primary sd-bg-text-primary"> primary , - <inline classes="sd-sphinx-override sd-badge sd-outline-primary sd-text-primary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-primary sd-text-primary"> primary-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-secondary sd-bg-text-secondary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-secondary sd-bg-text-secondary"> secondary , - <inline classes="sd-sphinx-override sd-badge sd-outline-secondary sd-text-secondary"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-secondary sd-text-secondary"> secondary-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-success sd-bg-text-success"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-success sd-bg-text-success"> success , - <inline classes="sd-sphinx-override sd-badge sd-outline-success sd-text-success"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-success sd-text-success"> success-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-info sd-bg-text-info"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-info sd-bg-text-info"> info , - <inline classes="sd-sphinx-override sd-badge sd-outline-info sd-text-info"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-info sd-text-info"> info-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-warning sd-bg-text-warning"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-warning sd-bg-text-warning"> warning , - <inline classes="sd-sphinx-override sd-badge sd-outline-warning sd-text-warning"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-warning sd-text-warning"> warning-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-danger sd-bg-text-danger"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-danger sd-bg-text-danger"> danger , - <inline classes="sd-sphinx-override sd-badge sd-outline-danger sd-text-danger"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-danger sd-text-danger"> danger-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-light sd-bg-text-light"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-light sd-bg-text-light"> light , - <inline classes="sd-sphinx-override sd-badge sd-outline-light sd-text-light"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-light sd-text-light"> light-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-muted sd-bg-text-muted"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-muted sd-bg-text-muted"> muted , - <inline classes="sd-sphinx-override sd-badge sd-outline-muted sd-text-muted"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-muted sd-text-muted"> muted-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-dark sd-bg-text-dark"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-dark sd-bg-text-dark"> dark , - <inline classes="sd-sphinx-override sd-badge sd-outline-dark sd-text-dark"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-dark sd-text-dark"> dark-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-white sd-bg-text-white"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-white sd-bg-text-white"> white , - <inline classes="sd-sphinx-override sd-badge sd-outline-white sd-text-white"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-white sd-text-white"> white-line <paragraph> - <inline classes="sd-sphinx-override sd-badge sd-bg-black sd-bg-text-black"> + <sd_badge classes="sd-sphinx-override sd-badge sd-bg-black sd-bg-text-black"> black , - <inline classes="sd-sphinx-override sd-badge sd-outline-black sd-text-black"> + <sd_badge classes="sd-sphinx-override sd-badge sd-outline-black sd-text-black"> black-line