|
| 1 | +"""Validate required documentation and local Markdown links.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import re |
| 6 | +import sys |
| 7 | +from pathlib import Path |
| 8 | +from urllib.parse import unquote |
| 9 | + |
| 10 | +ROOT = Path.cwd().resolve() |
| 11 | +REQUIRED = ( |
| 12 | + Path("README.md"), |
| 13 | + Path("SECURITY.md"), |
| 14 | + Path("CONTRIBUTING.md"), |
| 15 | + Path("docs/seo-metadata.md"), |
| 16 | +) |
| 17 | +LINK_PATTERN = re.compile(r"!?[[^]]*](([^)]+))") |
| 18 | + |
| 19 | + |
| 20 | +def link_target(raw: str) -> str: |
| 21 | + value = raw.strip() |
| 22 | + if value.startswith("<") and ">" in value: |
| 23 | + value = value[1 : value.index(">")] |
| 24 | + else: |
| 25 | + value = value.split(maxsplit=1)[0] |
| 26 | + return unquote(value.split("#", 1)[0]) |
| 27 | + |
| 28 | + |
| 29 | +def main() -> int: |
| 30 | + errors: list[str] = [] |
| 31 | + |
| 32 | + for relative in REQUIRED: |
| 33 | + path = ROOT / relative |
| 34 | + if not path.is_file(): |
| 35 | + errors.append(f"missing required file: {relative}") |
| 36 | + |
| 37 | + markdown_files = sorted( |
| 38 | + path for path in ROOT.rglob("*.md") if ".git" not in path.parts |
| 39 | + ) |
| 40 | + if not markdown_files: |
| 41 | + errors.append("no Markdown files found") |
| 42 | + |
| 43 | + for path in markdown_files: |
| 44 | + relative = path.relative_to(ROOT) |
| 45 | + text = path.read_text(encoding="utf-8") |
| 46 | + |
| 47 | + if not text.strip(): |
| 48 | + errors.append(f"empty Markdown file: {relative}") |
| 49 | + continue |
| 50 | + |
| 51 | + for line_number, line in enumerate(text.splitlines(), start=1): |
| 52 | + for match in LINK_PATTERN.finditer(line): |
| 53 | + raw = match.group(1).strip() |
| 54 | + if raw.startswith(("http://", "https://", "mailto:", "tel:", "#")): |
| 55 | + continue |
| 56 | + |
| 57 | + target = link_target(raw) |
| 58 | + if not target: |
| 59 | + continue |
| 60 | + |
| 61 | + resolved = (path.parent / target).resolve() |
| 62 | + try: |
| 63 | + resolved.relative_to(ROOT) |
| 64 | + except ValueError: |
| 65 | + errors.append( |
| 66 | + f"{relative}:{line_number}: link escapes repository: {raw}" |
| 67 | + ) |
| 68 | + continue |
| 69 | + |
| 70 | + if not resolved.exists(): |
| 71 | + errors.append( |
| 72 | + f"{relative}:{line_number}: broken relative link: {raw}" |
| 73 | + ) |
| 74 | + |
| 75 | + if errors: |
| 76 | + print("Documentation checks failed:") |
| 77 | + for error in errors: |
| 78 | + print(f"- {error}") |
| 79 | + return 1 |
| 80 | + |
| 81 | + print( |
| 82 | + f"Documentation checks passed for {len(markdown_files)} Markdown files." |
| 83 | + ) |
| 84 | + return 0 |
| 85 | + |
| 86 | + |
| 87 | +if __name__ == "__main__": |
| 88 | + sys.exit(main()) |
0 commit comments