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"]*>", "", 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