88 lines
2.7 KiB
Python
88 lines
2.7 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Verify every relative link, anchor and image path in a folder of Markdown.
|
||
|
|
|
||
|
|
Catches the two things that break most often after a transcription pass:
|
||
|
|
image paths that point at figures which were never extracted, and TOC anchors
|
||
|
|
that no longer match a heading because the heading was reworded.
|
||
|
|
|
||
|
|
Anchor slugs follow GitHub's rules: lowercase, strip punctuation except
|
||
|
|
hyphens/underscores, spaces to hyphens.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
check-links.py [DIR] # defaults to ./chapters
|
||
|
|
|
||
|
|
Exits non-zero if anything is broken, so it can gate a commit.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
LINK = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||
|
|
HEADING = re.compile(r"^#{1,6} +(.*)$")
|
||
|
|
|
||
|
|
|
||
|
|
def anchor(heading: str) -> str:
|
||
|
|
s = heading.strip().lower().replace("`", "")
|
||
|
|
s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE)
|
||
|
|
return re.sub(r"\s+", "-", s.strip())
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
root = Path(sys.argv[1] if len(sys.argv) > 1 else "chapters")
|
||
|
|
if not root.is_dir():
|
||
|
|
print(f"not a directory: {root}")
|
||
|
|
return 2
|
||
|
|
|
||
|
|
md_files = sorted(root.glob("*.md"))
|
||
|
|
anchors: dict[str, set[str]] = {}
|
||
|
|
for f in md_files:
|
||
|
|
seen: dict[str, int] = {}
|
||
|
|
slugs: set[str] = set()
|
||
|
|
for line in f.read_text().splitlines():
|
||
|
|
m = HEADING.match(line)
|
||
|
|
if not m:
|
||
|
|
continue
|
||
|
|
a = anchor(m.group(1))
|
||
|
|
n = seen.get(a, 0)
|
||
|
|
seen[a] = n + 1
|
||
|
|
# GitHub disambiguates repeated headings by appending -1, -2, ...
|
||
|
|
slugs.add(a if n == 0 else f"{a}-{n}")
|
||
|
|
anchors[f.name] = slugs
|
||
|
|
|
||
|
|
bad = 0
|
||
|
|
linked_images: set[Path] = set()
|
||
|
|
|
||
|
|
for f in md_files:
|
||
|
|
for _text, target in LINK.findall(f.read_text()):
|
||
|
|
if target.startswith(("http://", "https://", "mailto:", "#")):
|
||
|
|
continue
|
||
|
|
rel, _, frag = target.partition("#")
|
||
|
|
if not rel:
|
||
|
|
continue
|
||
|
|
resolved = (f.parent / rel).resolve()
|
||
|
|
if not resolved.exists():
|
||
|
|
print(f"{f.name}: MISSING FILE {target}")
|
||
|
|
bad += 1
|
||
|
|
continue
|
||
|
|
if resolved.suffix.lower() == ".png":
|
||
|
|
linked_images.add(resolved)
|
||
|
|
if frag and resolved.suffix == ".md":
|
||
|
|
if frag not in anchors.get(resolved.name, set()):
|
||
|
|
print(f"{f.name}: MISSING ANCHOR #{frag} in {rel}")
|
||
|
|
bad += 1
|
||
|
|
|
||
|
|
for img in sorted(root.rglob("*.png")):
|
||
|
|
if img.resolve() not in linked_images:
|
||
|
|
print(f"ORPHAN IMAGE: {img.relative_to(root)}")
|
||
|
|
bad += 1
|
||
|
|
|
||
|
|
print(f"\n{len(md_files)} file(s) checked, {bad} problem(s)")
|
||
|
|
return 1 if bad else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|