55 branches : [main]
66 paths :
77 - " .github/workflows/validate-docs.yml"
8+ - " scripts/ci/check_markdown_links.py"
89 - " docs/**"
910 - " **/*.md"
1011 - " **/*.markdown"
1112 push :
1213 branches : [main]
1314 paths :
1415 - " .github/workflows/validate-docs.yml"
16+ - " scripts/ci/check_markdown_links.py"
1517 - " docs/**"
1618 - " **/*.md"
1719 - " **/*.markdown"
@@ -33,216 +35,4 @@ jobs:
3335 python-version : " 3.11"
3436
3537 - name : Validate repo-relative Markdown links
36- shell : bash
37- run : |
38- python - <<'PY'
39- import html
40- import re
41- import sys
42- from pathlib import Path
43- from urllib.parse import unquote, urlsplit
44-
45- SKIP_DIRS = {
46- ".git",
47- "node_modules",
48- ".venv",
49- "venv",
50- "__pycache__",
51- "test-output",
52- }
53- FENCE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
54- HTML_TARGET = re.compile(
55- r"<(?:a|img|source|video|audio)\b[^>]*?"
56- r"\s(?:href|src|poster)\s*=\s*(?:\"([^\"]+)\"|'([^']+)')",
57- re.I,
58- )
59- REFERENCE_TARGET = re.compile(
60- r"^[ \t]{0,3}\[(?!\^)[^\]]+\]:[ \t]*"
61- r"(?:<([^>]+)>|((?:\\.|\S)+))",
62- re.M,
63- )
64-
65-
66- def markdown_files(root: Path):
67- paths = set(root.rglob("*.md")) | set(root.rglob("*.markdown"))
68- for path in sorted(paths):
69- if not any(part in SKIP_DIRS for part in path.parts):
70- yield path
71-
72-
73- def blank(text: str) -> str:
74- return "".join("\n" if char == "\n" else " " for char in text)
75-
76-
77- def closing_backticks(line: str, start: int, width: int) -> int:
78- marker = "`" * width
79- cursor = start
80- while True:
81- closing = line.find(marker, cursor)
82- if closing < 0:
83- return -1
84- before = closing > 0 and line[closing - 1] == "`"
85- after_index = closing + width
86- after = after_index < len(line) and line[after_index] == "`"
87- if not before and not after:
88- return closing
89- cursor = closing + width
90-
91-
92- def mask_code(markdown: str) -> str:
93- """Mask code and comments while preserving offsets and newlines."""
94- masked_lines = []
95- fence_char = None
96- fence_length = 0
97-
98- for line in markdown.splitlines(keepends=True):
99- fence = FENCE.match(line)
100- if fence_char is not None:
101- if fence:
102- marker, remainder = fence.groups()
103- if (
104- marker[0] == fence_char
105- and len(marker) >= fence_length
106- and not remainder.strip()
107- ):
108- fence_char = None
109- fence_length = 0
110- masked_lines.append(blank(line))
111- continue
112-
113- if fence:
114- marker = fence.group(1)
115- fence_char = marker[0]
116- fence_length = len(marker)
117- masked_lines.append(blank(line))
118- continue
119-
120- chars = list(line)
121- cursor = 0
122- while cursor < len(chars):
123- if chars[cursor] != "`":
124- cursor += 1
125- continue
126- end_of_run = cursor
127- while end_of_run < len(chars) and chars[end_of_run] == "`":
128- end_of_run += 1
129- width = end_of_run - cursor
130- closing = closing_backticks(line, end_of_run, width)
131- if closing < 0:
132- cursor = end_of_run
133- continue
134- for index in range(cursor, closing + width):
135- if chars[index] != "\n":
136- chars[index] = " "
137- cursor = closing + width
138- masked_lines.append("".join(chars))
139-
140- masked = "".join(masked_lines)
141- return re.sub(
142- r"<!--.*?-->",
143- lambda match: blank(match.group()),
144- masked,
145- flags=re.S,
146- )
147-
148-
149- def markdown_inline_targets(markdown: str):
150- """Parse Markdown destinations, including spaces and balanced parentheses."""
151- cursor = 0
152- while True:
153- opening = markdown.find("](", cursor)
154- if opening < 0:
155- return
156-
157- index = opening + 2
158- while index < len(markdown) and markdown[index].isspace():
159- index += 1
160- target_line = markdown.count("\n", 0, opening) + 1
161-
162- if index < len(markdown) and markdown[index] == "<":
163- closing = markdown.find(">", index + 1)
164- if closing >= 0:
165- yield markdown[index + 1 : closing], target_line
166- cursor = closing + 1
167- continue
168-
169- start = index
170- depth = 0
171- while index < len(markdown):
172- char = markdown[index]
173- if char == "\\" and index + 1 < len(markdown):
174- index += 2
175- continue
176- if char == "(":
177- depth += 1
178- elif char == ")":
179- if depth == 0:
180- break
181- depth -= 1
182- elif char.isspace() and depth == 0:
183- break
184- index += 1
185-
186- if index > start:
187- yield markdown[start:index], target_line
188- cursor = max(index + 1, opening + 2)
189-
190-
191- def line_number(markdown: str, offset: int) -> int:
192- return markdown.count("\n", 0, offset) + 1
193-
194-
195- def link_targets(markdown: str):
196- yield from markdown_inline_targets(markdown)
197- for match in REFERENCE_TARGET.finditer(markdown):
198- yield match.group(1) or match.group(2), line_number(markdown, match.start())
199- for match in HTML_TARGET.finditer(markdown):
200- yield match.group(1) or match.group(2), line_number(markdown, match.start())
201-
202-
203- def local_path(raw_target: str):
204- target = html.unescape(raw_target.strip())
205- try:
206- parsed = urlsplit(target)
207- except ValueError:
208- return None
209- if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith("/"):
210- return None
211- path = unquote(parsed.path)
212- return re.sub(r"\\([\\`*_{}\[\]()#+\-.! ])", r"\1", path)
213-
214-
215- root = Path(".").resolve()
216- broken = []
217- checked = 0
218-
219- for markdown in markdown_files(root):
220- text = markdown.read_text(encoding="utf-8", errors="ignore")
221- for raw_target, target_line in link_targets(mask_code(text)):
222- target = local_path(raw_target)
223- if target is None:
224- continue
225-
226- checked += 1
227- resolved = (markdown.parent / target).resolve()
228- try:
229- resolved.relative_to(root)
230- except ValueError:
231- broken.append(
232- f"{markdown.relative_to(root)}:{target_line}: "
233- f"{raw_target} (escapes repository)"
234- )
235- else:
236- if not resolved.exists():
237- broken.append(
238- f"{markdown.relative_to(root)}:{target_line}: {raw_target}"
239- )
240-
241- if broken:
242- print(f"{len(broken)} broken repo-relative link(s):", file=sys.stderr)
243- for item in broken:
244- print(f" {item}", file=sys.stderr)
245- raise SystemExit(1)
246-
247- print(f"All {checked} repo-relative Markdown links resolve.")
248- PY
38+ run : python scripts/ci/check_markdown_links.py
0 commit comments