diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md
index 594746c6..5f1718b7 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 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
@@ -210,6 +214,10 @@ button text
```
````
+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 `\|`.
+
Reference targets may also contain spaces, for example the labels that
`sphinx.ext.autosectionlabel` generates from section titles.
diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py
index 1b743d49..9caa045a 100644
--- a/sphinx_design/badges_buttons.py
+++ b/sphinx_design/badges_buttons.py
@@ -364,91 +364,50 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
return [node], []
-_LINE_BLOCK_START = re.compile(r"[ \t]*\|( +|$)")
-"""The start of an RST line block (docutils' ``line_block`` pattern), indented."""
-
-
-def _is_line_block_text(content: Sequence[str]) -> bool:
- """Return whether ``content`` looks like nothing but RST line blocks.
-
- Every non-blank line at the outermost indent must start a line-block line;
- deeper lines are allowed, as continuation lines or nested blocks.
- Only such a body is block-parsed, and any other body (one that starts with
- a substitution, such as ``|icon| Download``, or a line block followed by a
- paragraph) is parsed once, as inline text: parsing it twice would repeat
- the side effects of the first parse (messages, footnote numbering,
- substitution definitions).
-
- The check is textual, so a deeper-indented paragraph after a blank line
- still passes it; the parsed result is then checked as well, and such a
- body falls back to inline text after all.
- """
- lines = [line for line in content if line.strip()]
- if not lines:
- return False
- indent = min(len(line) - len(line.lstrip()) for line in lines)
- return all(
- _LINE_BLOCK_START.match(line) is not None
- for line in lines
- if len(line) - len(line.lstrip()) == indent
- )
-
-
-def _non_messages(node: nodes.Element) -> list[nodes.Node]:
- """Return the children of ``node`` that are not system messages."""
- return [c for c in node.children if not isinstance(c, nodes.system_message)]
-
-
-def _line_block_lines(node: nodes.Element) -> list[nodes.line] | None:
- """Return the lines of the line blocks in ``node``, if it contains nothing else.
-
- Bars indented past the directive options (the markup in #156) are parsed as
- a block quote whose only child is the line block, so such block quotes are
- unwrapped. Line blocks separated by a blank line, and nested line blocks,
- contribute their lines in document order.
- System messages are ignored: docutils places those of the inline markup in
- a line (e.g. an unknown role) beside the line block, and has already
- reported them, so falling back to inline text would parse it twice.
- """
- children = _non_messages(node)
- while len(children) == 1 and isinstance(children[0], nodes.block_quote):
- children = _non_messages(children[0])
- if not children or not all(isinstance(c, nodes.line_block) for c in children):
- return None
- return [line for block in children for line in block.findall(nodes.line)]
-
-
-def _join_button_lines(
- lines: list[nodes.line], source: str | None, lineno: int | None
-) -> list[nodes.Node]:
- """Return the content of ``lines``, with a line break between lines.
-
- Each line is a translatable message of its own (as in a normal line block),
- so a message never contains a line break.
- A line that docutils gave no source info (before docutils 0.22) is given
- ``source`` and ``lineno`` instead: sphinx extracts no message from a
- translatable node without a source.
+_LINE_BLOCK_START = re.compile(r"\|( +|$)")
+"""The start of an RST line-block line (docutils' ``line_block`` pattern)."""
+
+
+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.
+
+ 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.
+ 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.
"""
- pieces: list[nodes.Node] = []
- for index, line in enumerate(lines):
- if index:
- pieces.append(sd_line_break("", " "))
- if not line.children:
+ found: list[tuple[int, list[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:
+ found.append((offset, [text[match.end() :]]))
+ elif depth > indent and found and not previous_blank:
+ found[-1][1].append(raw)
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
+ 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
class _ButtonDirective(SdDirective):
@@ -489,26 +448,45 @@ 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
- )
- return _join_button_lines(lines, source, lineno)
- textnodes, _ = self.state.inline_text(
- "\n".join(self.content), self.lineno + self.content_offset
+ 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)
+ )
+ # sphinx unwraps a translatable inline into its parent
+ # (RemoveTranslatableInline): give each line its own parent
+ pieces.append(nodes.inline("", "", translatable))
+ return pieces
+ 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 d0b38353..2e685672 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
@@ -385,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 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 = """\
@@ -457,6 +480,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
=======
@@ -494,11 +541,11 @@ def test_button_link_line_block_then_paragraph_parsed_once(sphinx_builder):
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.
+ A message of a line's inline markup (here, an unknown role) does not stop
+ the text being read as lines, and each line is parsed once: the message
+ is reported once, at its own line, there is no spurious "Too many
+ autonumbered footnote references", the footnote reference resolves, and
+ the bars are not kept.
"""
rst = (
"Heading\n=======\n\n.. button-link:: https://example.com\n :expand:\n\n"
@@ -510,6 +557,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)
@@ -578,18 +629,31 @@ 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.
- 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.
+ sphinx extracts no message from a node without a source, so each line's
+ message carries the source and line of that line, on every docutils.
"""
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 "
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")]),
+ # 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")]),
+ # 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),
+ # a line at the bar's indent with no bar is not a continuation, even
+ # directly after a bar (docutils: "Line block ends without a blank line")
+ "paragraph-directly-after": (["| a", "b"], 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
+
+
+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
+
+
+LINE_BLOCK_LINES_INC = """\
+Included
+--------
+
+.. button-link:: https://example.com
+
+ | inc one
+ | inc :notarole:`z`
+"""
+
+
+def test_button_link_line_block_lines(sphinx_builder):
+ """Each line of a line block is located at its own line, in its own file.
+
+ A message of a line's inline markup is reported at that line, and the
+ line's gettext message is located there, also in an included file.
+ """
+ builder = sphinx_builder("gettext", conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(
+ "Heading\n=======\n\n.. include:: inc.txt\n", encoding="utf8"
+ )
+ builder.src_path.joinpath("inc.txt").write_text(
+ LINE_BLOCK_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 == [("inc.txt", "7")], builder.warnings
+ pot = (builder.out_path / "index.pot").read_text(encoding="utf8")
+ assert 'inc.txt:6\nmsgid "inc one"' in pot, pot
+ assert 'inc.txt:7\nmsgid "inc :notarole:`z`"' in pot, pot
diff --git a/tests/test_misc/test_button_i18n_gettext.pot b/tests/test_misc/test_button_i18n_gettext.pot
index 3177fd18..d789828c 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 ""