#!/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())