From a71251a523f6f50b3a2ff604bb42983a4cccbb82 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 23:15:36 +0100 Subject: [PATCH 01/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20REFACTOR:=20Buttons:?= =?UTF-8?q?=20split=20line-block=20text=20textually,=20parse=20each=20line?= =?UTF-8?q?=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #308 recognised a line block in button text by block-parsing the text with nested_parse, checking that the result held only line blocks, and otherwise parsing it again as inline text. A parse has side effects that a fallback cannot undo, so a body that looked like a line block but was not only one was parsed twice: a blank line followed by a deeper bar, paragraph, directive or substitution definition, or a first line deeper than the rest, gave a spurious "Too many autonumbered footnote references" (and an unresolved reference), a "Block quote ends without a blank line" warning, a duplicate explicit target, or a substitution definition leaking into the page. The lines are now found textually: every non-blank line at the first line's indent must start with docutils' line-block marker, and a deeper line directly after a non-blank line continues the previous line. Each line is then parsed once with inline_text into a translatable inline of its own, with the source and line of that content line. Any other text is parsed once as inline text, as before #308, so the parse-twice class is gone by construction, along with the block-quote unwrap, the system-message filter, the node move and the docutils < 0.22 source-info fallback. On valid line blocks the HTML, LaTeX, text, man, texinfo and .pot message output is byte-identical to #308's, on Sphinx 7.4, 8.2 and 9.1; the .pot locations on docutils < 0.22 now name each line instead of the first. All of #308's tests pass unchanged; the malformed bodies are added as regressions (they fail on #308's mechanism), with unit tests of the splitter. --- sphinx_design/badges_buttons.py | 140 ++++++++++++-------------------- tests/test_buttons.py | 111 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 89 deletions(-) diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index 1b743d4..efe7fa7 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -364,91 +364,40 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: return [node], [] -_LINE_BLOCK_START = re.compile(r"[ \t]*\|( +|$)") -"""The start of an RST line block (docutils' ``line_block`` pattern), indented.""" +_LINE_BLOCK_START = re.compile(r"\|( +|$)") +"""The start of an RST line-block line (docutils' ``line_block`` pattern).""" -def _is_line_block_text(content: Sequence[str]) -> bool: - """Return whether ``content`` looks like nothing but RST line blocks. +def _line_block_lines(content: Sequence[str]) -> list[tuple[int, str]] | None: + """Split ``content`` into its line-block lines, if it is nothing but those. - Every non-blank line at the outermost indent must start a line-block line; - deeper lines are allowed, as continuation lines or nested blocks. - Only such a body is block-parsed, and any other body (one that starts with - a substitution, such as ``|icon| Download``, or a line block followed by a - paragraph) is parsed once, as inline text: parsing it twice would repeat - the side effects of the first parse (messages, footnote numbering, - substitution definitions). - - The check is textual, so a deeper-indented paragraph after a blank line - still passes it; the parsed result is then checked as well, and such a - body falls back to inline text after all. - """ - lines = [line for line in content if line.strip()] - if not lines: - return False - indent = min(len(line) - len(line.lstrip()) for line in lines) - return all( - _LINE_BLOCK_START.match(line) is not None - for line in lines - if len(line) - len(line.lstrip()) == indent - ) - - -def _non_messages(node: nodes.Element) -> list[nodes.Node]: - """Return the children of ``node`` that are not system messages.""" - return [c for c in node.children if not isinstance(c, nodes.system_message)] - - -def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None: - """Return the lines of the line blocks in ``node``, if it contains nothing else. - - Bars indented past the directive options (the markup in #156) are parsed as - a block quote whose only child is the line block, so such block quotes are - unwrapped. Line blocks separated by a blank line, and nested line blocks, - contribute their lines in document order. - System messages are ignored: docutils places those of the inline markup in - a line (e.g. an unknown role) beside the line block, and has already - reported them, so falling back to inline text would parse it twice. + Return ``(offset, text)`` per line (``offset`` into ``content``), or None. + Every non-blank line at the first line's indent must start with a bar + (``|`` and a space, or a bare ``|``); a deeper line directly after a + non-blank line continues the previous line. Anything else is not a line + block. The check is purely textual, so the text is parsed exactly once. """ - children = _non_messages(node) - while len(children) == 1 and isinstance(children[0], nodes.block_quote): - children = _non_messages(children[0]) - if not children or not all(isinstance(c, nodes.line_block) for c in children): - return None - return [line for block in children for line in block.findall(nodes.line)] - - -def _join_button_lines( - lines: list[nodes.line], source: str | None, lineno: int | None -) -> list[nodes.Node]: - """Return the content of ``lines``, with a line break between lines. - - Each line is a translatable message of its own (as in a normal line block), - so a message never contains a line break. - A line that docutils gave no source info (before docutils 0.22) is given - ``source`` and ``lineno`` instead: sphinx extracts no message from a - translatable node without a source. - """ - pieces: list[nodes.Node] = [] - for index, line in enumerate(lines): - if index: - pieces.append(sd_line_break("", " ")) - if not line.children: + result: list[tuple[int, str]] = [] + indent: int | None = None + previous_blank = True + for offset, raw in enumerate(content): + text = raw.lstrip() + if not text: + previous_blank = True continue - # move (rather than copy) the parsed inline nodes: docutils registers - # some of them (e.g. footnote and named references) on the document - children = line.children[:] - del line[:] - translatable = nodes.inline(line.rawsource, "", *children, translatable=True) - if line.source is None: - translatable.source, translatable.line = source, lineno + depth = len(raw) - len(text) + if indent is None: + indent = depth + match = _LINE_BLOCK_START.match(text) if depth == indent else None + if match is not None: + result.append((offset, text[match.end() :].strip())) + elif depth > indent and result and not previous_blank: + line_offset, line_text = result[-1] + result[-1] = (line_offset, f"{line_text}\n{text}") else: - translatable.source, translatable.line = line.source, line.line - # sphinx unwraps a translatable inline by appending its children to its - # parent (RemoveTranslatableInline): give each line a parent of its own, - # so that the lines keep their order - pieces.append(nodes.inline("", "", translatable)) - return pieces + return None + previous_blank = False + return result or None class _ButtonDirective(SdDirective): @@ -489,18 +438,31 @@ def _content_nodes(self) -> list[nodes.Node]: 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. + would keep the ``|`` characters. The lines are found textually and + each is parsed once, as inline text, into a gettext message of its own. """ - if isinstance(self.state, RSTState) and _is_line_block_text(self.content): - container = nodes.Element() - self.state.nested_parse(self.content, self.content_offset, container) - lines = _line_block_lines(container) - if lines is not None: - # the first content line (content_offset is a 0-based offset) - source, lineno = self.state_machine.get_source_and_line( - self.content_offset + 1 + lines = ( + _line_block_lines(self.content) + if isinstance(self.state, RSTState) + else None + ) + if lines is not None: + pieces: list[nodes.Node] = [] + for index, (offset, text) in enumerate(lines): + if index: + pieces.append(sd_line_break("", " ")) + if not text: + continue + lineno = self.content_offset + offset + 1 + textnodes, _ = self.state.inline_text(text, lineno) + translatable = nodes.inline(text, "", *textnodes, translatable=True) + translatable.source, translatable.line = ( + self.state_machine.get_source_and_line(lineno) ) - return _join_button_lines(lines, source, lineno) + # sphinx unwraps a translatable inline into its parent + # (RemoveTranslatableInline): give each line its own parent + pieces.append(nodes.inline("", "", translatable)) + return pieces 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 d0b3835..be58c46 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -25,6 +25,7 @@ _BUTTON_REF_MARKER_PREFIX, ButtonLinkDirective, ButtonRefDirective, + _line_block_lines, ) from sphinx_design.icons import sd_icon @@ -610,3 +611,113 @@ def test_button_link_line_block_translated(sphinx_builder): builder.build() html = (builder.out_path / "index.html").read_text(encoding="utf8") assert _button_texts(html) == ["hallo
neue Zeile"] + + +# Bodies that look like a line block at their outermost indent, but are not +# only line-block lines. Each must be parsed once, as inline text, exactly as +# before #156 was fixed: ``(body, expected button text, expected warnings)``. +NOT_ONLY_LINE_BLOCK_CASES = { + # docutils: a line block, then a block quote holding another line block + "blank-then-deeper-bar": ( + " | a [#]_\n\n | b\n", + "| a ' in html + + +SPLIT_CASES = { + "plain": (["| a", "| b"], [(0, "a"), (1, "b")]), + "indented": ([" | a", " | b"], [(0, "a"), (1, "b")]), + "blank-between": (["| a", "", "| b"], [(0, "a"), (2, "b")]), + "bare-bar": (["| a", "|", "| b"], [(0, "a"), (1, ""), (2, "b")]), + "continuation": (["| a", " more", "| b"], [(0, "a\nmore"), (2, "b")]), + "nested": (["| a", "| b"], [(0, "a"), (1, "b")]), + "empty": ([], None), + "substitution-first": (["|sub| a"], None), + "paragraph-after": (["| a", "", "b"], None), + "deeper-after-blank": (["| a", "", " | b"], None), + "first-line-deeper": ([" | a", "| b"], None), + "deeper-first-line-only": ([" | a"], [(0, "a")]), +} + + +@pytest.mark.parametrize( + "content,expected", SPLIT_CASES.values(), ids=SPLIT_CASES.keys() +) +def test_line_block_lines(content, expected): + """The text is split into line-block lines, or rejected, without parsing it. + + A deeper line continues the previous line only directly after it, as in + docutils; after a blank line it starts a block quote, which is not a line. + """ + assert _line_block_lines(content) == expected From e600250a74aeaaab14d475a7bd39bc7cf7e5388b Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 23:19:56 +0100 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Buttons:=20locate?= =?UTF-8?q?=20RST=20button=20text=20at=20its=20own=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In RST, a directive's content_offset is the offset of its content in the whole input, so it already counts the lines before the directive. The inline path of button-link and button-ref added it to the directive's line anyway, both for the line it gave inline_text and for the gettext location of the text, so the lines before the directive were counted twice. A role error in the text of a button at line 4 of a page was reported at line 9 instead of 6, inside a dropdown it named a line past the end of the file, and for a button in an included file it named the included file's line plus the offset into the including page, attributed to the including page. The text is now located at its first content line, through the state machine, so an included file's text names that file. As for a paragraph, docutils reports the messages of the inline markup at the text's first line. MyST's content_offset counts the lines from the directive, so its line is unchanged (measured identical warnings and .pot on Sphinx 7.4, 8.2 and 9.1). The gettext regression fixture recorded the wrong line (19 for text at line 11) and is corrected. --- sphinx_design/badges_buttons.py | 16 ++++-- tests/test_buttons.py | 59 ++++++++++++++++++++ tests/test_misc/test_button_i18n_gettext.pot | 2 +- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index efe7fa7..faf000b 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -463,14 +463,20 @@ def _content_nodes(self) -> list[nodes.Node]: # (RemoveTranslatableInline): give each line its own parent pieces.append(nodes.inline("", "", translatable)) return pieces - textnodes, _ = self.state.inline_text( - "\n".join(self.content), self.lineno + self.content_offset - ) + if isinstance(self.state, RSTState): + # content_offset is the (0-based) offset of the first content line + # in the input, so it already counts the lines before the directive + lineno = self.content_offset + 1 + else: + # MyST: content_offset counts the lines from the directive's line + lineno = self.lineno + self.content_offset + textnodes, _ = self.state.inline_text("\n".join(self.content), lineno) # 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 + translatable.source, translatable.line = self.state_machine.get_source_and_line( + lineno + ) return [translatable] def run_with_defaults(self) -> list[nodes.Node]: diff --git a/tests/test_buttons.py b/tests/test_buttons.py index be58c46..5fb421a 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -721,3 +721,62 @@ def test_line_block_lines(content, expected): docutils; after a blank line it starts a block quote, which is not a line. """ assert _line_block_lines(content) == expected + + +INLINE_LINES_RST = """\ +Heading +======= + +.. button-link:: https://example.com + + top one + top :notarole:`x` + +.. dropdown:: Drop + + para + + .. button-link:: https://example.com + + in one + in :notarole:`y` + +.. include:: inc.txt +""" + +INLINE_LINES_INC = """\ +.. button-link:: https://example.com + + inc one + inc :notarole:`z` +""" + + +def test_button_link_inline_text_lines(sphinx_builder): + """Button text is located at its first content line, in its own file. + + ``content_offset`` is the offset of the content in the whole input, so + adding it to the directive's line counted the lines before the directive + twice: messages and gettext locations named a later line, past the end of + the file inside a nested directive, and the included file's line plus the + offset into the including one. As for a paragraph, docutils reports the + messages of the inline markup at the text's first line. + """ + builder = sphinx_builder("gettext", conf_kwargs={"extensions": ["sphinx_design"]}) + builder.src_path.joinpath("index.rst").write_text(INLINE_LINES_RST, encoding="utf8") + builder.src_path.joinpath("inc.txt").write_text(INLINE_LINES_INC, encoding="utf8") + builder.build(assert_pass=False) + reported = re.findall( + r"(\w+\.(?:rst|txt)):(\d+): ERROR: Unknown interpreted text role", + builder.warnings, + ) + assert reported == [("index.rst", "6"), ("index.rst", "15"), ("inc.txt", "3")], ( + builder.warnings + ) + pot = (builder.out_path / "index.pot").read_text(encoding="utf8") + for location, msgid in [ + ("index.rst:6", "top one top :notarole:`x`"), + ("index.rst:15", "in one in :notarole:`y`"), + ("inc.txt:3", "inc one inc :notarole:`z`"), + ]: + assert f'{location}\nmsgid "{msgid}"' in pot, pot diff --git a/tests/test_misc/test_button_i18n_gettext.pot b/tests/test_misc/test_button_i18n_gettext.pot index 3177fd1..d789828 100644 --- a/tests/test_misc/test_button_i18n_gettext.pot +++ b/tests/test_misc/test_button_i18n_gettext.pot @@ -6,7 +6,7 @@ msgstr "" msgid "Target Section" msgstr "" -#: ../../index.rst:19 +#: ../../index.rst:11 msgid "Click me now" msgstr "" From aae4984f441667c4f76d76652f3e215266bc9003 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 23:20:58 +0100 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Buttons:=20say=20w?= =?UTF-8?q?here=20a=20MyST=20hard=20break=20in=20button=20text=20falls=20s?= =?UTF-8?q?hort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs tell MyST users to break button text with a hard line break (a backslash at the end of the line). That breaks the line in HTML and LaTeX, but the text builder joins the lines ("Multi-linebutton text") and the gettext message contains the raw break markup ("Multi-line
\\ button text"), the two defects the RST line block avoids. The note now says so, and points translated projects at the RST line block, which works from MyST inside an eval-rst block (separate lines in text output, one message per line). --- docs/badges_buttons.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index 594746c..4f32edc 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -182,7 +182,11 @@ and icons), which is rendered in the button, just as it is for `button-link`: 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: +backslash (`\`, a hard line break) in MyST Markdown. +The MyST hard line break only breaks the line in HTML and LaTeX output: +other builders, such as text, join the lines, and the gettext message contains +the raw break markup, so translated projects should use the reStructuredText +line block (in MyST, inside an `eval-rst` block): ```{eval-rst} .. button-link:: https://example.com From 4df727fabcb78bf2f909732e1486f96354c06f08 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 10 Nov 2026 00:07:05 +0100 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Buttons:=20pin=20a?= =?UTF-8?q?=20leading=20bar=20as=20a=20line-block=20marker,=20and=20its=20?= =?UTF-8?q?escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-line RST label written `| Docs | API |` is a one-line line block, so it renders "Docs | API |"; a literal leading bar is written `\|`. Test both, and say so in the button docs. --- docs/badges_buttons.md | 3 +++ tests/test_buttons.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index 4f32edc..f294783 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -214,6 +214,9 @@ button text ``` ```` +In reStructuredText, a label that itself starts with `| ` is read as a one-line +line block, whose leading `| ` is not shown, so write a literal leading bar as `\|`. + Reference targets may also contain spaces, for example the labels that `sphinx.ext.autosectionlabel` generates from section titles. diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 5fb421a..ea169d2 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -458,6 +458,30 @@ def test_button_link_bar_text_is_not_a_line_block(sphinx_builder): assert text.startswith("Download the notes"), text +LEADING_BAR_CASES = { + # a leading "| " is a line-block marker, as anywhere else in RST: the + # label is a one-line line block, whose marker is consumed + "line-block": (" | Docs | API |\n", "Docs | API |"), + # an escaped leading bar is not a marker, so it renders literally + "escaped": (" \\| Docs | API |\n", "| Docs | API |"), +} + + +@pytest.mark.parametrize( + "body,expected", LEADING_BAR_CASES.values(), ids=LEADING_BAR_CASES.keys() +) +def test_button_link_leading_bar(body, expected, sphinx_builder): + """A single-line label starting with ``| `` reads as a line block. + + Its leading ``| `` is consumed as the line-block marker, and only that one: + later bars are text. A literal leading bar is written ``\\|``. + """ + rst = f"Heading\n=======\n\n.. button-link:: https://example.com\n\n{body}" + builder = _build_rst(sphinx_builder, rst) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert _button_texts(html) == [expected] + + LINE_BLOCK_THEN_PARAGRAPH_RST = """\ Heading ======= From 0b3d1b3246f1818fbf50859909afa380abc552ad Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 10 Nov 2026 00:31:35 +0100 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Buttons:=20keep=20a?= =?UTF-8?q?=20line-block=20line's=20continuation=20indent=20as=20docutils?= =?UTF-8?q?=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splitter stripped every continuation line, and kept the empty first line of a bare bar. docutils (and so #308's block parse) strips only the common indent of the continuation lines, so they keep their indent relative to each other, and drops a bare bar's empty first line. A continuation line indented deeper than the one before it therefore lost its leading spaces in text, HTML, LaTeX, man and .pot output, and `|` followed by a continuation line gained a leading newline. Both now match main and docutils. --- sphinx_design/badges_buttons.py | 18 +++++++++++++----- tests/test_buttons.py | 4 ++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index faf000b..36c3229 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -376,8 +376,10 @@ def _line_block_lines(content: Sequence[str]) -> list[tuple[int, str]] | None: (``|`` and a space, or a bare ``|``); a deeper line directly after a non-blank line continues the previous line. Anything else is not a line block. The check is purely textual, so the text is parsed exactly once. + A line's text is as docutils gives it: its continuation lines keep their + indent relative to each other, and a bare bar's empty first line is dropped. """ - result: list[tuple[int, str]] = [] + found: list[tuple[int, list[str]]] = [] indent: int | None = None previous_blank = True for offset, raw in enumerate(content): @@ -390,13 +392,19 @@ def _line_block_lines(content: Sequence[str]) -> list[tuple[int, str]] | None: indent = depth match = _LINE_BLOCK_START.match(text) if depth == indent else None if match is not None: - result.append((offset, text[match.end() :].strip())) - elif depth > indent and result and not previous_blank: - line_offset, line_text = result[-1] - result[-1] = (line_offset, f"{line_text}\n{text}") + found.append((offset, [text[match.end() :].strip()])) + elif depth > indent and found and not previous_blank: + found[-1][1].append(raw) else: return None previous_blank = False + result: list[tuple[int, str]] = [] + for offset, (first, *more) in found: + # as docutils' ``get_first_known_indented``: strip only the common + # indent of the continuation lines, and drop an empty first line + cut = min((len(m) - len(m.lstrip()) for m in more), default=0) + parts = [first, *(m[cut:] for m in more)] + result.append((offset, "\n".join(parts if first else parts[1:]))) return result or None diff --git a/tests/test_buttons.py b/tests/test_buttons.py index ea169d2..1d23cd9 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -725,6 +725,10 @@ def test_button_link_not_only_line_block_parsed_once( "blank-between": (["| a", "", "| b"], [(0, "a"), (2, "b")]), "bare-bar": (["| a", "|", "| b"], [(0, "a"), (1, ""), (2, "b")]), "continuation": (["| a", " more", "| b"], [(0, "a\nmore"), (2, "b")]), + # docutils strips only the common indent of the continuation lines + "continuation-relative-indent": (["| a", " b", " c"], [(0, "a\n b\nc")]), + # docutils drops the empty first line of a bare bar with a continuation + "bare-bar-continuation": (["|", " x"], [(0, "x")]), "nested": (["| a", "| b"], [(0, "a"), (1, "b")]), "empty": ([], None), "substitution-first": (["|sub| a"], None), From ffc0582e0eb5f2022a8bf1f4c82df3d0fd14d178 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 10 Nov 2026 00:32:58 +0100 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Buttons:=20pin=20e?= =?UTF-8?q?ach=20line-block=20line's=20own=20source=20and=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locating every line at the directive, or at the first line of the line block, passed the whole suite. The gettext test now asserts each line's .pot location, the message test asserts the unknown role is reported at the second line, and a new test checks both for a line-block button in an included file. --- tests/test_buttons.py | 44 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 1d23cd9..7f1fd31 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -535,6 +535,10 @@ def test_button_link_line_block_with_message_parsed_once(indent, sphinx_builder) assert builder.warnings.count('Unknown interpreted text role "notarole"') == 1, ( builder.warnings ) + # reported at the line it is on, the second line of the line block + assert re.search( + r"index\.rst:8: ERROR: Unknown interpreted text role", builder.warnings + ), 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) @@ -609,8 +613,9 @@ def test_button_link_line_block_gettext(sphinx_builder): """ 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 + # each message is located at its own line, not at the first line's + assert 'index.rst:6\nmsgid "hey"' in pot, pot + assert 'index.rst:7\nmsgid "new line"' in pot, pot assert "hey new line" not in pot, pot assert " Date: Tue, 10 Nov 2026 00:33:12 +0100 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Buttons:=20reword?= =?UTF-8?q?=20#308=20test=20comments=20for=20the=20line=20splitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments in the #308 tests still described the removed block parse (nodes moved out of it, messages placed beside the line block, a source fallback for docutils < 0.22). Their assertions are unchanged. --- tests/test_buttons.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 7f1fd31..9b1fb9e 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -386,8 +386,8 @@ def _build_rst(sphinx_builder, rst, buildername="html", **files): " | *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 + # the text is parsed once, so the autonumbered footnote reference is + # registered once, and numbered and resolved as in a paragraph "footnote-reference": ( " | hey\n | new line [#]_\n\n.. [#] A footnote.\n", "hey
new line Date: Tue, 10 Nov 2026 00:33:40 +0100 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Buttons:=20say=20t?= =?UTF-8?q?hat=20some=20builders=20print=20a=20MyST=20hard=20break=20raw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texinfo prints the raw break markup of a MyST hard line break in button text, rather than joining the lines as the text builder does. The note on a leading bar also covers a bare `|` label, which is an empty line block. --- docs/badges_buttons.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index f294783..f83049d 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -184,9 +184,9 @@ 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. The MyST hard line break only breaks the line in HTML and LaTeX output: -other builders, such as text, join the lines, and the gettext message contains -the raw break markup, so translated projects should use the reStructuredText -line block (in MyST, inside an `eval-rst` block): +other builders join the lines (text) or print the raw break markup (texinfo), +and the gettext message contains that markup too, so translated projects should +use the reStructuredText line block (in MyST, inside an `eval-rst` block): ```{eval-rst} .. button-link:: https://example.com @@ -214,8 +214,9 @@ button text ``` ```` -In reStructuredText, a label that itself starts with `| ` is read as a one-line -line block, whose leading `| ` is not shown, so write a literal leading bar as `\|`. +In reStructuredText, a label that itself starts with `| ` (or is a bare `|`) is read +as a one-line line block, whose leading bar is not shown, so write a literal leading +bar as `\|`. Reference targets may also contain spaces, for example the labels that `sphinx.ext.autosectionlabel` generates from section titles. From 6d231b6bb5834dfef1bc6de036f63503eac39f3d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 10 Nov 2026 01:00:56 +0100 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Buttons:=20keep=20t?= =?UTF-8?q?he=20whitespace=20after=20a=20line-block=20bar,=20as=20docutils?= =?UTF-8?q?=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only spaces indent an RST line, and docutils keeps a line block line's text after the bar as is. The splitter stripped any Unicode whitespace in both places, so `| \xa0text` (a no-break or ideographic space, the one way to indent a line visibly in a button) lost its leading space, and a line starting with a no-break space before a bar was read as an indented bar. Pinned by four split cases and an exact-HTML test, which also pins that a bare bar adds a break and no empty element. --- sphinx_design/badges_buttons.py | 6 ++++-- tests/test_buttons.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index 36c3229..9caa045 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -378,12 +378,14 @@ def _line_block_lines(content: Sequence[str]) -> list[tuple[int, str]] | None: block. The check is purely textual, so the text is parsed exactly once. A line's text is as docutils gives it: its continuation lines keep their indent relative to each other, and a bare bar's empty first line is dropped. + As in docutils, only spaces indent a line, and the text after the bar is + kept as is, so other whitespace there (a no-break space) is not stripped. """ found: list[tuple[int, list[str]]] = [] indent: int | None = None previous_blank = True for offset, raw in enumerate(content): - text = raw.lstrip() + text = raw.lstrip(" ") if not text: previous_blank = True continue @@ -392,7 +394,7 @@ def _line_block_lines(content: Sequence[str]) -> list[tuple[int, str]] | None: indent = depth match = _LINE_BLOCK_START.match(text) if depth == indent else None if match is not None: - found.append((offset, [text[match.end() :].strip()])) + found.append((offset, [text[match.end() :]])) elif depth > indent and found and not previous_blank: found[-1][1].append(raw) else: diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 9b1fb9e..1b9062d 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -408,6 +408,28 @@ def test_button_link_line_block(body, expected, sphinx_builder): assert "|" not in text +def test_button_link_line_block_exact_html(sphinx_builder): + """The exact HTML of a line block, which ``_button_texts`` normalises away. + + A bare bar is an empty line, so it adds a break and no empty element. + The whitespace after a bar is kept as docutils keeps it, so a no-break or + ideographic space can indent a line's text (the nesting is flattened). + """ + rst = ( + "Heading\n=======\n\n" + ".. button-link:: https://example.com\n\n" + " | hey\n |\n | new line\n\n" + ".. button-link:: https://example.com\n\n" + " | \xa0hey\n | \u3000new line\n" + ) + builder = _build_rst(sphinx_builder, rst) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert _BUTTON_RE.findall(html) == [ + "hey

new line
", + "\xa0hey
\u3000new line
", + ] + + def test_button_ref_line_block(sphinx_builder): """``button-ref`` shares the line-block path, including after resolution.""" rst = """\ @@ -734,6 +756,12 @@ def test_button_link_not_only_line_block_parsed_once( # docutils drops the empty first line of a bare bar with a continuation "bare-bar-continuation": (["|", " x"], [(0, "x")]), "nested": (["| a", "| b"], [(0, "a"), (1, "b")]), + # docutils keeps the text after the bar as is, other whitespace included + "nbsp-after-bar": (["| \xa0a"], [(0, "\xa0a")]), + "ideographic-space-after-bar": (["| \u3000a"], [(0, "\u3000a")]), + # only spaces indent a line: a no-break space before a bar is text + "nbsp-indent": (["| a", "\xa0| b"], None), + "nbsp-first-line": (["\xa0| a"], None), "empty": ([], None), "substitution-first": (["|sub| a"], None), "paragraph-after": (["| a", "", "b"], None), From b3e0abc443517687ce14a7b19b846b94f2a212aa Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 10 Nov 2026 01:01:16 +0100 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Buttons:=20pin=20t?= =?UTF-8?q?he=20no-bar=20rule=20and=20a=20line's=20msgid=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split case pins that a line at the bar's indent without a bar ends the line block even directly after a bar, so `| Docs` then `More` is not one line. A gettext test pins that a line's message is its source text, markup included (`**hey** there`), not the text it renders. --- tests/test_buttons.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 1b9062d..2e68567 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -641,6 +641,19 @@ def test_button_link_line_block_gettext(sphinx_builder): assert " Date: Tue, 10 Nov 2026 01:01:16 +0100 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Buttons:=20say=20t?= =?UTF-8?q?he=20leading-bar=20rule=20is=20for=20a=20one-line=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A two-line label whose second line is plain text is not a line block, so its leading bar is shown. --- docs/badges_buttons.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index f83049d..5f1718b 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -214,7 +214,7 @@ button text ``` ```` -In reStructuredText, a label that itself starts with `| ` (or is a bare `|`) is read +In reStructuredText, a one-line label that starts with `| ` (or is a bare `|`) is read as a one-line line block, whose leading bar is not shown, so write a literal leading bar as `\|`.