Skip to content

Commit 676c9cc

Browse files
committed
ci: add doc check to pre-commit
1 parent 8b0091c commit 676c9cc

3 files changed

Lines changed: 228 additions & 213 deletions

File tree

.github/workflows/validate-docs.yml

Lines changed: 3 additions & 213 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ on:
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

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ repos:
77
args: [--check]
88
- repo: local
99
hooks:
10+
- id: validate-docs
11+
name: validate docs
12+
entry: python scripts/ci/check_markdown_links.py
13+
language: system
14+
pass_filenames: false
15+
files: '(^docs/|\.md$|\.markdown$|^scripts/ci/check_markdown_links\.py$|^\.github/workflows/validate-docs\.yml$|^\.pre-commit-config\.yaml$)'
1016
- id: pyright
1117
name: pyright
1218
entry: uv run --frozen pyright src/clawbench tests

0 commit comments

Comments
 (0)