diff --git a/.gitignore b/.gitignore index 32bd99f..3bfa0ea 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ cover/ # Translations *.mo *.pot +!tests/test_snippets/*.pot +!tests/test_misc/*.pot # Django stuff: *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d90853..9891a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## Unreleased + +- 🐛 FIX: buttons are no longer destroyed by gettext translation: + translated `button-link`/`button-ref` keep their styling and links + (gettext now targets only the button text), thanks to {user}`sneakers-the-rat` + in {pr}`264` ({issue}`96`, {issue}`44`, {issue}`263`) + ## 0.7.0 - 2025-01-19 ### Dependencies diff --git a/docs/conf.py b/docs/conf.py index 4906999..4b25ff4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,10 @@ extlinks = { "pr": ("https://github.com/executablebooks/sphinx-design/pull/%s", "PR #%s"), + "issue": ( + "https://github.com/executablebooks/sphinx-design/issues/%s", + "#%s", + ), "user": ("https://github.com/%s", "@%s"), } diff --git a/docs/snippets/myst/button-link.txt b/docs/snippets/myst/button-link.txt index ed372cf..056e6be 100644 --- a/docs/snippets/myst/button-link.txt +++ b/docs/snippets/myst/button-link.txt @@ -5,6 +5,12 @@ Button text ``` +```{button-link} https://example.com +:color: primary + +Button text with options +``` + ```{button-link} https://example.com :color: primary :shadow: diff --git a/docs/snippets/rst/button-link.txt b/docs/snippets/rst/button-link.txt index d6817f3..9a2327a 100644 --- a/docs/snippets/rst/button-link.txt +++ b/docs/snippets/rst/button-link.txt @@ -4,6 +4,11 @@ Button text +.. button-link:: https://example.com + :color: primary + + Button text with options + .. button-link:: https://example.com :color: primary :shadow: diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index 61b83a0..1dac332 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -178,8 +178,16 @@ def run_with_defaults(self) -> list[nodes.Node]: textnodes, _ = self.state.inline_text( "\n".join(self.content), self.lineno + self.content_offset ) - content = nodes.inline("", "") - content.extend(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 + # the 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) else: content = nodes.inline(target, target) node.append(content) @@ -191,7 +199,9 @@ def run_with_defaults(self) -> list[nodes.Node]: node = grid_container # `visit_reference` requires that a reference be inside a `TextElement` parent - container = nodes.paragraph(classes=self.options.get("align", [])) + container = nodes.paragraph( + classes=self.options.get("align", []), translatable=False + ) self.set_source_info(container) container += node diff --git a/tests/conftest.py b/tests/conftest.py index cc112c7..28324c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,10 +68,12 @@ def get_doctree( return doctree -@pytest.fixture() -def sphinx_builder(tmp_path: Path, make_app, monkeypatch): +@pytest.fixture(params=[pytest.param("html", id="html")]) +def sphinx_builder( + tmp_path: Path, make_app, monkeypatch, request: pytest.FixtureRequest +): def _create_project( - buildername: str = "html", conf_kwargs: dict[str, Any] | None = None + buildername: str = request.param, conf_kwargs: dict[str, Any] | None = None ): src_path = tmp_path / "srcdir" src_path.mkdir() @@ -120,6 +122,8 @@ def _normalize(text: str) -> str: "refexplicit", "refwarn", "selected", + "translatable", + "translated", ] text = re.sub(rf' ({"|".join(attrs)})="1"', r' \1="True"', text) text = re.sub(rf' ({"|".join(attrs)})="0"', r' \1="False"', text) diff --git a/tests/test_misc.py b/tests/test_misc.py index 77fceea..fa550ea 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,3 +1,5 @@ +from babel.messages.catalog import Catalog +from babel.messages.mofile import write_mo from docutils import nodes import pytest @@ -73,6 +75,7 @@ def test_tab_set_with_invalid_children( doctree.attributes.pop("translation_progress", None) file_regression.check( normalize_doctree_xml(doctree.pformat()), + basename="test_tab_set_with_invalid_children", extension=".xml", encoding="utf8", ) @@ -342,3 +345,100 @@ def test_tab_set_with_paragraph_warns(sphinx_builder): builder.build(assert_pass=False) assert "All children of a 'tab-set' should be 'tab-item'" in builder.warnings assert "[design.tab]" in builder.warnings + + +I18N_INDEX_RST = """\ +Heading +======= + +.. _target-section: + +Target Section +============== + +.. button-link:: https://example.com + + Click me now + +.. button-ref:: target-section + :ref-type: ref + + Go to target + +See the :bdg-primary:`stable` badge. +""" + + +def test_button_i18n_gettext(sphinx_builder, file_regression): + """Gettext extraction should target only the button text, not the directive. + + See https://github.com/executablebooks/sphinx-design/issues/96 + + Known gap (visible in the regression file): ``button-ref`` text is absent + from the ``.pot`` because the gettext builder extracts from the resolved + doctree, and std-domain xref resolution flattens explicit-title content, + discarding the translatable marker. Translation itself still works for + both button types (see ``test_button_i18n_translated``), since the Locale + transform runs before resolution. + """ + builder = sphinx_builder("gettext", conf_kwargs={"extensions": ["sphinx_design"]}) + builder.src_path.joinpath("index.rst").write_text(I18N_INDEX_RST, encoding="utf8") + builder.build() + out = (builder.out_path / "index.pot").read_text(encoding="utf8") + # strip the metadata header (contains a varying timestamp) + out = out[out.find("#: ") :] + file_regression.check( + out, basename="test_button_i18n_gettext", extension=".pot", encoding="utf8" + ) + + +def test_button_i18n_translated(sphinx_builder): + """Translated buttons must keep their classes, link and resolve refs. + + See https://github.com/executablebooks/sphinx-design/issues/44 + and https://github.com/executablebooks/sphinx-design/issues/263 + """ + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["sphinx_design"], + "language": "de", + "locale_dirs": ["locales"], + } + ) + builder.src_path.joinpath("index.rst").write_text(I18N_INDEX_RST, encoding="utf8") + catalog = Catalog(locale="de", domain="index") + catalog.add("Click me now", "Klick mich jetzt") + catalog.add("Go to target", "Zum Ziel") + mo_dir = builder.src_path / "locales" / "de" / "LC_MESSAGES" + mo_dir.mkdir(parents=True) + with (mo_dir / "index.mo").open("wb") as handle: + write_mo(handle, catalog) + + builder.build() + doctree = builder.get_doctree("index", post_transforms=True) + references = list(doctree.findall(nodes.reference)) + + external = [ + ref + for ref in references + if "sd-btn" in ref["classes"] + and ref.get("refuri", "").startswith("https://example.com") + ] + assert len(external) == 1, [r.pformat() for r in references] + assert external[0].astext() == "Klick mich jetzt" + + internal = [ + ref + for ref in references + if "sd-btn" in ref["classes"] and not ref.get("refuri", "").startswith("http") + ] + assert len(internal) == 1, [r.pformat() for r in references] + assert internal[0].astext() == "Zum Ziel" + + badges = [ + node + for node in doctree.findall(nodes.inline) + if "sd-badge" in node.get("classes", []) + ] + assert len(badges) == 1 + assert badges[0].astext() == "stable" diff --git a/tests/test_misc/test_button_i18n_gettext.pot b/tests/test_misc/test_button_i18n_gettext.pot new file mode 100644 index 0000000..3177fd1 --- /dev/null +++ b/tests/test_misc/test_button_i18n_gettext.pot @@ -0,0 +1,15 @@ +#: ../../index.rst:2 +msgid "Heading" +msgstr "" + +#: ../../index.rst:7 +msgid "Target Section" +msgstr "" + +#: ../../index.rst:19 +msgid "Click me now" +msgstr "" + +#: ../../index.rst:18 +msgid "See the :bdg-primary:`stable` badge." +msgstr "" diff --git a/tests/test_snippets.py b/tests/test_snippets.py index fde2b4f..884264c 100644 --- a/tests/test_snippets.py +++ b/tests/test_snippets.py @@ -17,6 +17,12 @@ SNIPPETS_PATH = Path(__file__).parent.parent / "docs" / "snippets" SNIPPETS_GLOB_RST = list((SNIPPETS_PATH / "rst").glob("[!_]*")) SNIPPETS_GLOB_MYST = list((SNIPPETS_PATH / "myst").glob("[!_]*")) +EXPECTED_PATH = Path(__file__).parent / "test_snippets" +I18N_GLOB_MYST = [ + p + for p in SNIPPETS_GLOB_MYST + if (EXPECTED_PATH / f"snippet_i18n_{p.stem}.pot").exists() +] def write_assets(src_path: Path): @@ -205,3 +211,35 @@ def test_sd_custom_directives( extension=".xml", encoding="utf8", ) + + +@pytest.mark.parametrize("sphinx_builder", ["gettext"], indirect=True) +@pytest.mark.parametrize( + "path", + I18N_GLOB_MYST, + ids=[path.stem for path in I18N_GLOB_MYST], +) +@pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed") +def test_i18n_myst( + sphinx_builder: Callable[..., SphinxBuilder], + path: Path, + normalize_doctree_xml, + file_regression, +): + builder = sphinx_builder() + content = path.read_text(encoding="utf8") + builder.src_path.joinpath("index.md").write_text(content, encoding="utf8") + write_assets(builder.src_path) + builder.build() + + # strip metadata (that includes a constantly-varying timestamp) + out_path = builder.out_path / "index.pot" + out = out_path.read_text() + out = out[out.find("#: ../../index") :] + + file_regression.check( + out, + basename=f"snippet_i18n_{path.stem}", + extension=".pot", + encoding="utf8", + ) diff --git a/tests/test_snippets/snippet_i18n_button-link.pot b/tests/test_snippets/snippet_i18n_button-link.pot new file mode 100644 index 0000000..d33018d --- /dev/null +++ b/tests/test_snippets/snippet_i18n_button-link.pot @@ -0,0 +1,7 @@ +#: ../../index.md:4 +msgid "Button text" +msgstr "" + +#: ../../index.md:10 +msgid "Button text with options" +msgstr "" diff --git a/tests/test_snippets/snippet_post_button-link.xml b/tests/test_snippets/snippet_post_button-link.xml index b0e1a79..289ff1e 100644 --- a/tests/test_snippets/snippet_post_button-link.xml +++ b/tests/test_snippets/snippet_post_button-link.xml @@ -2,23 +2,27 @@
Heading - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> Button text - <paragraph> + <paragraph translatable="False"> + <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> + <inline> + Button text with options + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary sd-shadow-sm" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-outline-primary" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <inline classes="sd-d-grid"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-secondary" refuri="https://example.com"> <inline> diff --git a/tests/test_snippets/snippet_pre_button-link.xml b/tests/test_snippets/snippet_pre_button-link.xml index b0e1a79..289ff1e 100644 --- a/tests/test_snippets/snippet_pre_button-link.xml +++ b/tests/test_snippets/snippet_pre_button-link.xml @@ -2,23 +2,27 @@ <section ids="heading" names="heading"> <title> Heading - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> Button text - <paragraph> + <paragraph translatable="False"> + <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> + <inline> + Button text with options + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary sd-shadow-sm" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-outline-primary" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="False"> <inline classes="sd-d-grid"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-secondary" refuri="https://example.com"> <inline>