-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathcheck_project_rules.py
More file actions
139 lines (116 loc) · 4.01 KB
/
Copy pathcheck_project_rules.py
File metadata and controls
139 lines (116 loc) · 4.01 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
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
#!/usr/bin/env python3
"""Lightweight Markdown project checks for book repositories."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
ROOT = Path(__file__).resolve().parent
SKIP_DIRS = {
".agent",
".git",
".mdpress",
"_book",
"_site",
"dist",
"node_modules",
}
LINK_RE = re.compile(r"(!?)\[[^\]]*\]\(([^)\s]+(?:\s+\"[^\"]*\")?)\)")
FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
def iter_markdown_files() -> list[Path]:
files: list[Path] = []
for path in ROOT.rglob("*.md"):
if any(part in SKIP_DIRS for part in path.relative_to(ROOT).parts):
continue
files.append(path)
return sorted(files)
def strip_fenced_blocks(text: str) -> str:
output: list[str] = []
in_fence = False
fence_marker = ""
fence_len = 0
for line in text.splitlines():
match = FENCE_RE.match(line)
if match:
marker = match.group(1)
char = marker[0]
length = len(marker)
if not in_fence:
in_fence = True
fence_marker = char
fence_len = length
elif char == fence_marker and length >= fence_len:
in_fence = False
output.append("")
continue
output.append("" if in_fence else line)
return "\n".join(output)
def check_fences(path: Path, text: str) -> list[str]:
issues: list[str] = []
stack: list[tuple[str, int, int]] = []
for line_no, line in enumerate(text.splitlines(), 1):
match = FENCE_RE.match(line)
if not match:
continue
marker = match.group(1)
char = marker[0]
length = len(marker)
if not stack:
stack.append((char, length, line_no))
continue
open_char, open_len, _ = stack[-1]
if char == open_char and length >= open_len:
stack.pop()
else:
stack.append((char, length, line_no))
for _, _, line_no in stack:
issues.append(f"{path.relative_to(ROOT)}:{line_no}: unclosed fenced code block")
return issues
def is_local_target(target: str) -> bool:
parsed = urlparse(target)
return not parsed.scheme and not parsed.netloc and not target.startswith("#")
def normalize_target(raw_target: str) -> str:
target = raw_target.strip()
if " " in target and target.count('"') >= 2:
target = target.split(" ", 1)[0]
return unquote(target.split("#", 1)[0])
def check_links(path: Path, text: str) -> list[str]:
issues: list[str] = []
body = strip_fenced_blocks(text)
for match in LINK_RE.finditer(body):
raw_target = match.group(2).strip()
target = normalize_target(raw_target)
if not target or not is_local_target(raw_target):
continue
target_path = (path.parent / target).resolve()
try:
target_path.relative_to(ROOT)
except ValueError:
continue
if not target_path.exists():
line_no = body[: match.start()].count("\n") + 1
issues.append(
f"{path.relative_to(ROOT)}:{line_no}: missing local link target: {raw_target}"
)
return issues
def check_summary_links() -> list[str]:
summary = ROOT / "SUMMARY.md"
if not summary.exists():
return []
return check_links(summary, summary.read_text(encoding="utf-8", errors="ignore"))
def main() -> int:
issues: list[str] = []
files = iter_markdown_files()
for path in files:
text = path.read_text(encoding="utf-8", errors="ignore")
issues.extend(check_fences(path, text))
issues.extend(check_links(path, text))
issues.extend(check_summary_links())
if issues:
print("\n".join(sorted(set(issues))))
print(f"\n{len(set(issues))} issue(s) found across {len(files)} Markdown files.")
return 1
print(f"All {len(files)} Markdown files passed project checks.")
return 0
if __name__ == "__main__":
sys.exit(main())