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 ""