init
This commit is contained in:
commit
74718f7fb5
7 changed files with 1176 additions and 0 deletions
87
tools/check-links.py
Normal file
87
tools/check-links.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue