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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
#!/usr/bin/env python3
"""Check relative links in the project's tracked markdown documentation.
Walks docs/, wiki/ and userspace/cuda/, extracts markdown links, and fails if
a relative target does not exist. External links (http/https/mailto/tel/ftp/
data/javascript), pure anchors and links inside fenced code blocks are
skipped.
The raw imported wiki HTML dumps and the archived wiki copy are excluded:
their markup is not ours and their link shape is not representative.
Usage:
python3 scripts/check-docs-links.py
"""
import os
import re
import subprocess
import sys
from urllib.parse import unquote
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WATCHED_ROOTS = ("docs", "wiki", "userspace/cuda")
EXCLUDED_FILES = {
"wiki/GPU_Driver_Internals.md",
"wiki/GPU_Firmware.md",
"wiki/GPU_SBOM.md",
"wiki/Virtual_IO_Internals.md",
}
EXCLUDED_PREFIXES = ("wiki/open-iov.org/",)
SKIPPED_SCHEMES = (
"http://",
"https://",
"mailto:",
"tel:",
"ftp://",
"ftps://",
"data:",
"javascript:",
)
LINK_RE = re.compile(r"!?\[[^\]]*\]\(\s*([^)]+?)\s*\)")
def tracked_markdown_files():
"""Return repo-relative paths of tracked markdown files (git first)."""
try:
out = subprocess.run(
["git", "ls-files", "--", *WATCHED_ROOTS],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
).stdout
except (OSError, subprocess.CalledProcessError):
out = ""
if out.strip():
return [line for line in out.splitlines() if line.endswith(".md")]
files = []
for root in WATCHED_ROOTS:
for dirpath, _dirnames, filenames in os.walk(os.path.join(REPO_ROOT, root)):
for name in filenames:
if name.endswith(".md"):
full = os.path.join(dirpath, name)
files.append(os.path.relpath(full, REPO_ROOT))
return files
def excluded(relpath):
if relpath in EXCLUDED_FILES:
return True
return relpath.startswith(EXCLUDED_PREFIXES)
def strip_code_blocks(text):
"""Blank out fenced code blocks so example links are not checked."""
lines = []
fence = None
for line in text.splitlines():
stripped = line.lstrip()
marker = stripped[:3]
if fence is None:
if marker in ("```", "~~~"):
fence = marker
lines.append("")
continue
lines.append(line)
else:
if stripped.startswith(fence):
fence = None
lines.append("")
return "\n".join(lines)
def normalize_target(raw):
target = raw.strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1].strip()
if not target:
return None
if target.startswith("#"):
return None
if target.lower().startswith(SKIPPED_SCHEMES):
return None
# Drop an anchor and any markdown title, then percent-decode.
target = target.split()[0] if " " in target else target
target = target.split("#", 1)[0]
target = unquote(target)
if not target:
return None
return target
def check_file(relpath):
full = os.path.join(REPO_ROOT, relpath)
with open(full, "r", encoding="utf-8", errors="replace") as handle:
text = strip_code_blocks(handle.read())
problems = []
in_fence = False
for lineno, line in enumerate(text.splitlines(), start=1):
if line.lstrip()[:3] in ("```", "~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
for raw in LINK_RE.findall(line):
target = normalize_target(raw)
if target is None:
continue
if target.startswith("/"):
resolved = os.path.join(REPO_ROOT, target.lstrip("/"))
else:
resolved = os.path.normpath(
os.path.join(REPO_ROOT, os.path.dirname(relpath), target)
)
if not os.path.exists(resolved):
problems.append((lineno, target, os.path.relpath(resolved, REPO_ROOT)))
return problems
def main():
files = sorted(
relpath
for relpath in tracked_markdown_files()
if not excluded(relpath)
)
failures = 0
for relpath in files:
for lineno, target, resolved in check_file(relpath):
failures += 1
print(f"{relpath}:{lineno}: broken link: {target} (resolved: {resolved})")
if failures:
print(f"\n{failures} broken relative link(s) in {len(files)} file(s).")
return 1
print(f"OK: {len(files)} markdown file(s) checked, no broken relative links.")
return 0
if __name__ == "__main__":
sys.exit(main())
|