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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

- πŸ› FIX: `button-ref` no longer strips whitespace from reference targets, so
multi-word labels (e.g. from `autosectionlabel`) resolve correctly
({pr}`281`, {issue}`110`)
- πŸ› FIX: `button-ref` now renders rich/nested inline content (emphasis, icons,
etc.) identically to `button-link`, instead of flattening it to plain text
during cross-reference resolution ({pr}`281`, {issue}`228`)
- πŸ› 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`)
Expand Down
15 changes: 6 additions & 9 deletions docs/badges_buttons.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ Reference Button text
````
`````

Note that by default sphinx converts the content of references to raw text.
For example `**Bold text**` with `ref-type` set to `ref` will be rendered without bold:
The content of a `button-ref` supports rich inline formatting (such as emphasis
and icons), which is rendered in the button, just as it is for `button-link`:

```{button-ref} buttons
:ref-type: ref
Expand All @@ -115,14 +115,11 @@ For example `**Bold text**` with `ref-type` set to `ref` will be rendered withou
**Bold text**
```

However, if using [myst-parser](https://myst-parser.readthedocs.io/), you can set the `ref-type` to `myst`, and the content will be properly rendered:
Reference targets may also contain spaces, for example the labels that
`sphinx.ext.autosectionlabel` generates from section titles.

```{button-ref} buttons
:ref-type: myst
:color: primary

**Bold text**
```
When using [myst-parser](https://myst-parser.readthedocs.io/), you can also set
`ref-type` to `myst` to resolve Markdown-style references.

Use the `click-parent` option to make the button's parent container also clickable.

Expand Down
132 changes: 130 additions & 2 deletions sphinx_design/badges_buttons.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import Any

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import addnodes
from sphinx.application import Sphinx
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util import ws_re
from sphinx.util.docutils import ReferenceRole, SphinxRole

from sphinx_design.shared import SEMANTIC_COLORS, SdDirective, make_choice, text_align
Expand Down Expand Up @@ -41,6 +45,8 @@ def setup_badges_and_buttons(app: Sphinx) -> None:

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)


def create_bdg_classes(color: str | None, outline: bool) -> list[str]:
Expand Down Expand Up @@ -147,6 +153,11 @@ class _ButtonDirective(SdDirective):
"class": directives.class_option,
}

@staticmethod
def get_target(argument: str) -> str:
"""Normalise the directive argument into a reference target."""
raise NotImplementedError

