Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion mdit_py_plugins/anchors/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions tests/test_anchors.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,35 @@ def test_explicit_heading_id_plugin_order() -> None:
assert md.render("{#custom}\n# Heading") == '<h1 id="custom">Heading</h1>\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",
[
Expand Down