From 8e0a1fc76d42e9a44137b90d7a31e244323b632f Mon Sep 17 00:00:00 2001 From: Muneeb Sheikh Date: Sun, 11 Oct 2026 13:19:14 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Preserve=20explicit?= =?UTF-8?q?=20heading=20anchor=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ mdit_py_plugins/anchors/index.py | 14 +++++++++--- tests/test_anchors.py | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b78b3..2988bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## Unreleased + +- 🐛 FIX: Preserve explicit heading IDs in the anchors plugin and avoid generated ID collisions. + ## 0.7.0 - 2026-07-19 - ✨ NEW: Add section reference plugin (`section_ref`) (#144) diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py index 66bf0aa..3d0b621 100644 --- a/mdit_py_plugins/anchors/index.py +++ b/mdit_py_plugins/anchors/index.py @@ -29,6 +29,8 @@ def anchors_plugin(

Title String

+ Explicit heading IDs are preserved. Generated IDs avoid collisions with them. + :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 +68,11 @@ def _make_anchors_func( permalinkSpace: bool, ) -> Callable[[StateCore], None]: def _anchor_func(state: StateCore) -> None: - slugs: set[str] = set() + slugs: set[str] = { + str(token.attrGet("id")) + for token in state.tokens + if token.type == "heading_open" and token.attrGet("id") is not None + } for idx, token in enumerate(state.tokens): if token.type != "heading_open": continue @@ -80,8 +86,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 slug is None: + slug = unique_slug(slug_func(title), slugs) + token.attrSet("id", slug) if permalink: link_open = Token( diff --git a/tests/test_anchors.py b/tests/test_anchors.py index 0515b92..daaac14 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -5,6 +5,7 @@ import pytest from mdit_py_plugins.anchors import anchors_plugin +from mdit_py_plugins.attrs import attrs_block_plugin FIXTURE_PATH = Path(__file__).parent @@ -21,3 +22,41 @@ 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' From 900f1c2c72b13390648964ad6fc7b2c963731941 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 20:25:30 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20drop=20changelog?= =?UTF-8?q?=20entry=20(written=20at=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2988bc4..a1b78b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Change Log -## Unreleased - -- 🐛 FIX: Preserve explicit heading IDs in the anchors plugin and avoid generated ID collisions. - ## 0.7.0 - 2026-07-19 - ✨ NEW: Add section reference plugin (`section_ref`) (#144) From eecfde91f163bdd3d94cafc7ad08968288fb39a8 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 20:26:17 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20anchors:=20treat=20an?= =?UTF-8?q?=20empty=20explicit=20id=20as=20absent,=20pin=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty `{id=""}` is invalid HTML and gave a `href="#"` permalink; it is now neither reserved nor kept, and the heading gets a generated id. Tests pin the out-of-level reservation, duplicate explicit ids being kept (no error), the empty id, and plugin load-order independence. --- mdit_py_plugins/anchors/index.py | 13 +++++--- tests/test_anchors.py | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py index 3d0b621..2b92eb0 100644 --- a/mdit_py_plugins/anchors/index.py +++ b/mdit_py_plugins/anchors/index.py @@ -29,7 +29,9 @@ def anchors_plugin(

Title String

- Explicit heading IDs are preserved. Generated IDs avoid collisions with them. + 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 @@ -68,10 +70,13 @@ def _make_anchors_func( permalinkSpace: bool, ) -> Callable[[StateCore], None]: def _anchor_func(state: StateCore) -> None: + # 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(token.attrGet("id")) + str(heading_id) for token in state.tokens - if token.type == "heading_open" and token.attrGet("id") is not None + if token.type == "heading_open" and (heading_id := token.attrGet("id")) } for idx, token in enumerate(state.tokens): if token.type != "heading_open": @@ -87,7 +92,7 @@ def _anchor_func(state: StateCore) -> None: if child.type in ["text", "code_inline"] ) slug = token.attrGet("id") - if slug is None: + if not slug: slug = unique_slug(slug_func(title), slugs) token.attrSet("id", slug) diff --git a/tests/test_anchors.py b/tests/test_anchors.py index daaac14..8ce122b 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -60,3 +60,55 @@ def slug_func(title: str) -> str: .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' From 2633608cfa099969ff8f4e6d11174dbad72f30c2 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 20:27:15 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20anchors:=20do=20n?= =?UTF-8?q?ot=20strip=20the=20title=20before=20slugifying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default slug function no longer strips leading/trailing whitespace, so it is now identical to MyST-Parser's `github_slugify` (which keeps it deliberately, see its v0.18.1 changelog). This changes generated ids where the title text starts or ends with a space next to an image, raw HTML or an entity, e.g. `# Foo ![img](x.png)` gives `foo-` (was `foo`), and `# Foo` gives `-foo` (was `foo`). --- mdit_py_plugins/anchors/index.py | 2 +- tests/test_anchors.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py index 2b92eb0..7fca0d5 100644 --- a/mdit_py_plugins/anchors/index.py +++ b/mdit_py_plugins/anchors/index.py @@ -129,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 8ce122b..3e30ab9 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -5,6 +5,7 @@ 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 @@ -112,3 +113,36 @@ 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