From 6950d562d0fe1a071528fa22747d8a0ad8701783 Mon Sep 17 00:00:00 2001 From: Rahul Ranjan Date: Fri, 13 Nov 2026 07:29:59 +0000 Subject: [PATCH 1/2] fix: give a heading with no slug-able characters no anchor at all A generated empty slug produced an invalid empty id="" attribute (and a permalink pointing at "#"), while an empty *explicit* ID is already treated as absent. Extend the same doctrine to generated slugs: no id, no permalink, and no "" reservation in the slug set. Fixes #175 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- mdit_py_plugins/anchors/index.py | 11 ++++++++++- tests/test_anchors.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/mdit_py_plugins/anchors/index.py b/mdit_py_plugins/anchors/index.py index 7fca0d5..681250d 100644 --- a/mdit_py_plugins/anchors/index.py +++ b/mdit_py_plugins/anchors/index.py @@ -32,6 +32,8 @@ def anchors_plugin( 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. + A generated empty slug (a title with no slug-able characters, e.g. ``## ???``) + likewise counts as no ID. :param min_level: minimum header level to apply anchors :param max_level: maximum header level to apply anchors @@ -93,7 +95,14 @@ def _anchor_func(state: StateCore) -> None: ) slug = token.attrGet("id") if not slug: - slug = unique_slug(slug_func(title), slugs) + generated = slug_func(title) + if not generated: + # A title with no slug-able characters slugifies to "". + # An empty id="" is invalid HTML, so treat a generated empty + # slug like an empty explicit ID: the heading gets no anchor + # (and no permalink, which would point at "#"). + continue + slug = unique_slug(generated, slugs) token.attrSet("id", slug) if permalink: diff --git a/tests/test_anchors.py b/tests/test_anchors.py index 3e30ab9..9ae3f2f 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -115,6 +115,36 @@ def test_explicit_heading_id_plugin_order() -> None: assert md.render("{#custom}\n# Heading") == '

Heading

\n' +@pytest.mark.parametrize( + "source, expected_ids", + [ + pytest.param("## ???", [None], id="no-slugable-chars"), + pytest.param("## ???\n\n## ???", [None, None], id="no-slugable-chars-twice"), + pytest.param( + "## ???\n\n## Real", [None, "real"], id="following-heading-unaffected" + ), + ], +) +def test_empty_generated_slug_gets_no_id(source: str, expected_ids: list) -> None: + """A generated empty slug is treated like an empty explicit ID: no id at all. + + A heading whose title has no slug-able characters (e.g. ``## ???``) + would otherwise get an invalid empty ``id=""`` attribute, and with + ``permalink=True`` a link pointing at ``#``. + """ + md = MarkdownIt("commonmark").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 + expected_anchors = sum(1 for i in expected_ids if i is not None) + assert rendered.count('class="header-anchor"') == expected_anchors + + + @pytest.mark.parametrize( "source, expected_id", [ From 75553d65adfba77e93c73a8445e214881f4eae5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 07:31:15 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_anchors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_anchors.py b/tests/test_anchors.py index 9ae3f2f..aaab38d 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -144,7 +144,6 @@ def test_empty_generated_slug_gets_no_id(source: str, expected_ids: list) -> Non assert rendered.count('class="header-anchor"') == expected_anchors - @pytest.mark.parametrize( "source, expected_id", [