diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py index 66bf0aa..7fca0d5 100644 --- a/mdit_py_plugins/anchors/index.py +++ b/mdit_py_plugins/anchors/index.py @@ -29,6 +29,10 @@ def anchors_plugin(

Title String ΒΆ

+ Explicit heading IDs (e.g. ``{#id}`` from ``attrs_block_plugin``) are preserved, + at any level, and generated IDs avoid collisions with them. + Explicit IDs are not de-duplicated, and an empty explicit ID counts as no ID. + :param min_level: minimum header level to apply anchors :param max_level: maximum header level to apply anchors :param slug_func: function to convert title text to id slug. @@ -66,7 +70,14 @@ def _make_anchors_func( permalinkSpace: bool, ) -> Callable[[StateCore], None]: def _anchor_func(state: StateCore) -> None: - slugs: set[str] = set() + # reserve every explicit heading id (at any level) up front, + # so a generated slug never collides with one, whatever the order; + # an empty id is treated as absent + slugs: set[str] = { + str(heading_id) + for token in state.tokens + if token.type == "heading_open" and (heading_id := token.attrGet("id")) + } for idx, token in enumerate(state.tokens): if token.type != "heading_open": continue @@ -80,8 +91,10 @@ def _anchor_func(state: StateCore) -> None: for child in inline_token.children if child.type in ["text", "code_inline"] ) - slug = unique_slug(slug_func(title), slugs) - token.attrSet("id", slug) + slug = token.attrGet("id") + if not slug: + slug = unique_slug(slug_func(title), slugs) + token.attrSet("id", slug) if permalink: link_open = Token( @@ -116,7 +129,7 @@ def _anchor_func(state: StateCore) -> None: def slugify(title: str) -> str: - return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.strip().lower().replace(" ", "-")) + return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.lower().replace(" ", "-")) def unique_slug(slug: str, slugs: set[str]) -> str: diff --git a/tests/test_anchors.py b/tests/test_anchors.py index 0515b92..3e30ab9 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -5,6 +5,8 @@ import pytest from mdit_py_plugins.anchors import anchors_plugin +from mdit_py_plugins.anchors.index import slugify +from mdit_py_plugins.attrs import attrs_block_plugin FIXTURE_PATH = Path(__file__).parent @@ -21,3 +23,126 @@ def test_fixtures(line, title, input, expected): ) text = md.render(input) assert text.rstrip() == expected.rstrip() + + +@pytest.mark.parametrize( + "source, expected_ids", + [ + ("{#custom}\n# Heading", ["custom"]), + ("{#custom}\n# Heading\n\n# Custom", ["custom", "custom-1"]), + ("# Custom\n\n{#custom}\n# Heading", ["custom-1", "custom"]), + ], +) +def test_preserve_explicit_heading_ids(source: str, expected_ids: list[str]) -> None: + """Preserve explicit anchors and avoid collisions with generated anchors.""" + md = ( + MarkdownIt("commonmark") + .use(attrs_block_plugin) + .use(anchors_plugin, permalink=True) + ) + tokens = md.parse(source) + assert [ + token.attrGet("id") for token in tokens if token.type == "heading_open" + ] == expected_ids + rendered = md.renderer.render(tokens, md.options, {}) + for heading_id in expected_ids: + assert f'href="#{heading_id}"' in rendered + + +def test_explicit_heading_id_does_not_call_slug_function() -> None: + """An explicit heading ID does not need to be generated from its title.""" + + def slug_func(title: str) -> str: + raise AssertionError("Explicit IDs should not be slugified") + + md = ( + MarkdownIt("commonmark") + .use(attrs_block_plugin) + .use(anchors_plugin, slug_func=slug_func) + ) + assert md.render("{#custom}\n# Heading") == '

Heading

\n' + + +@pytest.mark.parametrize( + "source, expected_ids", + [ + pytest.param( + "{#foo}\n### Bar\n\n# Foo", + ["foo", "foo-1"], + id="out-of-level-id-reserved", + ), + pytest.param( + "{#a}\n# X\n\n{#a}\n# Y", + ["a", "a"], + id="duplicate-explicit-ids-kept", + ), + pytest.param( + '{id=""}\n# Title', + ["title"], + id="empty-id-is-generated", + ), + pytest.param( + '{id=""}\n# Title\n\n# Title', + ["title", "title-1"], + id="empty-id-not-reserved", + ), + ], +) +def test_explicit_heading_id_edge_cases(source: str, expected_ids: list[str]) -> None: + """Pin the edge cases of explicit heading IDs. + + An explicit ID outside the selected levels is still reserved, + duplicate explicit IDs are kept as written (no error is raised), + and an empty ID (invalid in HTML) is treated as absent. + """ + md = ( + MarkdownIt("commonmark") + .use(attrs_block_plugin) + .use(anchors_plugin, permalink=True) + ) + tokens = md.parse(source) + assert [ + token.attrGet("id") for token in tokens if token.type == "heading_open" + ] == expected_ids + rendered = md.renderer.render(tokens, md.options, {}) + assert 'id=""' not in rendered + assert 'href="#"' not in rendered + + +def test_explicit_heading_id_plugin_order() -> None: + """The explicit ID is kept whichever plugin is loaded first.""" + md = MarkdownIt("commonmark").use(anchors_plugin).use(attrs_block_plugin) + assert md.render("{#custom}\n# Heading") == '

Heading

\n' + + +@pytest.mark.parametrize( + "source, expected_id", + [ + pytest.param("# Foo", "foo", id="plain"), + pytest.param("# Foo ![img](x.png)", "foo-", id="trailing-image"), + pytest.param("# ![img](x.png) Foo", "-foo", id="leading-image"), + pytest.param("# Foo
", "foo-", id="trailing-html"), + pytest.param("# Foo", "-foo", id="leading-entity-space"), + ], +) +def test_slug_keeps_edge_whitespace(source: str, expected_id: str) -> None: + """Whitespace at the edges of the title text is not stripped before slugifying. + + The title text is joined from the ``text`` and ``code_inline`` children only, + so a space next to an image, raw HTML or an entity survives as a hyphen. + This matches MyST-Parser's ``github_slugify`` (and so its heading anchors). + """ + md = MarkdownIt("commonmark").use(anchors_plugin) + tokens = md.parse(source) + assert [ + token.attrGet("id") for token in tokens if token.type == "heading_open" + ] == [expected_id] + + +@pytest.mark.parametrize( + "title, expected", + [(" a b ", "-a-b-"), ("a b c", "a-b--c"), ("Foo", "foo")], +) +def test_slugify(title: str, expected: str) -> None: + """The default slug function lowercases, hyphenates spaces, and does not strip.""" + assert slugify(title) == expected