refactor: implement parser for link validation

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
Martin Donath
2026-05-09 14:28:08 +02:00
committed by GitHub
parent ee64247c31
commit bf26d1be5e
8 changed files with 3753 additions and 605 deletions
+22
View File
@@ -0,0 +1,22 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
-212
View File
@@ -1,212 +0,0 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from zensical.utilities.span import Span
if TYPE_CHECKING:
from collections.abc import Iterator
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
_RE = re.compile(
rb"""
# Escaped characters
(?P<escaped>
\\` # Backslash followed by any character
)
|
# Code blocks
#
# ```python
# ...
# ```
#
(?P<code>
^(?P<indent>[ \t]*) # Capture leading indentation
(?P<fence>`{3,}|~{3,}) # Capture fence character and length
[^\r\n]*\r?\n # Optional info string
.*? # Block content
^[ \t]*(?P=fence)[`~]* # Closing fence: same type, at least as long
[^\r\n]*(\r?\n|$) # Optional trailing content
)
|
# HTML comments (block and inline)
#
# <!-- Comment content -->
#
(?P<comment>
<!-- # Opening delimiter
.*? # Comment content
--> # Closing delimiter
)
|
# HTML blocks (single-line)
#
# <div>...</div>
#
(?P<html_inline>
^<(?P<tag_inline>\w+) # Opening block-level tag
(?P<attrs_inline>[ \t][^>]*)? # Optional attributes
></(?P=tag_inline)>[ \t]*(\r?\n|$) # Immediate closing tag
)
|
# HTML blocks (multi-line)
#
# <div>
# ...
# </div>
#
(?P<html>
^<(?P<tag>\w+) # Opening block-level tag
(?P<attrs>[ \t][^>]*)? # Optional attributes (captured)
>[ \t]*\r?\n # Close of tag, end of line
.*? # Block content
^</(?P=tag)>[ \t]*\r?$ # Closing tag
)
|
# Block math:
#
# $$
# ...
# $$
#
(?P<math_block>
^[ \t]*\$\$[ \t]*\r?\n # Opening $$ on its own line
.*? # Math content
^[ \t]*\$\$[ \t]*\r?$ # Closing $$ on its own line
)
|
# Block math (alternate):
#
# \[
# ...
# \]
#
(?P<math_block_alt>
^[ \t]*\\\[[ \t]*\r?\n # Opening \[ on its own line
.*? # Math content
^[ \t]*\\\][ \t]*\r?$ # Closing \] on its own line
)
|
# Inline math:
#
# $f(x)$
#
(?P<math_inline>
(?<!\$)\$(?![\s$]) # Opening $, not preceded/followed by $
(?-s:.*?) # Math content (same line only)
(?<![\s$])\$(?!\$) # Closing $, not preceded by space or $
)
|
# Inline math (alternate):
#
# \(f(x)\)
#
(?P<math_inline_alt>
\\\( # Opening \(
(?-s:.*?) # Math content (same line only)
\\\) # Closing \)
)
|
# Inline code blocks
#
# `code`
#
(?P<code_inline>
(?P<ticks>`+) # Opening backticks
.+? # Block content
(?P=ticks) # Closing backticks (matching)
)
|
# Jinja2 block tags:
#
# {% if ... %}
# {% for ... %}
# {% endif %}
#
(?P<jinja_block>
\{%-?[ \t]*.*?[ \t]*-?%\}
)
|
# Jinja2 variable expressions:
#
# {{ variable }}
# {{ obj[key] }}
# {{ func(arg) }}
#
(?P<jinja_expr>
\{\{-?[ \t]*.*?[ \t]*-?\}\}
)
|
# Jinja2 comments:
#
# {# ... #}
#
(?P<jinja_comment>
\{#-?.*?-?#\}
)
""",
re.VERBOSE | re.MULTILINE | re.DOTALL,
)
"""
Match regions that should be excluded from reference scanning.
This includes fenced code blocks, inline code blocks, HTML comments, and HTML
blocks without `markdown` attribute, as they may contain link-like patterns that
should not be treated as references.
"""
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def exclusions(content: bytes, shift: int = 0) -> Iterator[Span]:
"""Scan Markdown and yield exclusions."""
for match in _RE.finditer(content):
if match.lastgroup != "html":
yield Span(shift + match.start(), shift + match.end())
continue
# In case this is a non-Markdown HTML block, we exclude the entire
# block, as it may contain link-like patterns that must be ignored
attrs = match.group("attrs") or b""
if b"markdown" not in attrs:
yield Span(shift + match.start(), shift + match.end())
continue
# Exclude opening tag line
end = content.index(b"\n", match.start()) + 1
yield Span(shift + match.start(), shift + end)
# Exclude closing tag line (cut from block), and recurse
start = end
end = content.rindex(b"\n", 0, match.end())
yield from exclusions(content[start:end], shift + start)
-393
View File
@@ -1,393 +0,0 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import re
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Literal, TypeAlias
from zensical.collectors.exclusions import exclusions
from zensical.utilities.span import Span
if TYPE_CHECKING:
from collections.abc import Iterator
from re import Match
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
@dataclass
class Link(Span):
"""A link or image, e.g. `[text](href)` or `![alt](href)`."""
kind: Literal["link", "image", "autolink", "wikilink"]
"""Discriminator identifying the variant."""
text: Span
"""Span of visible link text (or image alt text)."""
href: Span
"""Span of the link destination."""
# ----------------------------------------------------------------------------
@dataclass
class LinkReference(Span):
"""A link or image reference, e.g. `[text][id]` or `![alt][id]`."""
kind: Literal["link", "image"]
"""Discriminator identifying the variant."""
text: Span
"""Span of the visible link text (or image alt text)."""
id: Span
"""Span of the link id."""
@dataclass
class LinkDefinition(Span):
"""A link or image definition, e.g. `[id]: href`."""
id: Span
"""Span of the link id."""
href: Span
"""Span of the link destination."""
# ----------------------------------------------------------------------------
@dataclass
class FootnoteReference(Span):
"""A footnote reference, e.g. `[^id]`."""
id: Span
"""Span of the footnote id."""
@dataclass
class FootnoteDefinition(Span):
"""A footnote definition, e.g. `[^id]: body`."""
id: Span
"""Span of the footnote id."""
body: Span
"""Span of the footnote body, including multiline text."""
# ----------------------------------------------------------------------------
# Types
# ----------------------------------------------------------------------------
Reference: TypeAlias = (
Link
| LinkReference
| LinkDefinition
| FootnoteReference
| FootnoteDefinition
)
"""
A reference extracted from Markdown.
References might include inline links and images, reference-style link usages,
reference definitions, footnote references, and footnote definitions. Each of
those reference carries a source span covering the full matched text, plus
additional metadata for the relevant subcomponents.
"""
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
_RE = re.compile(
rb"""
# Escaped characters:
#
# \[ \] \! \*
#
(?P<escape>
\\[\[\]!*`]
)
|
# Task list checkbox:
#
# - [ ] text
# - [x] text
# - [X] text
#
(?P<task>
^[^\S\n]*[-*+][^\S\n]+\[[xX ]\]
)
|
# Footnote definition:
#
# [^id]: body
# [^id]:
# body on next line
#
(?P<footdef>
^[^\S\n]*\[\^(?P<footdef_id>[^\]]+)\]:[^\S\n]*
(?P<footdef_body>[^\n]*(?:\n[^\S\n]+[^\n]+)*)
)
|
# Link definition:
#
# [id]: href
# [id]: href "optional title"
#
(?P<linkdef>
^[^\S\n]*\[(?P<linkdef_id>[^\]]+)\]:[^\S\n]*
(?P<linkdef_href>\S+)[^\n]*$
)
|
# Footnote reference:
#
# [^id]
#
(?P<footref>
\[\^(?P<footref_id>[^\]]+)\]
)
|
# Wikilink:
#
# [[text]]
#
(?P<wikilink>
\[\[(?P<wikilink_text>[^\]]+)\]\]
)
|
# Inline image:
#
# ![alt](href)
# ![alt](href "title")
#
(?P<image>
!\[(?P<image_alt>[^\]]*)\]
\((?P<image_href>[^)\s]+)[^)]*\)
)
|
# Image reference:
#
# ![alt][id]
# ![alt]\n[id] (label on next line)
# ![alt][] (collapsed)
# ![alt] (shortcut)
#
(?P<imageref>
!\[(?P<imageref_alt>[^\]]*)\]
(?:[^\S\n]*\n?[^\S\n]*\[(?![\^])(?P<imageref_id>[^\]]*)\])?
)
|
# Inline link:
#
# [text](href)
# [text](href "title")
#
(?P<link>
\[(?P<link_text>[^\]]+)\]
\((?P<link_href>[^)\s]+)[^)]*\)
)
|
# Autolink:
#
# <https://example.com>
#
(?P<autolink>
<(?P<autolink_href>https?://[^>]+)>
)
|
# Abbreviation definitions:
#
# *[HTML]: Hyper Text Markup Language
#
(?P<abbr>
^\*\[(?P<abbr_text>[^\]]+)\]:[^\n]*$
)
|
# Link reference:
#
# [text][id]
# [text]\n[id] (label on next line)
# [text][] (collapsed)
# [text] (shortcut)
#
(?P<linkref>
\[(?P<linkref_text>[^\]]+)\]
(?:[^\S\n]*\n?[^\S\n]*\[(?![\^])(?P<linkref_id>[^\]]*)\])?
)
""",
re.VERBOSE | re.MULTILINE,
)
"""
Match link-like constructs in Markdown.
**Extraction and matching order**
References are extracted via the compiled regex. The regex uses alternation
with carefully ordered branches - specificity matters:
- **Escaped characters** - `[` `]` `!` `*` (skipped, not treated as references)
- **Task list checkbox** - `- [ ]` `- [x]` `- [X]`
- **Footnote definition** - `[^id]: body` (block-level, can span lines)
- **Link definition** - `[id]: href` (block-level, with optional title)
- **Footnote reference** - `[^id]` (inline)
- **Wikilink** - `[[target]]` (inline, standalone syntax)
- **Inline image** - `![alt](href)` (inline)
- **Image reference** - `![alt][id]`, `![alt][]`, `![alt]` (inline)
- **Inline link** - `[text](href)` (inline)
- **Autolink** - `<https://...>` (inline, angle-bracket syntax)
- **Abbreviations** - `*[HTML]: Hyper Text Markup Language`
- **Link reference** - `[text][id]`, `[text][]`, `[text]` (inline)
**Why order matters**
- Escaped characters are skipped and not treated as references.
- Block-level definitions (footnote, link) are checked before inline patterns
to avoid overlaps with similar bracket syntax.
- Images must be checked before links: `![alt](href)` starts with `!`, but
if not caught early, could conflict with `[text](href)` patterns.
- Autolinks are more specific (`<...>`) and checked before generic reference
links (`[text][id]`).
- Wikilinks (`[[...]]`) are unambiguous and can appear earlier.
**Shortcut and collapsed references**
Link and image references support three forms:
- **Explicit**: `[text][id]` or `![alt][id]` - the id is explicit
- **Collapsed**: `[text][]` or `![alt][]` - empty brackets, shortcut
- **Shortcut**: `[text]` or `![alt]` - no second brackets, shortcut
In collapsed and shortcut forms, the reference id defaults to the visible text.
"""
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def references(markdown: bytes, shift: int = 0) -> Iterator[Reference]:
"""Scan Markdown and yield all references.
Performs a single left-to-right pass over the given Markdown by using an
alternation regex, yielding one `Reference` per match in source order. No
state is carried between matches. Results can be collected into a list
or processed lazily.
"""
exclude = iter(exclusions(markdown))
current = next(exclude, None)
for match in _RE.finditer(markdown):
kind = match.lastgroup
# Extract the start and end positions of the full match, and build a
# partial function to extract spans from named capture groups
start, end = shift + match.start(), shift + match.end()
span = partial(_span, shift, match)
# Advance past exclusions that end before this match
while current and current.end <= start:
current = next(exclude, None)
# If the current exclusion covers the match, skip it
if current and (current.contains(start) or current.contains(end)):
continue
# Inline link
if kind == "link":
text, href = span("link_text"), span("link_href")
yield Link(start, end, "link", text, href)
# Inline image
elif kind == "image":
text, href = span("image_alt"), span("image_href")
yield Link(start, end, "image", text, href)
# Image reference
elif kind == "imageref":
text = span("imageref_alt")
id = _span_for_id(shift, match, "imageref_id") or text
yield LinkReference(start, end, "image", text, id)
# Link reference
elif kind == "linkref":
text = span("linkref_text")
id = _span_for_id(shift, match, "linkref_id") or text
yield LinkReference(start, end, "link", text, id)
# Link definition
elif kind == "linkdef":
id, href = span("linkdef_id"), span("linkdef_href")
yield LinkDefinition(start, end, id, href)
# Footnote reference
elif kind == "footref":
id = span("footref_id")
yield FootnoteReference(start, end, id)
# Footnote definition
elif kind == "footdef":
id, body = span("footdef_id"), span("footdef_body")
yield FootnoteDefinition(start, end, id, body)
# Recurse into footnote body to extract nested references, shifting
# the content to account for the position of the Markdown body
yield from references(
markdown[body.start : body.end], shift + body.start
)
# Wikilink
elif kind == "wikilink":
text = span("wikilink_text")
yield Link(start, end, "wikilink", text, text)
# Autolink
elif kind == "autolink":
href = span("autolink_href")
yield Link(start, end, "autolink", href, href)
# ----------------------------------------------------------------------------
def _span(shift: int, match: Match[bytes], name: str) -> Span:
"""Build a span for a named capture group within a match."""
start, end = match.start(name), match.end(name)
return Span(shift + start, shift + end)
def _span_for_id(shift: int, match: Match[bytes], name: str) -> Span | None:
"""Build an id span for a named capture group within a match."""
if match.group(name):
return _span(shift, match, name)
# Return nothing
return None
@@ -0,0 +1,59 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from typing import TYPE_CHECKING
from zensical.collectors.references.cursor import Cursor, _scan
from zensical.collectors.references.types import (
FootnoteDefinition,
FootnoteReference,
Link,
LinkDefinition,
LinkReference,
Reference,
)
if TYPE_CHECKING:
from collections.abc import Iterator
__all__ = [
"FootnoteDefinition",
"FootnoteReference",
"Link",
"LinkDefinition",
"LinkReference",
"Reference",
"references",
]
# ---------------------------------------------------------------------------
# Functions
# ---------------------------------------------------------------------------
def references(markdown: bytes, shift: int = 0) -> Iterator[Reference]:
"""Scan Markdown and yield references."""
cursor = Cursor(markdown, shift)
yield from _scan(cursor)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, TypeAlias
from zensical.utilities.span import Span
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
@dataclass
class Link(Span):
"""A link or image, e.g. `[text](href)` or `![alt](href)`."""
kind: Literal["link", "image", "autolink", "wikilink", "html"]
"""Discriminator identifying the variant."""
text: Span
"""Span of visible link text (or image alt text)."""
href: Span
"""Span of the link destination."""
# ----------------------------------------------------------------------------
@dataclass
class LinkReference(Span):
"""A link or image reference, e.g. `[text][id]` or `![alt][id]`."""
kind: Literal["link", "image"]
"""Discriminator identifying the variant."""
text: Span
"""Span of the visible link text (or image alt text)."""
id: Span
"""Span of the link id."""
@dataclass
class LinkDefinition(Span):
"""A link or image definition, e.g. `[id]: href`."""
id: Span
"""Span of the link id."""
href: Span
"""Span of the link destination."""
# ----------------------------------------------------------------------------
@dataclass
class FootnoteReference(Span):
"""A footnote reference, e.g. `[^id]`."""
id: Span
"""Span of the footnote id."""
@dataclass
class FootnoteDefinition(Span):
"""A footnote definition, e.g. `[^id]: body`."""
id: Span
"""Span of the footnote id."""
body: Span
"""Span of the footnote body, including multiline text."""
# ----------------------------------------------------------------------------
# Types
# ----------------------------------------------------------------------------
Reference: TypeAlias = (
Link
| LinkReference
| LinkDefinition
| FootnoteReference
| FootnoteDefinition
)
"""
A reference extracted from Markdown.
References might include inline links and images, link references and link
definitions, footnote references, and footnote definitions. Each of those
reference carries a source span covering the full matched text, possibly with
additional metadata for the relevant subcomponents.
"""