def create_ref_node(
self, rawtext: str, target: str, explicit_title: bool, classes: list[str]
) -> nodes.Node:
Expand All @@ -155,7 +166,7 @@ def create_ref_node(

def run_with_defaults(self) -> list[nodes.Node]:
rawtext = self.arguments[0]
target = directives.uri(rawtext)
target = self.get_target(rawtext)
classes = ["sd-sphinx-override", "sd-btn", "sd-text-wrap"]
if "color" in self.options:
if "outline" in self.options:
Expand Down Expand Up @@ -211,6 +222,11 @@ def run_with_defaults(self) -> list[nodes.Node]:
class ButtonLinkDirective(_ButtonDirective):
"""A button directive with an external link."""

@staticmethod
def get_target(argument: str) -> str:
"""Return the URI, with all whitespace removed (as for a normal link)."""
return directives.uri(argument)

def create_ref_node(
self, rawtext: str, target: str, explicit_title: bool, classes: list[str]
) -> nodes.Node:
Expand All @@ -225,13 +241,29 @@ def create_ref_node(
class ButtonRefDirective(_ButtonDirective):
"""A button directive with an internal link."""

@staticmethod
def get_target(argument: str) -> str:
"""Return the reference target, collapsing internal whitespace.

This mirrors :class:`~sphinx.roles.XRefRole`'s whitespace
normalisation, so that multi-word labels (e.g. those generated by
``sphinx.ext.autosectionlabel`` from section titles) are preserved and
resolve correctly, rather than being stripped of all whitespace.
"""
return ws_re.sub(" ", argument).strip()

def create_ref_node(
self, rawtext: str, target: str, explicit_title: bool, classes: list[str]
) -> nodes.Node:
"""Create the reference node."""
ref_type = self.options.get("ref-type", "any")
if ref_type == "ref":
# match sphinx's :ref: role, which is ``XRefRole(lowercase=True)``:
# std-domain labels are stored lowercased, so lowercase the target
# too. ``doc``/``myst`` targets are case-sensitive, and the ``any``
# resolver lowercases internally where needed
target = target.lower()
options = {
# TODO the presence of classes raises an error if the link cannot be found
"classes": classes,
"reftarget": target,
"refdoc": self.env.docname,
Expand All @@ -241,3 +273,99 @@ def create_ref_node(
"refwarn": True,
}
return addnodes.pending_xref(rawtext, **options)


_BUTTON_REF_STASH_ATTR = "sd_button_ref_content"
"""Name of the (transient) python attribute on the ``document`` object,
mapping marker class names to stashed ``button-ref`` content nodes."""

_BUTTON_REF_MARKER_PREFIX = "sd-button-ref-content-"
"""Prefix of the transient marker classes used to correlate a ``button-ref``
``pending_xref`` with its resolved node; never present in final output."""


class ButtonRefContentStash(SphinxPostTransform):
"""Stash the rich content of each ``button-ref`` cross-reference.

Sphinx's standard-domain resolvers rebuild an explicit-title ``ref``/``doc``
cross-reference from ``node.astext()``, which flattens any nested inline
markup (emphasis, icons, ...) down to plain text (see issue #228). Since
``button-ref`` allows arbitrary parsed content, we stash a copy of that
content before any resolver runs, tagging the ``pending_xref`` with a
unique marker class. Whatever node the xref is replaced with inherits the
marker (docutils ``replace_self`` copies ids/classes onto the
replacement), letting :class:`ButtonRefContentGraft` restore the content
afterwards, without relying on any non-public resolver API.
"""

# must run before every cross-reference resolver: the built-in
# ``ReferencesResolver`` runs at priority 10, and myst-parser's resolver
# (which handles ``ref-type: myst`` buttons) at priority 9
default_priority = 8

def run(self, **kwargs: Any) -> None:
"""Tag and stash every ``button-ref`` pending cross-reference."""
stash: dict[str, nodes.Element] = {}
for index, node in enumerate(self.document.findall(addnodes.pending_xref)):
if "sd-btn" not in node.get("classes", ()):
continue
if not node.get("refexplicit"):
# the directive had no content: the resolver-generated text
# (e.g. the target's section title) is the desired button text
continue
if next(iter(node[0].findall(addnodes.pending_xref)), None) is not None:
# the button content itself contains a cross-reference: a
# stashed copy would re-insert an unresolved ``pending_xref``
# after every resolver has run, crashing the writer. Fall back
# to the pre-existing behaviour (the std domain flattens the
# content to plain text) - a link nested inside a button link
# would be invalid HTML anyway
continue
marker = f"{_BUTTON_REF_MARKER_PREFIX}{index}"
node["classes"].append(marker)
stash[marker] = node[0].deepcopy()
# a plain (transient) python attribute: unlike a node attribute it can
# never leak into pickled doctrees or (XML) serialisations
setattr(self.document, _BUTTON_REF_STASH_ATTR, stash)


class ButtonRefContentGraft(SphinxPostTransform):
"""Restore stashed ``button-ref`` content onto resolved references.

Counterpart to :class:`ButtonRefContentStash`: locate the node that
inherited each marker class from its ``pending_xref``, strip the marker,
and, for resolved references, replace the (possibly text-flattened)
resolver-built content with the stashed rich content, so that
``button-ref`` renders its content identically to ``button-link``.
"""

# run just after the built-in ``ReferencesResolver`` (priority 10)
default_priority = 11

def run(self, **kwargs: Any) -> None:
"""Graft stashed content onto each marked node."""
stash: dict[str, nodes.Element] = getattr(
self.document, _BUTTON_REF_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):
# a resolver replaced the xref with a reference, possibly
# rebuilding its content from plain text (the std domain
# does, for explicit-title ref/doc targets): restore the
# stashed rich content (a no-op in effect for resolvers
# that already preserved the original content node)
element.children = []
element.append(stash[marker].deepcopy())
# otherwise the xref was unresolved, and the built-in resolver
# substituted the original (still rich) content node itself,
# which inherited the marker: stripping the marker is enough.
# If an (external) resolver produced a node that did not
# inherit the marker at all, nothing is found here and the
# 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, {})
Loading
Loading