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..aaab38d 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -115,6 +115,35 @@ 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", [