markdowned-datasheets/tools/check-links.py

88 lines
2.7 KiB
Python
Raw Normal View History

2026-08-22 15:33:55 +02:00
#!/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())