diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index 78b93f7..bc781a1 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -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 diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index a6e5c39..772e0a2 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -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 (