From 02660a20956e207b8be74a478b96bc960aa47f89 Mon Sep 17 00:00:00 2001
From: cherith <315844584+Cherith1222@users.noreply.github.com>
Date: Thu, 8 Oct 2026 06:34:03 +0800
Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Show=20button=20lin?=
=?UTF-8?q?e=20blocks=20as=20separate=20lines?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
button-link parsed its body as inline text, so an RST line-block bar stayed on the page. Bars indented under an option were parsed as a block quote and still kept the bar.
AI-assisted (Cursor). The account owner confirmed this can be submitted.
---
CHANGELOG.md | 4 ++
sphinx_design/badges_buttons.py | 71 ++++++++++++++++++++++++++++--
tests/test_buttons.py | 76 +++++++++++++++++++++++++++++++++
3 files changed, 148 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f31abd0..0c6dc7b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- 🐛 FIX: `button-link` and `button-ref` render an RST line block as separate
+ lines, including when the bars are indented under an option such as
+ `:expand:`, instead of keeping the `|` characters in the button
+ ({issue}`156`)
- ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS
generator (`tools/generate_css.py` driven by `style/design.toml` and
hand-authored `style/*.css`); `package.json` is gone. The compiled
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index ceb2e67a..05f08283 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -335,6 +335,60 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
return [node], []
+_LINE_BLOCK_LINE = re.compile(r"^[ \t]*\|(.*)$")
+
+
+def _is_button_line_block(content: Sequence[str]) -> bool:
+ """Return whether ``content`` is only an RST line block.
+
+ ``inline_text`` does not understand block markup, so a button whose body is
+ ``| hey`` / ``| new line`` would otherwise keep the bar characters (#156).
+ """
+ nonempty = [line for line in content if line.strip()]
+ return bool(nonempty) and all(_LINE_BLOCK_LINE.match(line) for line in nonempty)
+
+
+def _flatten_line_block(block: nodes.line_block) -> list[nodes.Node]:
+ """Turn a parsed line block into inline nodes separated by ``
``."""
+ pieces: list[nodes.Node] = []
+ for child in block:
+ if pieces:
+ pieces.append(nodes.raw("", "
", format="html"))
+ if isinstance(child, nodes.line):
+ pieces.extend(item.deepcopy() for item in child.children)
+ elif isinstance(child, nodes.line_block):
+ pieces.extend(_flatten_line_block(child))
+ return pieces
+
+
+def _line_blocks_in(node: nodes.Element) -> list[nodes.line_block] | None:
+ """Return line blocks when ``node`` contains nothing else.
+
+ A bar indented past a directive option (``:expand:``) is a block quote
+ whose only child is the line block. A blank line between bars is two
+ sibling line blocks. Both are the markup from #156; anything else is not.
+ """
+ children = list(node)
+ while len(children) == 1 and isinstance(children[0], nodes.block_quote):
+ children = list(children[0])
+ if children and all(isinstance(child, nodes.line_block) for child in children):
+ return children
+ return None
+
+
+def _flatten_line_blocks(blocks: list[nodes.line_block]) -> list[nodes.Node]:
+ """Join line blocks with a break where a blank line split them."""
+ pieces: list[nodes.Node] = []
+ for block in blocks:
+ flattened = _flatten_line_block(block)
+ if not flattened:
+ continue
+ if pieces:
+ pieces.append(nodes.raw("", "
", format="html"))
+ pieces.extend(flattened)
+ return pieces
+
+
class _ButtonDirective(SdDirective):
"""A base button directive."""
@@ -368,6 +422,19 @@ def create_ref_node(
"""Create the reference node."""
raise NotImplementedError
+ def _button_content_nodes(self) -> list[nodes.Node]:
+ """Parse button body, expanding an RST line block into separate lines."""
+ if _is_button_line_block(self.content):
+ container = nodes.Element()
+ self.state.nested_parse(self.content, self.content_offset, container)
+ blocks = _line_blocks_in(container)
+ if blocks is not None:
+ return _flatten_line_blocks(blocks)
+ textnodes, _ = self.state.inline_text(
+ "\n".join(self.content), self.lineno + self.content_offset
+ )
+ return textnodes
+
def run_with_defaults(self) -> list[nodes.Node]:
rawtext = self.arguments[0]
target = self.get_target(rawtext)
@@ -392,9 +459,7 @@ 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
- )
+ textnodes = self._button_content_nodes()
# make link text translatable -
# target gettext to the content lines, not the outer directive
translatable = nodes.inline("", "", *textnodes, translatable=True)
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 24318c3c..42c5a987 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -10,6 +10,8 @@
``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-link`` whose content is an RST line block must show the
+ lines, not the literal ``|`` markers.
"""
from docutils import nodes
@@ -325,6 +327,80 @@ def test_button_ref_nested_xref_no_crash(fmt, sphinx_builder):
assert _BUTTON_REF_MARKER_PREFIX not in html
+LINE_BLOCK_RST = """
+Heading
+=======
+
+.. button-link:: https://example.com
+ :expand:
+
+ | hey
+ | new line
+"""
+
+# The report indents the bars one column past ``:expand:``. Docutils then
+# parses a block quote around the line block, and the bars used to survive.
+ISSUE_LINE_BLOCK_RST = """
+Heading
+=======
+
+.. button-link:: example.com
+ :expand:
+
+ | hey
+ | new line
+"""
+
+BLANK_LINE_BLOCK_RST = """
+Heading
+=======
+
+.. button-link:: https://example.com
+
+ | hey
+
+ | new line
+"""
+
+
+def _assert_button_lines(html: str, href: str) -> None:
+ assert f'href="{href}"' in html
+ assert "| hey" not in html
+ assert "| new line" not in html
+ assert "hey
new line" in html
+
+
+def test_button_link_line_block_is_not_literal(sphinx_builder):
+ """RST line blocks inside ``button-link`` become separate lines (#156)."""
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(LINE_BLOCK_RST, encoding="utf8")
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ _assert_button_lines(html, "https://example.com")
+
+
+def test_button_link_line_block_indented_under_option(sphinx_builder):
+ """Bars indented past an option still render as lines (#156)."""
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(
+ ISSUE_LINE_BLOCK_RST, encoding="utf8"
+ )
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ _assert_button_lines(html, "example.com")
+
+
+def test_button_link_line_block_blank_line(sphinx_builder):
+ """A blank line between bars does not leave the markers in the button."""
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(
+ BLANK_LINE_BLOCK_RST, encoding="utf8"
+ )
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ _assert_button_lines(html, "https://example.com")
+
+
def test_button_ref_titlecase_ref_type(sphinx_builder):
"""A Title-Case target with ``ref-type: ref`` resolves like ``:ref:`` does.
From 8adb50eb830074c9dffdfca757f7a4b1abf38530 Mon Sep 17 00:00:00 2001
From: cherith <315844584+Cherith1222@users.noreply.github.com>
Date: Thu, 8 Oct 2026 06:35:38 +0800
Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Link=20the=20butto?=
=?UTF-8?q?n=20line-block=20note=20to=20its=20pull=20request?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AI-assisted (Cursor). The account owner confirmed this can be submitted.
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c6dc7b9..3c746b17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
- 🐛 FIX: `button-link` and `button-ref` render an RST line block as separate
lines, including when the bars are indented under an option such as
`:expand:`, instead of keeping the `|` characters in the button
- ({issue}`156`)
+ ({pr}`308`, {issue}`156`)
- ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS
generator (`tools/generate_css.py` driven by `style/design.toml` and
hand-authored `style/*.css`); `package.json` is gone. The compiled
From 8ecfeba3ec34530c746fd825324c6244942f657a Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 20:47:50 +0100
Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20drop=20changel?=
=?UTF-8?q?og=20entry=20(written=20at=20release)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 4 ----
1 file changed, 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c746b17..1f31abd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,6 @@
## Unreleased
-- 🐛 FIX: `button-link` and `button-ref` render an RST line block as separate
- lines, including when the bars are indented under an option such as
- `:expand:`, instead of keeping the `|` characters in the button
- ({pr}`308`, {issue}`156`)
- ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS
generator (`tools/generate_css.py` driven by `style/design.toml` and
hand-authored `style/*.css`); `package.json` is gone. The compiled
From 05367d08428c549052515515f8bfb3125722ae5b Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 20:47:58 +0100
Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20block-parse=20butto?=
=?UTF-8?q?n=20text=20only=20when=20it=20is=20an=20RST=20line=20block?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow docutils' line-block syntax: the first non-blank line must start
with "|" and then a space (or end there). Text that starts with a
substitution ("|sub| text") is then parsed once, as before, instead of
twice: the second parse duplicated side effects, such as an autonumbered
footnote reference ("Too many autonumbered footnote references").
Continuation lines are now accepted too. MyST text is never
block-parsed: line blocks are RST syntax, and the extra parse reported
MyST's warnings twice.
Move the parsed inline nodes into the button rather than copying them:
docutils registers footnote references on the document, so a copied one
was never numbered and crashed the HTML writer (KeyError: 'refid').
The tests cover button-link and button-ref, the markup from #156, a
blank line between bars, continuation lines, nested lines and inline
markup.
---
sphinx_design/badges_buttons.py | 90 ++++++++-------
tests/test_buttons.py | 190 +++++++++++++++++++++-----------
2 files changed, 171 insertions(+), 109 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index 05f08283..b10873c6 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
@@ -335,57 +335,50 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
return [node], []
-_LINE_BLOCK_LINE = re.compile(r"^[ \t]*\|(.*)$")
+_LINE_BLOCK_START = re.compile(r"[ \t]*\|( +|$)")
+"""The start of an RST line block (docutils' ``line_block`` pattern), indented."""
-def _is_button_line_block(content: Sequence[str]) -> bool:
- """Return whether ``content`` is only an RST line block.
+def _starts_with_line_block(content: Sequence[str]) -> bool:
+ """Return whether the first non-blank line of ``content`` starts a line block.
- ``inline_text`` does not understand block markup, so a button whose body is
- ``| hey`` / ``| new line`` would otherwise keep the bar characters (#156).
+ Only then is the button body block-parsed; any other body (including one
+ that starts with a substitution, such as ``|icon| Download``) is parsed
+ once, as inline text.
"""
- nonempty = [line for line in content if line.strip()]
- return bool(nonempty) and all(_LINE_BLOCK_LINE.match(line) for line in nonempty)
+ for line in content:
+ if line.strip():
+ return _LINE_BLOCK_START.match(line) is not None
+ return False
-def _flatten_line_block(block: nodes.line_block) -> list[nodes.Node]:
- """Turn a parsed line block into inline nodes separated by ``
``."""
- pieces: list[nodes.Node] = []
- for child in block:
- if pieces:
- pieces.append(nodes.raw("", "
", format="html"))
- if isinstance(child, nodes.line):
- pieces.extend(item.deepcopy() for item in child.children)
- elif isinstance(child, nodes.line_block):
- pieces.extend(_flatten_line_block(child))
- return pieces
-
+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.
-def _line_blocks_in(node: nodes.Element) -> list[nodes.line_block] | None:
- """Return line blocks when ``node`` contains nothing else.
-
- A bar indented past a directive option (``:expand:``) is a block quote
- whose only child is the line block. A blank line between bars is two
- sibling line blocks. Both are the markup from #156; anything else is not.
+ 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.
"""
- children = list(node)
+ children = node.children
while len(children) == 1 and isinstance(children[0], nodes.block_quote):
- children = list(children[0])
- if children and all(isinstance(child, nodes.line_block) for child in children):
- return children
- return None
+ children = children[0].children
+ 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 _flatten_line_blocks(blocks: list[nodes.line_block]) -> list[nodes.Node]:
- """Join line blocks with a break where a blank line split them."""
+def _join_button_lines(lines: list[nodes.line]) -> list[nodes.Node]:
+ """Return the inline content of ``lines``, with a line break between lines."""
pieces: list[nodes.Node] = []
- for block in blocks:
- flattened = _flatten_line_block(block)
- if not flattened:
- continue
- if pieces:
+ for index, line in enumerate(lines):
+ if index:
pieces.append(nodes.raw("", "
", format="html"))
- pieces.extend(flattened)
+ # 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[:]
+ pieces.extend(children)
return pieces
@@ -422,14 +415,19 @@ def create_ref_node(
"""Create the reference node."""
raise NotImplementedError
- def _button_content_nodes(self) -> list[nodes.Node]:
- """Parse button body, expanding an RST line block into separate lines."""
- if _is_button_line_block(self.content):
+ def _content_text_nodes(self) -> list[nodes.Node]:
+ """Parse the button text.
+
+ 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 _starts_with_line_block(self.content):
container = nodes.Element()
self.state.nested_parse(self.content, self.content_offset, container)
- blocks = _line_blocks_in(container)
- if blocks is not None:
- return _flatten_line_blocks(blocks)
+ lines = _line_block_lines(container)
+ if lines is not None:
+ return _join_button_lines(lines)
textnodes, _ = self.state.inline_text(
"\n".join(self.content), self.lineno + self.content_offset
)
@@ -459,7 +457,7 @@ def run_with_defaults(self) -> list[nodes.Node]:
node["reftitle"] = self.options["tooltip"]
if self.content:
- textnodes = self._button_content_nodes()
+ textnodes = self._content_text_nodes()
# make link text translatable -
# target gettext to the content lines, not the outer directive
translatable = nodes.inline("", "", *textnodes, translatable=True)
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 42c5a987..4989d285 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -10,10 +10,12 @@
``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-link`` whose content is an RST line block must show the
- lines, not the literal ``|`` markers.
+* #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 docutils import nodes
import pytest
@@ -327,92 +329,154 @@ def test_button_ref_nested_xref_no_crash(fmt, sphinx_builder):
assert _BUTTON_REF_MARKER_PREFIX not in html
-LINE_BLOCK_RST = """
-Heading
-=======
+def test_button_ref_titlecase_ref_type(sphinx_builder):
+ """A Title-Case target with ``ref-type: ref`` resolves like ``:ref:`` does.
-.. button-link:: https://example.com
- :expand:
+ Sphinx's ``:ref:`` role lowercases its target (std labels are stored
+ lowercased), so ``button-ref`` matches it - the visible heading text can
+ be pasted verbatim as the target.
+ """
+ src = MULTIWORD["rst"].replace(
+ ".. button-ref:: my article section",
+ ".. button-ref:: My Article Section",
+ )
+ assert src != MULTIWORD["rst"]
+ 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
- | hey
- | new line
-"""
-# The report indents the bars one column past ``:expand:``. Docutils then
-# parses a block quote around the line block, and the bars used to survive.
-ISSUE_LINE_BLOCK_RST = """
-Heading
-=======
+_BUTTON_RE = re.compile(
+ r']*class="[^"]*\bsd-btn\b[^"]*"[^>]*>(.*?)', re.DOTALL
+)
-.. button-link:: example.com
- :expand:
- | hey
- | new line
-"""
+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"]
-def _assert_button_lines(html: str, href: str) -> None:
- assert f'href="{href}"' in html
- assert "| hey" not in html
- assert "| new line" not in html
- assert "hey
new line" in html
+NOT_A_LINE_BLOCK_RST = """\
+Heading
+=======
+.. |sub| replace:: Download
-def test_button_link_line_block_is_not_literal(sphinx_builder):
- """RST line blocks inside ``button-link`` become separate lines (#156)."""
- builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
- builder.src_path.joinpath("index.rst").write_text(LINE_BLOCK_RST, encoding="utf8")
- builder.build()
- html = (builder.out_path / "index.html").read_text(encoding="utf8")
- _assert_button_lines(html, "https://example.com")
+.. button-link:: https://example.com
+ |sub| the notes [#]_
-def test_button_link_line_block_indented_under_option(sphinx_builder):
- """Bars indented past an option still render as lines (#156)."""
- builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
- builder.src_path.joinpath("index.rst").write_text(
- ISSUE_LINE_BLOCK_RST, encoding="utf8"
- )
- builder.build()
- html = (builder.out_path / "index.html").read_text(encoding="utf8")
- _assert_button_lines(html, "example.com")
+.. [#] A footnote.
+"""
-def test_button_link_line_block_blank_line(sphinx_builder):
- """A blank line between bars does not leave the markers in the button."""
- builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
- builder.src_path.joinpath("index.rst").write_text(
- BLANK_LINE_BLOCK_RST, encoding="utf8"
- )
- builder.build()
+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")
- _assert_button_lines(html, "https://example.com")
+ [text] = _button_texts(html)
+ assert text.startswith("Download the notes"), text
-def test_button_ref_titlecase_ref_type(sphinx_builder):
- """A Title-Case target with ``ref-type: ref`` resolves like ``:ref:`` does.
+@pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed")
+def test_button_link_myst_bars_unchanged(sphinx_builder):
+ """Line blocks are RST syntax: MyST button text keeps its bars, parsed once.
- Sphinx's ``:ref:`` role lowercases its target (std labels are stored
- lowercased), so ``button-ref`` matches it - the visible heading text can
- be pasted verbatim as the target.
+ Block-parsing the MyST text as well would report its parse-time warnings
+ (here, an unknown role) twice.
"""
- src = MULTIWORD["rst"].replace(
- ".. button-ref:: my article section",
- ".. button-ref:: My Article Section",
+ builder = sphinx_builder(
+ conf_kwargs={
+ "extensions": ["myst_parser", "sphinx_design"],
+ "myst_enable_extensions": ["colon_fence"],
+ }
+ )
+ builder.src_path.joinpath("index.md").write_text(
+ "# Heading\n\n"
+ "```{button-link} https://example.com\n| hey\n| new {notarole}`line`\n```\n",
+ encoding="utf8",
+ )
+ builder.build(assert_pass=False)
+ assert builder.warnings.count('Unknown interpreted text role "notarole"') == 1, (
+ builder.warnings
)
- assert src != MULTIWORD["rst"]
- 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
+ [text] = _button_texts(html)
+ assert text.startswith("| hey | new"), text
From d42b71d3396b0d7f6336392b955dc631b6399e9b Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 20:48:25 +0100
Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20break=20button=20li?=
=?UTF-8?q?nes=20in=20every=20output=20format?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A raw HTML
is dropped by the LaTeX, text, man and texinfo writers,
which then joined the lines ("heynew line"). Use an sd_line_break node
instead:
in HTML, \newline{} in LaTeX (it compiles inside
\sphinxhref with pdflatex and xelatex), and a space in the other
builders and in astext().
---
sphinx_design/badges_buttons.py | 31 +++++++++++++++++++++-
tests/test_buttons.py | 46 +++++++++++++++++++++++++++------
2 files changed, 68 insertions(+), 9 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index b10873c6..8af07f8d 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -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,
@@ -373,7 +402,7 @@ def _join_button_lines(lines: list[nodes.line]) -> list[nodes.Node]:
pieces: list[nodes.Node] = []
for index, line in enumerate(lines):
if index:
- pieces.append(nodes.raw("", "
", format="html"))
+ pieces.append(sd_line_break("", " "))
# 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[:]
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 4989d285..faa84dbf 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -369,25 +369,25 @@ def _build_rst(sphinx_builder, rst, buildername="html", **files):
LINE_BLOCK_CASES = {
- "plain": (" | hey\n | new line\n", "hey
new line"),
+ "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"),
+ "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",
+ "hey there continued
new line",
),
- "nested": (" | hey\n | indented\n", "hey
indented"),
+ "nested": (" | hey\n | indented\n", "hey
indented"),
"inline-markup": (
" | *hey*\n | new :octicon:`heart` line\n",
- "hey
new",
+ "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 new line"]
+ assert _button_texts(html) == ["hey
new line"]
NOT_A_LINE_BLOCK_RST = """\
@@ -480,3 +480,33 @@ def test_button_link_myst_bars_unchanged(sphinx_builder):
html = (builder.out_path / "index.html").read_text(encoding="utf8")
[text] = _button_texts(html)
assert text.startswith("| hey | new"), text
+
+
+LINE_BLOCK_RST = """\
+Heading
+=======
+
+.. button-link:: https://example.com
+
+ | hey
+ | new line
+"""
+
+
+def test_button_link_line_block_text(sphinx_builder):
+ """Builders without a line break separate the lines with a space."""
+ builder = _build_rst(sphinx_builder, LINE_BLOCK_RST, "text")
+ text = (builder.out_path / "index.txt").read_text(encoding="utf8")
+ assert "hey new line" in text, text
+ doctree = builder.get_doctree("index", post_transforms=True)
+ [button] = [r for r in doctree.findall(nodes.reference) if "sd-btn" in r["classes"]]
+ assert button.astext() == "hey new line"
+
+
+def test_button_link_line_block_latex(sphinx_builder):
+ """LaTeX output breaks the line inside the link (``\\sphinxhref``)."""
+ builder = _build_rst(sphinx_builder, LINE_BLOCK_RST, "latex")
+ [tex] = builder.out_path.glob("*.tex")
+ assert r"\sphinxhref{https://example.com}{hey\newline{}new line}" in tex.read_text(
+ encoding="utf8"
+ )
From 89be32d199f3ec896d7b0e1ff008e277419a078c Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 20:48:25 +0100
Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20translate=20the=20l?=
=?UTF-8?q?ines=20of=20a=20multi-line=20button=20one=20by=20one?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The whole button was one gettext message, and its text ran the lines
together. Each line is now a message of its own, as in a normal line
block (msgids "hey" and "new line"), and each translated line keeps its
place.
---
sphinx_design/badges_buttons.py | 36 +++++++++++++++++++++------------
tests/test_buttons.py | 34 +++++++++++++++++++++++++++++++
2 files changed, 57 insertions(+), 13 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index 8af07f8d..e20dee9c 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -398,16 +398,27 @@ def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
def _join_button_lines(lines: list[nodes.line]) -> list[nodes.Node]:
- """Return the inline content of ``lines``, with a line break between lines."""
+ """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.
+ """
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[:]
- pieces.extend(children)
+ translatable = nodes.inline(line.rawsource, "", *children, translatable=True)
+ 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
@@ -444,8 +455,8 @@ def create_ref_node(
"""Create the reference node."""
raise NotImplementedError
- def _content_text_nodes(self) -> list[nodes.Node]:
- """Parse the button text.
+ 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
@@ -460,7 +471,12 @@ def _content_text_nodes(self) -> list[nodes.Node]:
textnodes, _ = self.state.inline_text(
"\n".join(self.content), self.lineno + self.content_offset
)
- return textnodes
+ # 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]
@@ -486,17 +502,11 @@ def run_with_defaults(self) -> list[nodes.Node]:
node["reftitle"] = self.options["tooltip"]
if self.content:
- textnodes = self._content_text_nodes()
- # 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 faa84dbf..2589d050 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -16,6 +16,8 @@
import re
+from babel.messages.catalog import Catalog
+from babel.messages.mofile import write_mo
from docutils import nodes
import pytest
@@ -510,3 +512,35 @@ def test_button_link_line_block_latex(sphinx_builder):
assert r"\sphinxhref{https://example.com}{hey\newline{}new line}" in tex.read_text(
encoding="utf8"
)
+
+
+def test_button_link_line_block_gettext(sphinx_builder):
+ """Each line is its own message, with no line break in it."""
+ builder = _build_rst(sphinx_builder, LINE_BLOCK_RST, "gettext")
+ pot = (builder.out_path / "index.pot").read_text(encoding="utf8")
+ assert 'msgid "hey"' in pot, pot
+ assert 'msgid "new line"' in pot, pot
+ assert "hey new line" not in pot, pot
+ assert "
neue Zeile"]
From b87d2d531aaf4ab4ebe5fe76424efc854a06b33b Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 20:48:25 +0100
Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20show=20how=20to=20?=
=?UTF-8?q?break=20button=20text=20over=20several=20lines?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An RST line block, or a MyST hard line break (a backslash at the end of
the line). The live example also puts the LaTeX line break through the
PDF build.
---
docs/badges_buttons.md | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md
index 9564ddaf..594746c6 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.
From 820bf10424155f0ae1fe2b45a1f22b3c99c75e1c Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 21:51:06 +0100
Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20block-parse=20butto?=
=?UTF-8?q?n=20text=20only=20when=20it=20is=20all=20line=20blocks?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Check every line at the outermost indent, not only the first one, so that
a line block followed by a paragraph is parsed once, as inline text,
instead of being block-parsed and then parsed again (which repeated
messages, raised a spurious "Too many autonumbered footnote references"
and registered substitution definitions document-wide).
---
sphinx_design/badges_buttons.py | 35 ++++++++++++++++++++++-----------
tests/test_buttons.py | 31 +++++++++++++++++++++++++++++
2 files changed, 55 insertions(+), 11 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index ae71c4a9..56fcbdac 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -368,17 +368,30 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
"""The start of an RST line block (docutils' ``line_block`` pattern), indented."""
-def _starts_with_line_block(content: Sequence[str]) -> bool:
- """Return whether the first non-blank line of ``content`` starts a line block.
-
- Only then is the button body block-parsed; any other body (including one
- that starts with a substitution, such as ``|icon| Download``) is parsed
- once, as inline text.
+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.
"""
- for line in content:
- if line.strip():
- return _LINE_BLOCK_START.match(line) is not None
- return False
+ 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 _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
@@ -462,7 +475,7 @@ def _content_nodes(self) -> list[nodes.Node]:
separate lines (#156): ``inline_text`` knows no block markup, so it
would keep the ``|`` characters.
"""
- if isinstance(self.state, RSTState) and _starts_with_line_block(self.content):
+ 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)
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 2589d050..699061f3 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -457,6 +457,37 @@ def test_button_link_bar_text_is_not_a_line_block(sphinx_builder):
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
Date: Mon, 9 Nov 2026 21:52:30 +0100
Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20translate=20multi-l?=
=?UTF-8?q?ine=20button=20text=20before=20docutils=200.22?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Before docutils 0.22 (Sphinx 7 and 8 here) the lines of a parsed line
block have no source info, and Sphinx extracts no message from a
translatable node without a source, so a multi-line button could not be
translated. Give such lines the source and line of the button's first
content line.
---
sphinx_design/badges_buttons.py | 18 +++++++++++++++---
tests/test_buttons.py | 7 ++++++-
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index 56fcbdac..f678e1d9 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -410,11 +410,16 @@ def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
return [line for block in children for line in block.findall(nodes.line)]
-def _join_button_lines(lines: list[nodes.line]) -> list[nodes.Node]:
+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):
@@ -427,7 +432,10 @@ def _join_button_lines(lines: list[nodes.line]) -> list[nodes.Node]:
children = line.children[:]
del line[:]
translatable = nodes.inline(line.rawsource, "", *children, translatable=True)
- translatable.source, translatable.line = line.source, line.line
+ 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
@@ -480,7 +488,11 @@ def _content_nodes(self) -> list[nodes.Node]:
self.state.nested_parse(self.content, self.content_offset, container)
lines = _line_block_lines(container)
if lines is not None:
- return _join_button_lines(lines)
+ # 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
)
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 699061f3..862c2e3c 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -546,7 +546,12 @@ def test_button_link_line_block_latex(sphinx_builder):
def test_button_link_line_block_gettext(sphinx_builder):
- """Each line is its own message, with no line break in it."""
+ """Each line is its own message, with no line break in it.
+
+ Before docutils 0.22 the parsed lines carry no source info, and sphinx
+ extracts no message from a node without a source, so the button gives
+ them the source of its content.
+ """
builder = _build_rst(sphinx_builder, LINE_BLOCK_RST, "gettext")
pot = (builder.out_path / "index.pot").read_text(encoding="utf8")
assert 'msgid "hey"' in pot, pot
From fa023b01f844ff542690c55743f10b63469137ba Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Mon, 9 Nov 2026 22:07:47 +0100
Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20keep=20a=20line=20b?=
=?UTF-8?q?lock=20as=20lines=20when=20its=20markup=20reports=20a=20message?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
docutils puts the system messages of a line's inline markup (an unknown
role, or a role found only through the English fallback) beside the
line block, so the button fell back to inline text and parsed the text
a second time: the message was reported twice, an autonumbered footnote
reference failed, and the bars were kept. The messages are now ignored
when checking for line blocks; they have already been reported.
---
sphinx_design/badges_buttons.py | 12 ++++++++++--
tests/test_buttons.py | 30 ++++++++++++++++++++++++++++++
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index f678e1d9..1b743d49 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -394,6 +394,11 @@ def _is_line_block_text(content: Sequence[str]) -> bool:
)
+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.
@@ -401,10 +406,13 @@ def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
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 = node.children
+ children = _non_messages(node)
while len(children) == 1 and isinstance(children[0], nodes.block_quote):
- children = children[0].children
+ 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)]
diff --git a/tests/test_buttons.py b/tests/test_buttons.py
index 862c2e3c..d0b38353 100644
--- a/tests/test_buttons.py
+++ b/tests/test_buttons.py
@@ -488,6 +488,36 @@ def test_button_link_line_block_then_paragraph_parsed_once(sphinx_builder):
assert 'class="footnote-reference' in text, text
+@pytest.mark.parametrize(
+ "indent", [" ", " "], ids=["plain", "indented-past-options"]
+)
+def test_button_link_line_block_with_message_parsed_once(indent, sphinx_builder):
+ """A line block whose inline markup reports a message is still lines, parsed once.
+
+ docutils puts the messages of a line's inline markup (here, an unknown role)
+ beside the line block (inside the block quote, for the markup of #156).
+ Falling back to inline text for that would parse the text twice: the
+ message reported twice, a spurious "Too many autonumbered footnote
+ references", the footnote reference left unresolved, and the bars kept.
+ """
+ rst = (
+ "Heading\n=======\n\n.. button-link:: https://example.com\n :expand:\n\n"
+ f"{indent}| hey\n{indent}| there :notarole:`x` [#]_\n\n.. [#] A footnote.\n"
+ )
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(rst, encoding="utf8")
+ builder.build(assert_pass=False)
+ assert builder.warnings.count('Unknown interpreted text role "notarole"') == 1, (
+ builder.warnings
+ )
+ assert "Too many autonumbered" not in builder.warnings, builder.warnings
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ [text] = _button_texts(html)
+ assert text.startswith("hey
there