mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
75 lines
2.4 KiB
Python
75 lines
2.4 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Check that every link between the markdown files goes somewhere.
|
||
|
|
|
||
|
|
Documentation that links to a page that has been renamed is worse than
|
||
|
|
documentation that does not link at all, because the reader trusts it. Run
|
||
|
|
from the repository root:
|
||
|
|
|
||
|
|
python3 tools/check-links.py
|
||
|
|
"""
|
||
|
|
|
||
|
|
import pathlib
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
|
||
|
|
LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||
|
|
|
||
|
|
|
||
|
|
def prose(text: str) -> str:
|
||
|
|
"""The document with its fenced code blocks taken out.
|
||
|
|
|
||
|
|
An example that happens to contain a link is an example, not a link.
|
||
|
|
"""
|
||
|
|
out, fenced = [], False
|
||
|
|
for line in text.split("\n"):
|
||
|
|
if line.startswith("```"):
|
||
|
|
fenced = not fenced
|
||
|
|
continue
|
||
|
|
if not fenced:
|
||
|
|
out.append(line)
|
||
|
|
return "\n".join(out)
|
||
|
|
|
||
|
|
|
||
|
|
def anchors(text: str) -> set[str]:
|
||
|
|
"""The headings of a document, as the fragments that reach them."""
|
||
|
|
found = set()
|
||
|
|
for line in prose(text).split("\n"):
|
||
|
|
if line.startswith("#"):
|
||
|
|
title = line.lstrip("#").strip().lower()
|
||
|
|
found.add(re.sub(r"[^a-z0-9 -]", "", title).replace(" ", "-"))
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
root = pathlib.Path(".").resolve()
|
||
|
|
# Fixtures are not documentation: a parity case is deliberately malformed
|
||
|
|
# markdown, and a link in one is part of what is being tested.
|
||
|
|
skip = {"target", "cases", "golden"}
|
||
|
|
files = sorted(
|
||
|
|
path
|
||
|
|
for path in root.rglob("*.md")
|
||
|
|
if skip.isdisjoint(path.parts) and not path.name.startswith(".")
|
||
|
|
)
|
||
|
|
headings = {path: anchors(path.read_text(encoding="utf-8")) for path in files}
|
||
|
|
|
||
|
|
broken = []
|
||
|
|
for path in files:
|
||
|
|
for target in LINK.findall(prose(path.read_text(encoding="utf-8"))):
|
||
|
|
if target.startswith(("http://", "https://", "mailto:")):
|
||
|
|
continue
|
||
|
|
where, _, fragment = target.partition("#")
|
||
|
|
dest = (path.parent / where).resolve() if where else path
|
||
|
|
if not dest.exists():
|
||
|
|
broken.append(f"{path.relative_to(root)} -> {target}: no such file")
|
||
|
|
elif fragment and dest in headings and fragment not in headings[dest]:
|
||
|
|
broken.append(f"{path.relative_to(root)} -> {target}: no such heading")
|
||
|
|
|
||
|
|
for line in broken:
|
||
|
|
print(f"broken link: {line}", file=sys.stderr)
|
||
|
|
print(f"{len(files)} files, {len(broken)} broken links")
|
||
|
|
return 1 if broken else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|