From ee15e3c77b1471054def3b87b811875c7b2d7dc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:15:08 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20warn=20instead=20of?= =?UTF-8?q?=20KeyError=20on=20invalid=20aliased=20image=20attributes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `copy_attributes` rebinds `key` to the aliased (node) name before the value is validated, but then interpolated `token.attrs[key]` into the warning message. For a short form such as `![i](f.png){w=1x}` that key does not exist on the token, so an invalid value raised `KeyError: 'width'` and aborted the build instead of warning. Interpolate the value already in hand; `token.attrs[key]` was only ever a re-fetch of it. The message text is unchanged for the long forms, and an alias now produces the same message as its long form. --- myst_parser/mdit_to_docutils/base.py | 2 +- tests/test_renderers/fixtures/myst-config.txt | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 68cfae72..51d629be 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -430,7 +430,7 @@ def copy_attributes( value = converters[key](str(value)) except ValueError: self.create_warning( - f"Invalid {key!r} attribute value: {token.attrs[key]!r}", + f"Invalid {key!r} attribute value: {value!r}", MystWarnings.INVALID_ATTRIBUTE, line=token_line(token, default=0), append_to=node, diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index cd0b5963..8d60c460 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -367,6 +367,28 @@ a :1: (WARNING/2) Invalid 'align' attribute value: 'other' [myst.attribute] . +[attrs_inline_image_warnings_aliases] --myst-enable-extensions=attrs_inline +. +![a](b){w=1x h=2x a=other } +. + + + a + + + Invalid 'width' attribute value: '1x' [myst.attribute] + + + Invalid 'height' attribute value: '2x' [myst.attribute] + + + Invalid 'align' attribute value: 'other' [myst.attribute] + +:1: (WARNING/2) Invalid 'width' attribute value: '1x' [myst.attribute] +:1: (WARNING/2) Invalid 'height' attribute value: '2x' [myst.attribute] +:1: (WARNING/2) Invalid 'align' attribute value: 'other' [myst.attribute] +. + [attrs_block] --myst-enable-extensions=attrs_block . {#myid1 .class1 .class2} From c26e05183d8896b8377afbe6c0c180f07645d60d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:19:34 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20duplicate=20{#id}=20o?= =?UTF-8?q?n=20headings=20no=20longer=20aborts=20the=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate_heading_target` appended the title-derived name to the node and called `note_implicit_target`, which re-registers every name already on the node -- including the explicit `{#id}` name that `copy_attributes` had just added and registered. The node therefore collided with itself: docutils demoted the explicit name into `dupnames` while its name map still pointed at the node, so a second heading carrying the same `{#id}` raised `ValueError: list.remove(x): x not in list` and no page was written. The same happened for headings rendered as rubrics inside a directive. Register only the newly derived implicit name, and restore the explicit names afterwards. Duplicates now produce docutils' own `Duplicate explicit target name` warning, exactly as duplicate ids on paragraphs already did, and a single `{#id}` heading keeps that name instead of losing it to `dupnames`. docutils 0.23 guards against the self-collision on its own, so this only affects earlier versions -- which is every version a released Sphinx can be paired with. --- myst_parser/mdit_to_docutils/base.py | 16 +++++++++-- tests/test_renderers/fixtures/attributes.md | 27 +++++++++++++++++++ .../attrs_block_duplicate_ids/conf.py | 3 +++ .../attrs_block_duplicate_ids/index.md | 21 +++++++++++++++ tests/test_sphinx/test_sphinx_builds.py | 27 +++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/conf.py create mode 100644 tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/index.md diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 51d629be..5dfb218a 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -851,8 +851,20 @@ def generate_heading_target( # TODO this is purely to mimic docutils, but maybe we don't need it? # (since we have the slugify logic below) name = nodes.fully_normalize_name(implicit_text) - node["names"].append(name) - self.document.note_implicit_target(node, node) + # Register only this new, implicit name. + # ``note_implicit_target`` re-registers *every* name already on the node, + # as an implicit one -- including an explicit ``{#id}`` name that + # ``copy_attributes`` has already added and registered. The node then + # collides with itself: docutils demotes the explicit name into + # ``dupnames`` while its name map still points here, so a later, + # genuine duplicate of that name raises + # ``ValueError: list.remove(x): x not in list``. + explicit_names = node["names"] + node["names"] = [name] + try: + self.document.note_implicit_target(node, node) + finally: + node["names"] = explicit_names + node["names"] if level > self.md_config.heading_anchors: return diff --git a/tests/test_renderers/fixtures/attributes.md b/tests/test_renderers/fixtures/attributes.md index 59b780a0..1c7a5795 100644 --- a/tests/test_renderers/fixtures/attributes.md +++ b/tests/test_renderers/fixtures/attributes.md @@ -77,3 +77,30 @@ list-style b . + +heading with id +. +{#hid} +## First +. + + + + Document headings start at H2, not H1 [myst.header] +
+ + First +. + +rubric with id +. +```{note} +{#rid} +## First +``` +. +<document source="<src>/index.md"> + <note> + <rubric ids="rid" level="2" names="rid first"> + First +. diff --git a/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/conf.py b/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/conf.py new file mode 100644 index 00000000..720371a2 --- /dev/null +++ b/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/conf.py @@ -0,0 +1,3 @@ +extensions = ["myst_parser"] +exclude_patterns = ["_build"] +myst_enable_extensions = ["attrs_block"] diff --git a/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/index.md b/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/index.md new file mode 100644 index 00000000..27c5a7df --- /dev/null +++ b/tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/index.md @@ -0,0 +1,21 @@ +# Page + +{#sec} +## First + +{#sec} +## Second + +{#para} +Paragraph one. + +{#para} +Paragraph two. + +```{note} +{#rub} +### Third + +{#rub} +### Fourth +``` diff --git a/tests/test_sphinx/test_sphinx_builds.py b/tests/test_sphinx/test_sphinx_builds.py index 333b24dc..189bac32 100644 --- a/tests/test_sphinx/test_sphinx_builds.py +++ b/tests/test_sphinx/test_sphinx_builds.py @@ -472,6 +472,33 @@ def test_substitutions_missing( ) +@pytest.mark.sphinx( + buildername="html", + srcdir=os.path.join(SOURCE_DIR, "attrs_block_duplicate_ids"), + freshenv=True, +) +def test_attrs_block_duplicate_ids( + app, + status, + warning, +): + """Test that a duplicated ``{#id}`` warns, rather than aborting the build.""" + app.build() + assert "build succeeded" in status.getvalue() # Build succeeded + warnings = strip_colors(warning.getvalue()).strip().splitlines() + assert len(warnings) == 3 + assert warnings[0].endswith( + 'index.md:7: WARNING: Duplicate explicit target name: "sec". [docutils]' + ) + assert warnings[1].endswith( + 'index.md:: WARNING: Duplicate explicit target name: "para". [docutils]' + ) + assert warnings[2].endswith( + 'index.md:20: WARNING: Duplicate explicit target name: "rub". [docutils]' + ) + assert Path(app.outdir, "index.html").exists() + + @pytest.mark.sphinx( buildername="gettext", srcdir=os.path.join(SOURCE_DIR, "gettext"), freshenv=True ) From 214353faa89f282b72d4721fb9a4e8d3f2419c64 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 9 Sep 2026 12:20:40 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20spell=20rel=3D"norefe?= =?UTF-8?q?rrer"=20for=20myst=5Flinks=5Fexternal=5Fnew=5Ftab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `myst_links_external_new_tab` emitted `rel="noreferer noopener"`, with one `r`. No browser recognises that token, so the referrer was sent anyway and the option only ever delivered half of what it promised. Spell it `noreferrer`. The single fixture that pinned the misspelling is updated with it. --- myst_parser/mdit_to_docutils/base.py | 2 +- tests/test_renderers/fixtures/myst-config.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 5dfb218a..d156cda4 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -1010,7 +1010,7 @@ def render_link_url( attribute_keys = ["class", "id", "reftitle", "target", "rel"] if self.md_config.links_external_new_tab: token.attrs["target"] = "_blank" - token.attrs["rel"] = "noreferer noopener" + token.attrs["rel"] = "noreferrer noopener" self.copy_attributes( token, ref_node, attribute_keys, aliases={"title": "reftitle"} ) diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index 8d60c460..e783c223 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -551,6 +551,6 @@ content . <document source="<string>"> <paragraph> - <reference refuri="https://example.com" rel="noreferer noopener" target="_blank"> + <reference refuri="https://example.com" rel="noreferrer noopener" target="_blank"> text . From b060445143f13b3f8dbf880fcc55f9006125ae98 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 9 Sep 2026 12:21:33 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20myst=5Flinks=5Fextern?= =?UTF-8?q?al=5Fnew=5Ftab=20keeps=20authored=20target=20and=20rel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_link_url` wrote the config-driven `target` and `rel` onto the token before `copy_attributes` read the attribute group, so an authored `{target=_self rel=nofollow}` was silently replaced by `target="_blank" rel="noreferrer noopener"`. Apply the config values only for keys the author did not supply. The two keys are independent, so authoring just one of them leaves the other on its configured value. This is non-breaking for anyone not authoring `target` or `rel` on an external link: with no attribute group the rendering is unchanged. The option's help text, which is the whole of its user-facing documentation, now says which attributes it sets and that authored values win. --- myst_parser/config/main.py | 4 ++- myst_parser/mdit_to_docutils/base.py | 4 +-- tests/test_renderers/fixtures/myst-config.txt | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index bd19dc7a..8331481f 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -242,7 +242,9 @@ def __repr__(self) -> str: default=False, metadata={ "validator": instance_of(bool), - "help": "Open all external links in a new tab", + "help": "Open all external links in a new tab " + '(sets target="_blank" and rel="noreferrer noopener", ' + "unless the link already sets them)", }, ) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index d156cda4..ffac344d 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -1009,8 +1009,8 @@ def render_link_url( self.add_line_and_source_path(ref_node, token) attribute_keys = ["class", "id", "reftitle", "target", "rel"] if self.md_config.links_external_new_tab: - token.attrs["target"] = "_blank" - token.attrs["rel"] = "noreferrer noopener" + token.attrs.setdefault("target", "_blank") + token.attrs.setdefault("rel", "noreferrer noopener") self.copy_attributes( token, ref_node, attribute_keys, aliases={"title": "reftitle"} ) diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index e783c223..7efb1588 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -554,3 +554,28 @@ content <reference refuri="https://example.com" rel="noreferrer noopener" target="_blank"> text . + +[links-external-new-tab-authored] --myst-links-external-new-tab="true" --myst-enable-extensions=attrs_inline +. +[a](https://example.com){target=_self rel=nofollow} + +[a](https://example.com){target=_self} + +[a](https://example.com){rel=nofollow} + +<https://example.com>{rel=nofollow} +. +<document source="<string>"> + <paragraph> + <reference refuri="https://example.com" rel="nofollow" target="_self"> + a + <paragraph> + <reference refuri="https://example.com" rel="noreferrer noopener" target="_self"> + a + <paragraph> + <reference refuri="https://example.com" rel="nofollow" target="_blank"> + a + <paragraph> + <reference refuri="https://example.com" rel="nofollow" target="_blank"> + https://example.com +. From a8e6feee3221a07149afce96669ee2b04300adca Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 9 Sep 2026 12:22:38 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20balance=20the=20paren?= =?UTF-8?q?thesis=20in=20the=20emphasize=5Flines=20range=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An out-of-range `emphasize-lines` reported `emphasize_lines: out of range(1-3` -- the closing parenthesis was missing from the format string. Sphinx's own equivalent message, `line number spec is out of range(1-%d): %r`, is unbalanced too; this makes MyST's copy read correctly rather than matching it. --- myst_parser/mdit_to_docutils/base.py | 2 +- tests/test_renderers/test_parse_linenos.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 tests/test_renderers/test_parse_linenos.py diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index ffac344d..fd3b09db 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -659,7 +659,7 @@ def _parse_linenos(emphasize_lines: str, num_lines: int) -> list[int]: hl_lines = parselinenos(emphasize_lines, num_lines) if any(i >= num_lines for i in hl_lines): - raise ValueError(f"out of range(1-{num_lines}") + raise ValueError(f"out of range(1-{num_lines})") return [x + 1 for x in hl_lines if x < num_lines] diff --git a/tests/test_renderers/test_parse_linenos.py b/tests/test_renderers/test_parse_linenos.py new file mode 100644 index 00000000..15973729 --- /dev/null +++ b/tests/test_renderers/test_parse_linenos.py @@ -0,0 +1,22 @@ +"""Test ``DocutilsRenderer._parse_linenos``. + +This is the helper behind the ``emphasize-lines`` attribute, +and is only reached on the sphinx code path. +""" + +import re + +import pytest + +from myst_parser.mdit_to_docutils.base import DocutilsRenderer + + +def test_parse_linenos(): + """A line within the block is returned, 1-based.""" + assert DocutilsRenderer._parse_linenos("2", 3) == [2] + + +def test_parse_linenos_out_of_range(): + """A line past the end of the block reports the allowed range.""" + with pytest.raises(ValueError, match=re.escape("out of range(1-3)")): + DocutilsRenderer._parse_linenos("5", 3) From 45664854613e366c15585b539944da056e17ee03 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 9 Sep 2026 13:13:10 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20pin=20the=20new-tab?= =?UTF-8?q?=20help=20text,=20alias=20warnings,=20heading=20id=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the preceding five commits, tests only: - `test_help_text` now asserts the `myst_links_external_new_tab` help string mentions `rel="noreferrer noopener"`, so reverting it is no longer invisible to the suite. - A `myst-config.txt` row pins that when an alias and its long form are both given (`{w=1x width=2x}`), each warning reports its own value; before the alias fix the alias's warning reported the long form's value. - The `heading with id` doctree row uses an H1 so it no longer carries the unrelated "headings start at H2" message; the load-bearing section line is unchanged. - `test_parse_linenos.py` skips at module level when Sphinx is missing, since the helper it tests imports `sphinx.util` at call time. --- tests/test_docutils.py | 2 ++ tests/test_renderers/fixtures/attributes.md | 5 +---- tests/test_renderers/fixtures/myst-config.txt | 18 ++++++++++++++++++ tests/test_renderers/test_parse_linenos.py | 2 ++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 890badbc..66419012 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -113,6 +113,8 @@ def test_help_text(): assert not exc.code assert "MyST options" in stream.getvalue() + # the option's help text is its only user-facing documentation + assert 'rel="noreferrer noopener"' in " ".join(stream.getvalue().split()) def test_include_from_rst(tmp_path): diff --git a/tests/test_renderers/fixtures/attributes.md b/tests/test_renderers/fixtures/attributes.md index 1c7a5795..32dfc0a6 100644 --- a/tests/test_renderers/fixtures/attributes.md +++ b/tests/test_renderers/fixtures/attributes.md @@ -81,12 +81,9 @@ list-style heading with id . {#hid} -## First +# First . <document source="<src>/index.md"> - <system_message level="2" line="2" source="<src>/index.md" type="WARNING"> - <paragraph> - Document headings start at H2, not H1 [myst.header] <section ids="hid" names="hid first"> <title> First diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index 7efb1588..7a04c562 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -389,6 +389,24 @@ a <string>:1: (WARNING/2) Invalid 'align' attribute value: 'other' [myst.attribute] . +[attrs_inline_image_warnings_alias_and_long] --myst-enable-extensions=attrs_inline +. +![a](b){w=1x width=2x} +. +<document source="<string>"> + <paragraph> + <image alt="a" uri="b"> + <system_message level="2" line="1" source="<string>" type="WARNING"> + <paragraph> + Invalid 'width' attribute value: '1x' [myst.attribute] + <system_message level="2" line="1" source="<string>" type="WARNING"> + <paragraph> + Invalid 'width' attribute value: '2x' [myst.attribute] + +<string>:1: (WARNING/2) Invalid 'width' attribute value: '1x' [myst.attribute] +<string>:1: (WARNING/2) Invalid 'width' attribute value: '2x' [myst.attribute] +. + [attrs_block] --myst-enable-extensions=attrs_block . {#myid1 .class1 .class2} diff --git a/tests/test_renderers/test_parse_linenos.py b/tests/test_renderers/test_parse_linenos.py index 15973729..39ea3628 100644 --- a/tests/test_renderers/test_parse_linenos.py +++ b/tests/test_renderers/test_parse_linenos.py @@ -8,6 +8,8 @@ import pytest +pytest.importorskip("sphinx") + from myst_parser.mdit_to_docutils.base import DocutilsRenderer