forked from swaroopch/byte-of-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_links.py
More file actions
73 lines (59 loc) · 2.55 KB
/
Copy pathcheck_links.py
File metadata and controls
73 lines (59 loc) · 2.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/usr/bin/env python3
"""Check internal cross-references in the course notes.
Verifies that every `file.md#anchor` link points at a file that exists and at an
anchor actually defined in that file, either as an explicit `<a id="...">` tag
or as a heading GitBook would slugify to that name.
Run from the repo root: uv run check_links.py
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).parent / "docs"
LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
EXPLICIT_ANCHOR = re.compile(r'<a\s+id="([^"]+)"')
HEADING = re.compile(r"^#+\s+(.*)$", re.MULTILINE)
# Trailing {#custom-id} syntax, as used by the upstream book.
HEADING_ID = re.compile(r"\{#([^}]+)\}\s*$")
def slugify(heading):
"""Approximate GitBook/Honkit heading -> anchor conversion."""
text = re.sub(r"<a\s+id=\"[^\"]+\"\s*>\s*</a>", "", heading)
text = HEADING_ID.sub("", text)
text = re.sub(r"[`*_\\]", "", text)
text = text.strip().lower()
text = re.sub(r"[^\w\s-]", "", text)
return re.sub(r"[\s]+", "-", text).strip("-")
def anchors_in(path):
"""All anchor names that a link into this file could legitimately target."""
text = path.read_text(encoding="utf-8")
found = set(EXPLICIT_ANCHOR.findall(text))
for heading in HEADING.findall(text):
match = HEADING_ID.search(heading)
if match:
found.add(match.group(1))
found.add(slugify(heading))
return found
def main():
md_files = sorted(ROOT.glob("*.md"))
anchor_cache = {p.name: anchors_in(p) for p in md_files}
problems = []
for path in md_files:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for target in LINK.findall(line):
if target.startswith(("http://", "https://", "mailto:")):
continue
filename, _, anchor = target.partition("#")
filename = filename or path.name
if not filename.endswith(".md"):
continue # images and other assets
if filename not in anchor_cache:
problems.append(f"{path.name}:{lineno}: missing file {filename}")
elif anchor and anchor not in anchor_cache[filename]:
problems.append(
f"{path.name}:{lineno}: no anchor #{anchor} in {filename}"
)
for problem in problems:
print(problem)
print(f"\n{len(problems)} broken internal link(s) across {len(md_files)} files.")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())