Skip to content
Merged
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
21 changes: 17 additions & 4 deletions mdit_py_plugins/anchors/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def anchors_plugin(

<h1 id="title-string">Title String <a class="header-anchor" href="#title-string">¶</a></h1>

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.
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_anchors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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") == '<h1 id="custom">Heading</h1>\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") == '<h1 id="custom">Heading</h1>\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 <br>", "foo-", id="trailing-html"),
pytest.param("# &#32;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
Loading