fix: ignore [TOC] marker during link validation (#686)

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-05-19 10:44:45 +02:00
parent 7a141c5678
commit 85c3b1e321
2 changed files with 106 additions and 3 deletions
@@ -449,6 +449,44 @@ class TestLinkReferences:
assert text(md, links[0].text) == b"id"
assert text(md, links[0].href) == b"id"
@pytest.mark.parametrize(
"md",
[
pytest.param(b"text [TOC]", id="inline"),
pytest.param(b"[TOC] text", id="inline-prefix"),
pytest.param(b"[TOC]\ntext", id="paragraph"),
pytest.param(b" [TOC]", id="code-block"),
],
)
def test_link_ref_toc_text(self, md: bytes) -> None:
refs = collect(md)
assert len(refs) == 1
link_refs = link_refs_only(refs)
assert len(link_refs) == 1
assert text(md, link_refs[0].text) == b"TOC"
assert text(md, link_refs[0].id) == b"TOC"
def test_link_ref_collapsed_toc_id(self) -> None:
md = b"[TOC][]"
refs = collect(md)
assert len(refs) == 1
link_refs = link_refs_only(refs)
assert len(link_refs) == 1
assert text(md, link_refs[0].text) == b"TOC"
assert text(md, link_refs[0].id) == b"TOC"
def test_link_ref_explicit_toc_id(self) -> None:
md = b"[TOC][id]"
refs = collect(md)
assert len(refs) == 1
link_refs = link_refs_only(refs)
assert len(link_refs) == 1
assert text(md, link_refs[0].text) == b"TOC"
assert text(md, link_refs[0].id) == b"id"
# --- negative cases ---
def test_no_link_ref_escaped_brackets(self) -> None:
@@ -476,6 +514,21 @@ class TestLinkReferences:
refs = collect(md)
assert len(refs) == 0
def test_no_link_ref_toc_marker(self) -> None:
md = b"[TOC]"
refs = collect(md)
assert len(refs) == 0
def test_no_link_ref_toc_marker_with_shift(self) -> None:
md = b"[TOC]"
refs = collect(md, shift=10)
assert len(refs) == 0
def test_no_link_ref_toc_marker_block(self) -> None:
md = b"before\n\n[TOC]\n\nafter"
refs = collect(md)
assert len(refs) == 0
# ---------------------------------------------------------------------------
@@ -599,9 +652,7 @@ class TestLinkDefinitions:
),
],
)
def test_link_def_angle_brackets_with_link_after(
self, md: bytes
) -> None:
def test_link_def_angle_brackets_with_link_after(self, md: bytes) -> None:
refs = collect(md)
assert len(refs) == 2
@@ -422,12 +422,17 @@ def _scan_link_or_link_ref(cursor: Cursor) -> Link | LinkReference | None:
)
# Consume link id
after_text = end
id, end = _scan_link_id(cursor, end)
# Ignore empty shortcut references like `[]` or `[][]`.
if id is None and text.start == text.end:
return None
# Ignore Python Markdown's table-of-contents marker.
if id is None and _is_toc_marker(cursor, text, after_text):
return None
# Advance cursor and return link reference
cursor.advance(end - start)
return LinkReference(
@@ -693,6 +698,32 @@ def _scan_link_id_identifier(
return None
def _is_toc_marker(cursor: Cursor, text: Span, end: int) -> bool:
"""Return whether a shortcut reference is a TOC marker block."""
start = text.start - cursor.shift - 1
if cursor.data[start + 1 : end - 1] != b"TOC":
return False
# Python Markdown treats the marker as a block, not inline text. A block can
# be indented up to three spaces before it becomes a code block.
if not cursor.at_line_start() or cursor.col > 3: # noqa: PLR2004
return False
# The marker must be on a line by itself
line = _find_line_start(cursor, start)
if not _is_previous_line_blank(cursor, line):
return False
# Skip whitespace after the marker and ensure there's nothing else
pos = _skip_whitespace(cursor, end)
if pos < cursor.end and cursor.data[pos] not in (_CR, _NL):
return False
# The next line must be blank or non-existent
pos = _skip_line(cursor, pos)
return pos >= cursor.end or _is_blank_line(cursor, pos)
# ---------------------------------------------------------------------------
@@ -1492,6 +1523,27 @@ def _skip_line(cursor: Cursor, pos: int) -> int:
return pos
def _is_blank_line(cursor: Cursor, pos: int) -> bool:
"""Return whether the line at the given position is blank."""
end = _find_line_end(cursor, pos)
return all(char in _WHITESPACE for char in cursor.data[pos:end])
def _is_previous_line_blank(cursor: Cursor, pos: int) -> bool:
"""Return whether the line before the given position is blank."""
if pos == 0:
return True
# Find the end of the previous line, skipping any trailing newlines
end = pos - 1
if end > 0 and cursor.data[end - 1] == _CR:
end -= 1
# Find the start of the previous line and check if it's blank
start = _find_line_start(cursor, end)
return all(char in _WHITESPACE for char in cursor.data[start:end])
def _find_bracket(cursor: Cursor, pos: int) -> int:
"""Find the next `]` or newline."""
while (