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 ", "foo-", id="trailing-image"),
+ pytest.param("#  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