diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md
index 9564dda..594746c 100644
--- a/docs/badges_buttons.md
+++ b/docs/badges_buttons.md
@@ -180,6 +180,36 @@ and icons), which is rendered in the button, just as it is for `button-link`:
**Bold text**
```
+To break the text of a button over several lines, write it as a line block in
+reStructuredText (one `|` per line), or end each line but the last with a
+backslash (`\`, a hard line break) in MyST Markdown:
+
+```{eval-rst}
+.. button-link:: https://example.com
+ :color: primary
+
+ | Multi-line
+ | button text
+```
+
+````{tab-set-code}
+```markdown
+:::{button-link} https://example.com
+:color: primary
+
+Multi-line\
+button text
+:::
+```
+```rst
+.. button-link:: https://example.com
+ :color: primary
+
+ | Multi-line
+ | button text
+```
+````
+
Reference targets may also contain spaces, for example the labels that
`sphinx.ext.autosectionlabel` generates from section titles.
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index 261b60f..1b743d4 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -4,7 +4,7 @@
from docutils import nodes
from docutils.parsers.rst import directives
-from docutils.parsers.rst.states import Inliner
+from docutils.parsers.rst.states import Inliner, RSTState
from docutils.utils import unescape
from sphinx import addnodes
from sphinx.application import Sphinx
@@ -56,6 +56,15 @@ def setup_badges_and_buttons(app: Sphinx) -> None:
texinfo=(_visit_sd_badge_passthrough, _depart_sd_badge_passthrough),
)
+ app.add_node(
+ sd_line_break,
+ html=(visit_sd_line_break_html, None),
+ latex=(visit_sd_line_break_latex, None),
+ 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)
@@ -175,6 +184,26 @@ def _depart_sd_badge_passthrough(self: Any, node: nodes.Element) -> None:
"""Counterpart to :func:`_visit_sd_badge_passthrough`."""
+class sd_line_break(nodes.inline): # noqa: N801
+ """A line break between the lines of a multi-line button text.
+
+ It holds a single space, which the text, man and texinfo builders render
+ (as does ``astext()``), so that the lines are separated rather than joined.
+ """
+
+
+def visit_sd_line_break_html(self: Any, node: nodes.Element) -> None:
+ """Emit an HTML line break instead of the space."""
+ self.body.append("
")
+ raise nodes.SkipNode
+
+
+def visit_sd_line_break_latex(self: Any, node: nodes.Element) -> None:
+ """Emit a LaTeX line break instead of the space."""
+ self.body.append(r"\newline{}")
+ raise nodes.SkipNode
+
+
_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,
@@ -335,6 +364,93 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
return [node], []
+_LINE_BLOCK_START = re.compile(r"[ \t]*\|( +|$)")
+"""The start of an RST line block (docutils' ``line_block`` pattern), indented."""
+
+
+def _is_line_block_text(content: Sequence[str]) -> bool:
+ """Return whether ``content`` looks like nothing but RST line blocks.
+
+ Every non-blank line at the outermost indent must start a line-block line;
+ deeper lines are allowed, as continuation lines or nested blocks.
+ Only such a body is block-parsed, and any other body (one that starts with
+ a substitution, such as ``|icon| Download``, or a line block followed by a
+ paragraph) is parsed once, as inline text: parsing it twice would repeat
+ the side effects of the first parse (messages, footnote numbering,
+ substitution definitions).
+
+ The check is textual, so a deeper-indented paragraph after a blank line
+ still passes it; the parsed result is then checked as well, and such a
+ body falls back to inline text after all.
+ """
+ lines = [line for line in content if line.strip()]
+ if not lines:
+ return False
+ indent = min(len(line) - len(line.lstrip()) for line in lines)
+ return all(
+ _LINE_BLOCK_START.match(line) is not None
+ for line in lines
+ if len(line) - len(line.lstrip()) == indent
+ )
+
+
+def _non_messages(node: nodes.Element) -> list[nodes.Node]:
+ """Return the children of ``node`` that are not system messages."""
+ return [c for c in node.children if not isinstance(c, nodes.system_message)]
+
+
+def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
+ """Return the lines of the line blocks in ``node``, if it contains nothing else.
+
+ Bars indented past the directive options (the markup in #156) are parsed as
+ a block quote whose only child is the line block, so such block quotes are
+ unwrapped. Line blocks separated by a blank line, and nested line blocks,
+ contribute their lines in document order.
+ System messages are ignored: docutils places those of the inline markup in
+ a line (e.g. an unknown role) beside the line block, and has already
+ reported them, so falling back to inline text would parse it twice.
+ """
+ children = _non_messages(node)
+ while len(children) == 1 and isinstance(children[0], nodes.block_quote):
+ children = _non_messages(children[0])
+ if not children or not all(isinstance(c, nodes.line_block) for c in children):
+ return None
+ return [line for block in children for line in block.findall(nodes.line)]
+
+
+def _join_button_lines(
+ lines: list[nodes.line], source: str | None, lineno: int | None
+) -> list[nodes.Node]:
+ """Return the content of ``lines``, with a line break between lines.
+
+ Each line is a translatable message of its own (as in a normal line block),
+ so a message never contains a line break.
+ A line that docutils gave no source info (before docutils 0.22) is given
+ ``source`` and ``lineno`` instead: sphinx extracts no message from a
+ translatable node without a source.
+ """
+ pieces: list[nodes.Node] = []
+ for index, line in enumerate(lines):
+ if index:
+ pieces.append(sd_line_break("", " "))
+ if not line.children:
+ continue
+ # move (rather than copy) the parsed inline nodes: docutils registers
+ # some of them (e.g. footnote and named references) on the document
+ children = line.children[:]
+ del line[:]
+ translatable = nodes.inline(line.rawsource, "", *children, translatable=True)
+ if line.source is None:
+ translatable.source, translatable.line = source, lineno
+ else:
+ translatable.source, translatable.line = line.source, line.line
+ # sphinx unwraps a translatable inline by appending its children to its
+ # parent (RemoveTranslatableInline): give each line a parent of its own,
+ # so that the lines keep their order
+ pieces.append(nodes.inline("", "", translatable))
+ return pieces
+
+
class _ButtonDirective(SdDirective):
"""A base button directive."""
@@ -368,6 +484,33 @@ def create_ref_node(
"""Create the reference node."""
raise NotImplementedError
+ def _content_nodes(self) -> list[nodes.Node]:
+ """Parse the button text into translatable inline nodes.
+
+ The text is inline markup, except that an RST line block renders as
+ separate lines (#156): ``inline_text`` knows no block markup, so it
+ would keep the ``|`` characters.
+ """
+ if isinstance(self.state, RSTState) and _is_line_block_text(self.content):
+ container = nodes.Element()
+ self.state.nested_parse(self.content, self.content_offset, container)
+ lines = _line_block_lines(container)
+ if lines is not None:
+ # the first content line (content_offset is a 0-based offset)
+ source, lineno = self.state_machine.get_source_and_line(
+ self.content_offset + 1
+ )
+ return _join_button_lines(lines, source, lineno)
+ textnodes, _ = self.state.inline_text(
+ "\n".join(self.content), self.lineno + self.content_offset
+ )
+ # make link text translatable -
+ # target gettext to the content lines, not the outer directive
+ translatable = nodes.inline("", "", *textnodes, translatable=True)
+ self.set_source_info(translatable)
+ translatable.line += self.content_offset
+ return [translatable]
+
def run_with_defaults(self) -> list[nodes.Node]:
rawtext = self.arguments[0]
target = self.get_target(rawtext)
@@ -392,19 +535,11 @@ def run_with_defaults(self) -> list[nodes.Node]:
node["reftitle"] = self.options["tooltip"]
if self.content:
- textnodes, _ = self.state.inline_text(
- "\n".join(self.content), self.lineno + self.content_offset
- )
- # make link text translatable -
- # target gettext to the content lines, not the outer directive
- translatable = nodes.inline("", "", *textnodes, translatable=True)
- self.set_source_info(translatable)
- translatable.line += self.content_offset
- # the translatable inline is a placeholder that sphinx unwraps
+ # a translatable inline is a placeholder that sphinx unwraps
# after translation (RemoveTranslatableInline); keep a plain outer
# inline so the reference always retains an element child
# (an unresolved xref otherwise crashes on replacement)
- content = nodes.inline("", "", translatable)
+ content = nodes.inline("", "", *self._content_nodes())
else:
content = nodes.inline(target, target)
node.append(content)
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 24318c3..d0b3835 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -10,8 +10,14 @@
``button-link``, rather than being flattened to plain text.
* An unresolved ``button-ref`` target must emit a normal missing-reference
warning and still build (no traceback).
+* #156 - a button whose RST content is a line block renders its lines as
+ separate lines, rather than keeping the literal ``|`` markers.
"""
+import re
+
+from babel.messages.catalog import Catalog
+from babel.messages.mofile import write_mo
from docutils import nodes
import pytest
@@ -340,3 +346,267 @@ def test_button_ref_titlecase_ref_type(sphinx_builder):
builder = _build(sphinx_builder, "rst", src, MULTIWORD["myst"])
html = (builder.out_path / "index.html").read_text(encoding="utf8")
assert 'href="#my-article-section"' in html
+
+
+_BUTTON_RE = re.compile(
+ r']*class="[^"]*\bsd-btn\b[^"]*"[^>]*>(.*?)', re.DOTALL
+)
+
+
+def _button_texts(html: str) -> list[str]:
+ """Return the inner HTML of each button, without ```` wrappers."""
+ return [
+ " ".join(re.sub(r"?span[^>]*>", "", inner).split())
+ for inner in _BUTTON_RE.findall(html)
+ ]
+
+
+def _build_rst(sphinx_builder, rst, buildername="html", **files):
+ builder = sphinx_builder(buildername, conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(rst, encoding="utf8")
+ for name, text in files.items():
+ builder.src_path.joinpath(f"{name}.rst").write_text(text, encoding="utf8")
+ builder.build() # asserts no warnings
+ return builder
+
+
+LINE_BLOCK_CASES = {
+ "plain": (" | hey\n | new line\n", "hey
new line"),
+ # the markup of #156: the bars are indented one column past the options,
+ # so docutils parses a block quote around the line block
+ "indented-past-options": (" | hey\n | new line\n", "hey
new line"),
+ "blank-line-between": (" | hey\n\n | new line\n", "hey
new line"),
+ "continuation-line": (
+ " | hey there\n continued\n | new line\n",
+ "hey there continued
new line",
+ ),
+ "nested": (" | hey\n | indented\n", "hey
indented"),
+ "inline-markup": (
+ " | *hey*\n | new :octicon:`heart` line\n",
+ "hey
new",
+ ),
+ # the parsed nodes are moved into the button, not copied: docutils numbers
+ # the footnote references it registered while parsing
+ "footnote-reference": (
+ " | hey\n | new line [#]_\n\n.. [#] A footnote.\n",
+ "hey
new line new line"]
+
+
+NOT_A_LINE_BLOCK_RST = """\
+Heading
+=======
+
+.. |sub| replace:: Download
+
+.. button-link:: https://example.com
+
+ |sub| the notes [#]_
+
+.. [#] A footnote.
+"""
+
+
+def test_button_link_bar_text_is_not_a_line_block(sphinx_builder):
+ """Text that starts with a substitution is parsed once, as inline text.
+
+ ``|sub| text`` is not a docutils line block (a bar must be followed by a
+ space or end the line), so it must not be block-parsed first: a second
+ parse would duplicate side effects, such as the autonumbered footnote
+ reference here (a spurious "Too many autonumbered footnote references").
+ """
+ builder = _build_rst(sphinx_builder, NOT_A_LINE_BLOCK_RST)
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ [text] = _button_texts(html)
+ assert text.startswith("Download the notes"), text
+
+
+LINE_BLOCK_THEN_PARAGRAPH_RST = """\
+Heading
+=======
+
+.. button-link:: https://example.com
+
+ | hey
+ | new line [#]_
+
+ trailing paragraph
+
+.. [#] A footnote.
+"""
+
+
+def test_button_link_line_block_then_paragraph_parsed_once(sphinx_builder):
+ """A line block followed by other text is parsed once, as inline text.
+
+ Such text is not only line blocks, so it keeps its bars (as before #156);
+ block-parsing it first, then falling back to inline text, would parse the
+ footnote reference twice (a spurious "Too many autonumbered footnote
+ references", and the reference left unresolved).
+ """
+ builder = _build_rst(sphinx_builder, LINE_BLOCK_THEN_PARAGRAPH_RST)
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ [text] = _button_texts(html)
+ assert text.startswith("| hey | new line there neue Zeile"]