From 7be40c6ceb4407992a1d93cb6e30563b737e4724 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Tue, 19 May 2026 10:58:20 +0200 Subject: [PATCH] fix: ignore GitHub-style callouts during link validation (#688) Signed-off-by: squidfunk --- .../tests/unit/collectors/test_references.py | 59 ++++++++++++ .../zensical/collectors/references/cursor.py | 94 +++++++++++++------ 2 files changed, 125 insertions(+), 28 deletions(-) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index bc781a1..60ebf46 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -487,6 +487,41 @@ class TestLinkReferences: assert text(md, link_refs[0].text) == b"TOC" assert text(md, link_refs[0].id) == b"id" + @pytest.mark.parametrize( + ("md", "expected_text"), + [ + pytest.param(b"[!NOTE]", b"!NOTE", id="not-blockquote"), + pytest.param(b"> text [!NOTE]", b"!NOTE", id="inline"), + pytest.param(b"> [!NOTE] text", b"!NOTE", id="trailing-text"), + pytest.param(b"> [!note]", b"!note", id="lowercase"), + ], + ) + def test_link_ref_callout_text( + self, md: bytes, expected_text: 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) == expected_text + + @pytest.mark.parametrize( + ("md", "expected_id"), + [ + pytest.param(b"> [!NOTE][]", b"!NOTE", id="collapsed"), + pytest.param(b"> [!NOTE][id]", b"id", id="explicit"), + ], + ) + def test_link_ref_callout_id(self, md: bytes, expected_id: 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"!NOTE" + assert text(md, link_refs[0].id) == expected_id + # --- negative cases --- def test_no_link_ref_escaped_brackets(self) -> None: @@ -529,6 +564,30 @@ class TestLinkReferences: refs = collect(md) assert len(refs) == 0 + @pytest.mark.parametrize( + "md", + [ + pytest.param(b"> [!NOTE]", id="note"), + pytest.param(b"> [!TIP]", id="tip"), + pytest.param(b"> [!IMPORTANT]", id="important"), + pytest.param(b"> [!WARNING]", id="warning"), + pytest.param(b"> [!CAUTION]", id="caution"), + pytest.param( + b"> [!NOTE]\n> This is a **note** admonition.", + id="note-body", + ), + pytest.param( + b"> [!WARNING]\n> This is a **warning** admonition.", + id="warning-body", + ), + pytest.param(b">>[!NOTE]", id="nested-compact"), + pytest.param(b"> > [!NOTE]", id="nested-spaced"), + ], + ) + def test_no_link_ref_callout_marker(self, md: bytes) -> None: + refs = collect(md) + assert len(refs) == 0 + # --------------------------------------------------------------------------- diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index 772e0a2..9345092 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -425,14 +425,18 @@ def _scan_link_or_link_ref(cursor: Cursor) -> Link | LinkReference | None: after_text = end id, end = _scan_link_id(cursor, end) - # Ignore empty shortcut references like `[]` or `[][]`. + # 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. + # Ignore Python Markdown's table-of-contents marker if id is None and _is_toc_marker(cursor, text, after_text): return None + # Ignore GitHub callout markers inside blockquotes + if id is None and _is_callout_marker(cursor, text, after_text): + return None + # Advance cursor and return link reference cursor.advance(end - start) return LinkReference( @@ -698,32 +702,6 @@ 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) - - # --------------------------------------------------------------------------- @@ -1475,6 +1453,66 @@ def _scan_tasklist_checkbox(cursor: Cursor) -> int | 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) + + +def _is_callout_marker(cursor: Cursor, text: Span, end: int) -> bool: + """Return whether a shortcut reference is a GitHub callout marker.""" + start = text.start - cursor.shift - 1 + if cursor.data[start + 1 : end - 1] not in ( + b"!NOTE", + b"!TIP", + b"!IMPORTANT", + b"!WARNING", + b"!CAUTION", + ): + 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 + + # Skip to the next line and ensure it's not blank + found = False + pos = _find_line_start(cursor, start) + while pos < start: + pos = _skip_whitespace(cursor, pos) + if pos >= start or cursor.data[pos] != _RANGLE: + return False + found = True + pos = _skip_whitespace(cursor, pos + 1) + + # The callout marker must be preceded by one or more > characters + return found and pos == start + + +# --------------------------------------------------------------------------- + + def _skip_whitespace(cursor: Cursor, pos: int) -> int: """Skip horizontal whitespace (spaces and tabs).""" while pos < cursor.end and cursor.data[pos] in _WHITESPACE: