Files
2026-07-13 12:10:23 +08:00

76 lines
2.2 KiB
Python

#!/usr/bin/env python3
"""Check that relative markdown links point to files that exist.
Scans every tracked *.md file in the repo and validates relative link targets
(external URLs, anchors and mailto links are skipped; code blocks and inline
code spans are ignored). Exits 1 if any broken link is found.
Run locally: python3 scripts/check_md_links.py
"""
import os
import re
import subprocess
import sys
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL)
INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
SKIP_PREFIXES = ("http://", "https://", "mailto:", "#", "<")
def repo_root() -> str:
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def tracked_markdown_files(root: str) -> list[str]:
out = subprocess.check_output(["git", "ls-files", "*.md"], text=True, cwd=root)
return [line for line in out.splitlines() if line]
def main() -> int:
root = repo_root()
broken: list[str] = []
for rel in tracked_markdown_files(root):
path = os.path.join(root, rel)
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except OSError:
continue
text = FENCED_CODE_RE.sub("", text)
text = INLINE_CODE_RE.sub("", text)
for match in LINK_RE.finditer(text):
target = match.group(1)
if target.startswith(SKIP_PREFIXES):
continue
file_part = target.split("#")[0].split("?")[0]
if not file_part:
continue
if file_part.startswith("/"):
resolved = os.path.join(root, file_part.lstrip("/"))
else:
resolved = os.path.normpath(
os.path.join(os.path.dirname(path), file_part)
)
if not os.path.exists(resolved):
broken.append(f"{rel}: {target}")
if broken:
print(f"{len(broken)} broken relative link(s):")
for entry in broken:
print(f" {entry}")
return 1
print("All relative markdown links resolve.")
return 0
if __name__ == "__main__":
sys.exit(main())