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 68cfae72..fd3b09db 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, @@ -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] @@ -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 @@ -997,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"] = "noreferer 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_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 59b780a0..32dfc0a6 100644 --- a/tests/test_renderers/fixtures/attributes.md +++ b/tests/test_renderers/fixtures/attributes.md @@ -77,3 +77,27 @@ list-style b . + +heading with id +. +{#hid} +# First +. + +
+ + 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_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index cd0b5963..7a04c562 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -367,6 +367,46 @@ a <string>: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 } +. +<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 'height' attribute value: '2x' [myst.attribute] + <system_message level="2" line="1" source="<string>" type="WARNING"> + <paragraph> + Invalid 'align' attribute value: 'other' [myst.attribute] + +<string>:1: (WARNING/2) Invalid 'width' attribute value: '1x' [myst.attribute] +<string>:1: (WARNING/2) Invalid 'height' attribute value: '2x' [myst.attribute] +<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} @@ -529,6 +569,31 @@ 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 . + +[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 +. diff --git a/tests/test_renderers/test_parse_linenos.py b/tests/test_renderers/test_parse_linenos.py new file mode 100644 index 00000000..39ea3628 --- /dev/null +++ b/tests/test_renderers/test_parse_linenos.py @@ -0,0 +1,24 @@ +"""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 + +pytest.importorskip("sphinx") + +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) 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 )