From 93cc8699789f86f6b0b60033c95a706066818d83 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 18:38:01 +0200 Subject: [PATCH 1/5] fix: snippet markers recognized as unresolved link references (#700) Signed-off-by: squidfunk --- .../tests/unit/collectors/test_references.py | 31 +++++++++++++ .../zensical/collectors/references/cursor.py | 45 +++++++++++++++++++ uv.lock | 8 ++-- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index 2875bd1..a8e3b97 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -1710,6 +1710,37 @@ class TestFencedCodeBlocks: # --------------------------------------------------------------------------- +class TestSnippets: + """Tests for pymdownx.snippets markers.""" + + def test_section_markers_are_ignored(self) -> None: + md = ( + b"# --8<-- [start:reference_section]\n" + b"Text\n" + b"# --8<-- [end:reference_section]\n" + ) + refs = collect(md) + assert len(refs) == 0 + + def test_section_markers_do_not_hide_following_links(self) -> None: + md = ( + b"# --8<-- [start:reference_section]\n" + b"Text\n" + b"# --8<-- [end:reference_section]\n" + b"[after](href)\n" + ) + refs = collect(md) + assert len(refs) == 1 + + links = links_only(refs) + assert len(links) == 1 + assert text(md, links[0].text) == b"after" + assert text(md, links[0].href) == b"href" + + +# --------------------------------------------------------------------------- + + class TestInlineCode: """Tests for inline code.""" diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index 3738077..b49797c 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -169,6 +169,13 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: while cursor.pos < cursor.end: char = cursor.data[cursor.pos] + # Snippet directives and section markers: --8<-- ... + if cursor.at_line_start(): + end = _scan_snippet(cursor) + if end is not None: + cursor.advance(end - cursor.pos) + continue + # Escaped character or math if char == _BACKSLASH: next = cursor.peek(1) @@ -387,6 +394,44 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: # --------------------------------------------------------------------------- +def _scan_snippet(cursor: Cursor) -> int | None: + """Scan for a pymdownx.snippets directive or section marker line.""" + pos = _skip_whitespace(cursor, cursor.pos) + + # Markdown snippet sections are commonly wrapped in heading comments. + if pos < cursor.end and cursor.data[pos] == _HASH: + pos = _skip_whitespace(cursor, pos + 1) + + # Match the scissors marker: -+8<-+ + start = pos + while pos < cursor.end and cursor.data[pos] == _DASH: + pos += 1 + if pos == start or pos >= cursor.end or cursor.data[pos] != ord(b"8"): + return None + pos += 1 + + start = pos + while pos < cursor.end and cursor.data[pos] == _LANGLE: + pos += 1 + if pos == start: + return None + + start = pos + while pos < cursor.end and cursor.data[pos] == _DASH: + pos += 1 + if pos == start: + return None + + # Snippet syntax ends at the line boundary. + if pos < cursor.end and cursor.data[pos] not in (_SPACE, _TAB, _CR, _NL): + return None + + return _skip_line(cursor, pos) + + +# --------------------------------------------------------------------------- + + def _scan_link_or_link_ref(cursor: Cursor) -> Link | LinkReference | None: """Scan for link or link reference. diff --git a/uv.lock b/uv.lock index 9d5a2e2..a493c4e 100644 --- a/uv.lock +++ b/uv.lock @@ -636,15 +636,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -952,7 +952,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1" }, { name = "markdown", specifier = ">=3.7" }, { name = "pygments", specifier = ">=2.20" }, - { name = "pymdown-extensions", specifier = ">=10.21.2" }, + { name = "pymdown-extensions", specifier = ">=10.21.3" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "tomli", specifier = ">=2.4.0" }, ] From c40f649f7bb3be56e15b52b2daa4af2558485777 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 18:59:54 +0200 Subject: [PATCH 2/5] fix: brackets in indented code blocks recognized as unresolved link references (#700) Signed-off-by: squidfunk --- .../tests/unit/collectors/test_references.py | 33 +++++++++-- .../zensical/collectors/references/cursor.py | 55 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index a8e3b97..03c2471 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -455,7 +455,6 @@ class TestLinkReferences: 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: @@ -737,10 +736,7 @@ class TestLinkDefinitions: def test_no_link_def_indent(self) -> None: md = b" [id]: href" refs = collect(md) - assert len(refs) == 1 - - link_refs = link_refs_only(refs) - assert len(link_refs) == 1 + assert len(refs) == 0 def test_no_link_def_prefix(self) -> None: md = b"text [id]: href" @@ -1741,6 +1737,33 @@ class TestSnippets: # --------------------------------------------------------------------------- +class TestIndentedCodeBlocks: + """Tests for indented code blocks.""" + + def test_indented_code_is_ignored(self) -> None: + md = b"\n [Start][]\n" + refs = collect(md) + assert len(refs) == 0 + + def test_indented_code_with_blank_line_continuation_is_ignored(self) -> None: + md = b"\n [Start][]\n\n [End][]\n" + refs = collect(md) + assert len(refs) == 0 + + def test_indented_code_does_not_hide_following_link(self) -> None: + md = b"\n [Start][]\n\n[after](href)\n" + refs = collect(md) + assert len(refs) == 1 + + links = links_only(refs) + assert len(links) == 1 + assert text(md, links[0].text) == b"after" + assert text(md, links[0].href) == b"href" + + +# --------------------------------------------------------------------------- + + class TestInlineCode: """Tests for inline code.""" diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index b49797c..9f67a64 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -169,6 +169,13 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: while cursor.pos < cursor.end: char = cursor.data[cursor.pos] + # Top-level indented code block. + if cursor.at_line_start(): + end = _scan_indented_code(cursor) + if end is not None: + cursor.advance(end - cursor.pos) + continue + # Snippet directives and section markers: --8<-- ... if cursor.at_line_start(): end = _scan_snippet(cursor) @@ -394,6 +401,37 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: # --------------------------------------------------------------------------- +def _scan_indented_code(cursor: Cursor) -> int | None: + """Scan for a top-level indented code block.""" + line = _find_line_start(cursor, cursor.pos) + if not _is_previous_line_blank(cursor, line): + return None + + pos = line + indent, end = _measure_indent(cursor, pos) + if indent < 4: + return None + if end >= cursor.end or cursor.data[end] in (_CR, _NL): + return None + + # Consume the whole chunk: indented lines plus blank continuation lines. + pos = _skip_line(cursor, end) + while pos < cursor.end: + if _is_blank_line(cursor, pos): + pos = _skip_line(cursor, pos) + continue + + indent, end = _measure_indent(cursor, pos) + if indent < 4: + break + pos = _skip_line(cursor, end) + + return pos + + +# --------------------------------------------------------------------------- + + def _scan_snippet(cursor: Cursor) -> int | None: """Scan for a pymdownx.snippets directive or section marker line.""" pos = _skip_whitespace(cursor, cursor.pos) @@ -1579,6 +1617,23 @@ def _skip_whitespace(cursor: Cursor, pos: int) -> int: return pos +def _measure_indent(cursor: Cursor, pos: int) -> tuple[int, int]: + """Return visual indentation width and first non-whitespace position.""" + indent = 0 + while pos < cursor.end: + char = cursor.data[pos] + if char == _SPACE: + indent += 1 + pos += 1 + continue + if char == _TAB: + indent += 4 - (indent % 4) + pos += 1 + continue + break + return indent, pos + + def _skip_whitespace_newline(cursor: Cursor, pos: int) -> int: """Skip trailing horizontal whitespace and at most one newline.""" pos = _skip_whitespace(cursor, pos) From a4866d0fa60bf27064353c3a8d781b961a430b96 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 19:03:03 +0200 Subject: [PATCH 3/5] chore: make ruff happy Signed-off-by: squidfunk --- python/tests/unit/collectors/test_references.py | 4 +++- python/zensical/collectors/references/cursor.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index 03c2471..1cf8893 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -1745,7 +1745,9 @@ class TestIndentedCodeBlocks: refs = collect(md) assert len(refs) == 0 - def test_indented_code_with_blank_line_continuation_is_ignored(self) -> None: + def test_indented_code_with_blank_line_continuation_is_ignored( + self, + ) -> None: md = b"\n [Start][]\n\n [End][]\n" refs = collect(md) assert len(refs) == 0 diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index 9f67a64..fde4bd6 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -409,7 +409,7 @@ def _scan_indented_code(cursor: Cursor) -> int | None: pos = line indent, end = _measure_indent(cursor, pos) - if indent < 4: + if indent < 4: # noqa: PLR2004 return None if end >= cursor.end or cursor.data[end] in (_CR, _NL): return None @@ -422,7 +422,7 @@ def _scan_indented_code(cursor: Cursor) -> int | None: continue indent, end = _measure_indent(cursor, pos) - if indent < 4: + if indent < 4: # noqa: PLR2004 break pos = _skip_line(cursor, end) From eb7211503c56e2a97f9e7313f7c7d7b54b2b8794 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 19:07:40 +0200 Subject: [PATCH 4/5] fix: ignore Markdown comments `[//]: ...` during link validation Signed-off-by: squidfunk --- .../tests/unit/collectors/test_references.py | 22 +++++++++++++++ .../zensical/collectors/references/cursor.py | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index 1cf8893..e8e79c1 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -1766,6 +1766,28 @@ class TestIndentedCodeBlocks: # --------------------------------------------------------------------------- +class TestMarkdownComments: + """Tests for Markdown comments written as link-definition hacks.""" + + def test_markdown_comment_is_ignored(self) -> None: + md = b"[//]: # (comment)\n" + refs = collect(md) + assert len(refs) == 0 + + def test_markdown_comment_does_not_hide_following_link(self) -> None: + md = b"[//]: # (comment)\n[after](href)\n" + refs = collect(md) + assert len(refs) == 1 + + links = links_only(refs) + assert len(links) == 1 + assert text(md, links[0].text) == b"after" + assert text(md, links[0].href) == b"href" + + +# --------------------------------------------------------------------------- + + class TestInlineCode: """Tests for inline code.""" diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index fde4bd6..d3160db 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -183,6 +183,13 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: cursor.advance(end - cursor.pos) continue + # Markdown comment hack: [//]: ... + if cursor.at_line_start(): + end = _scan_markdown_comment(cursor) + if end is not None: + cursor.advance(end - cursor.pos) + continue + # Escaped character or math if char == _BACKSLASH: next = cursor.peek(1) @@ -470,6 +477,26 @@ def _scan_snippet(cursor: Cursor) -> int | None: # --------------------------------------------------------------------------- +def _scan_markdown_comment(cursor: Cursor) -> int | None: + """Scan for a Markdown comment line written as `[//]: ...`.""" + start = cursor.pos + + # Skip if there're more than four spaces of indentation + if cursor.col > 3: # noqa: PLR2004 + return None + + id, end = _scan_link_id(cursor, start) + if id is None or cursor.data[id.start - cursor.shift : id.end - cursor.shift] != b"//": + return None + if end >= cursor.end or cursor.data[end] != _COLON: + return None + + return _skip_line(cursor, end) + + +# --------------------------------------------------------------------------- + + def _scan_link_or_link_ref(cursor: Cursor) -> Link | LinkReference | None: """Scan for link or link reference. From fe04446967d5eb060440498b21afe09cfad37bb7 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 19:16:03 +0200 Subject: [PATCH 5/5] chore: make ruff happy Signed-off-by: squidfunk --- python/zensical/collectors/references/cursor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index d3160db..af25ebe 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -486,7 +486,10 @@ def _scan_markdown_comment(cursor: Cursor) -> int | None: return None id, end = _scan_link_id(cursor, start) - if id is None or cursor.data[id.start - cursor.shift : id.end - cursor.shift] != b"//": + if ( + id is None + or cursor.data[id.start - cursor.shift : id.end - cursor.shift] != b"//" + ): return None if end >= cursor.end or cursor.data[end] != _COLON: return None