From 22f4463dfbf539b4489246748213b599be87f4f3 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 19 May 2026 16:08:12 -0700 Subject: [PATCH 1/3] make button text translatable --- .gitignore | 1 + docs/snippets/myst/button-link.txt | 6 +++ sphinx_design/badges_buttons.py | 11 +++++- tests/conftest.py | 8 ++-- tests/test_snippets.py | 38 +++++++++++++++++++ .../snippet_i18n_button-link.pot | 7 ++++ .../snippet_post_button-link.xml | 16 ++++---- .../test_snippets/snippet_pre_button-link.xml | 16 ++++---- 8 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 tests/test_snippets/snippet_i18n_button-link.pot diff --git a/.gitignore b/.gitignore index 32bd99fe..68b9321c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ cover/ # Translations *.mo *.pot +!tests/test_snippets/*.pot # Django stuff: *.log diff --git a/docs/snippets/myst/button-link.txt b/docs/snippets/myst/button-link.txt index ed372cf2..056e6beb 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/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index 61b83a06..449ecf4e 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -178,8 +178,13 @@ 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 = nodes.inline("", "", translatable=True) content.extend(textnodes) + + # make link text translatable - + # target gettext to the content lines, not the outer directive + self.set_source_info(content) + content.line += self.content_offset else: content = nodes.inline(target, target) node.append(content) @@ -191,7 +196,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 cc112c75..fa318fb6 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() diff --git a/tests/test_snippets.py b/tests/test_snippets.py index fde2b4f0..884264c8 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 00000000..d33018d2 --- /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 b0e1a799..21fd8a21 100644 --- a/tests/test_snippets/snippet_post_button-link.xml +++ b/tests/test_snippets/snippet_post_button-link.xml @@ -2,23 +2,25 @@
Heading - <paragraph> + <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> - <inline> - Button text - <paragraph> + Button text + <paragraph translatable="0"> + <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> + Button text with options + <paragraph translatable="0"> <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="0"> <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="0"> <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 b0e1a799..21fd8a21 100644 --- a/tests/test_snippets/snippet_pre_button-link.xml +++ b/tests/test_snippets/snippet_pre_button-link.xml @@ -2,23 +2,25 @@ <section ids="heading" names="heading"> <title> Heading - <paragraph> + <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph> + <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> - <inline> - Button text - <paragraph> + Button text + <paragraph translatable="0"> + <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> + Button text with options + <paragraph translatable="0"> <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="0"> <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="0"> <inline classes="sd-d-grid"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-secondary" refuri="https://example.com"> <inline> From 65281b4cd937f8636e78777b4fd937aa2d5b1c0c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 12 Jul 2026 13:11:28 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20keep=20buttons=20inta?= =?UTF-8?q?ct=20under=20gettext=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the approach from #264: gettext targets only the button text via a translatable inline, so translated button-link/button-ref keep their classes and links (closes #96, #44, #263). Additional hardening and coverage on top of #264: - wrap the translatable placeholder inline in a plain inline, so the reference always retains an element child after sphinx unwraps the placeholder (an unresolved button-ref otherwise crashed the build during reference resolution in translated projects) - this also restores the pre-existing doctree shape (inline wrapper), keeping HTML output identical to previous releases - add gettext extraction regression (pot) and a translated-build round-trip test proving classes/links/refs survive with translated text, for both button types, plus a badge control - align the rst button-link snippet with the myst one (both feed the same regression files) - note: button-ref text is still missing from pot extraction because std-domain xref resolution flattens explicit-title content before the gettext builder extracts; translation itself works (documented in the test, follow-up tracked on the roadmap) --- .gitignore | 1 + CHANGELOG.md | 7 ++ docs/snippets/rst/button-link.txt | 5 + sphinx_design/badges_buttons.py | 13 ++- tests/test_misc.py | 97 +++++++++++++++++++ tests/test_misc/test_button_i18n_gettext.pot | 15 +++ .../snippet_post_button-link.xml | 6 +- .../test_snippets/snippet_pre_button-link.xml | 6 +- 8 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 tests/test_misc/test_button_i18n_gettext.pot diff --git a/.gitignore b/.gitignore index 68b9321c..3bfa0ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ cover/ *.mo *.pot !tests/test_snippets/*.pot +!tests/test_misc/*.pot # Django stuff: *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d908531..9891a798 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/snippets/rst/button-link.txt b/docs/snippets/rst/button-link.txt index d6817f3b..9a2327a2 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 449ecf4e..1dac3327 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -178,13 +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("", "", translatable=True) - content.extend(textnodes) - # make link text translatable - # target gettext to the content lines, not the outer directive - self.set_source_info(content) - content.line += self.content_offset + 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) diff --git a/tests/test_misc.py b/tests/test_misc.py index 77fceea0..c128489f 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,97 @@ 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 00000000..3177fd18 --- /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/snippet_post_button-link.xml b/tests/test_snippets/snippet_post_button-link.xml index 21fd8a21..f027aa1f 100644 --- a/tests/test_snippets/snippet_post_button-link.xml +++ b/tests/test_snippets/snippet_post_button-link.xml @@ -8,10 +8,12 @@ https://example.com <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> - Button text + <inline> + Button text <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> - Button text with options + <inline> + Button text with options <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary sd-shadow-sm" 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 21fd8a21..f027aa1f 100644 --- a/tests/test_snippets/snippet_pre_button-link.xml +++ b/tests/test_snippets/snippet_pre_button-link.xml @@ -8,10 +8,12 @@ https://example.com <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> - Button text + <inline> + Button text <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary" refuri="https://example.com"> - Button text with options + <inline> + Button text with options <paragraph translatable="0"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap sd-btn-primary sd-shadow-sm" refuri="https://example.com"> <inline> From d3bbb47bf655168b0e35cdf09ba5a27d9557acec Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 12 Jul 2026 13:24:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20normalize=20translat?= =?UTF-8?q?able=20attrs,=20add=20issue=20extlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add 'translatable'/'translated' to the boolean-attribute normalization list, so regressions are stable across docutils 0.22 (1/0) and earlier (True/False) serializations - define an 'issue' extlink for changelog references --- docs/conf.py | 4 ++++ tests/conftest.py | 2 ++ tests/test_misc.py | 3 +++ tests/test_snippets/snippet_post_button-link.xml | 12 ++++++------ tests/test_snippets/snippet_pre_button-link.xml | 12 ++++++------ 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4906999d..4b25ff4d 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/tests/conftest.py b/tests/conftest.py index fa318fb6..28324c27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,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 c128489f..fa550ea5 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -345,8 +345,11 @@ 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: diff --git a/tests/test_snippets/snippet_post_button-link.xml b/tests/test_snippets/snippet_post_button-link.xml index f027aa1f..289ff1e7 100644 --- a/tests/test_snippets/snippet_post_button-link.xml +++ b/tests/test_snippets/snippet_post_button-link.xml @@ -2,27 +2,27 @@ <section ids="heading" names="heading"> <title> Heading - <paragraph translatable="0"> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph translatable="0"> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> Button text - <paragraph translatable="0"> + <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="0"> + <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 translatable="0"> + <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 translatable="0"> + <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 f027aa1f..289ff1e7 100644 --- a/tests/test_snippets/snippet_pre_button-link.xml +++ b/tests/test_snippets/snippet_pre_button-link.xml @@ -2,27 +2,27 @@ <section ids="heading" names="heading"> <title> Heading - <paragraph translatable="0"> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> https://example.com - <paragraph translatable="0"> + <paragraph translatable="False"> <reference classes="sd-sphinx-override sd-btn sd-text-wrap" refuri="https://example.com"> <inline> Button text - <paragraph translatable="0"> + <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="0"> + <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 translatable="0"> + <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 translatable="0"> + <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>