Files
pgr/scripts/audit_markdown_links.py
2026-09-23 15:26:40 +02:00

132 lines
3.9 KiB
Python
Executable File

#!/usr/bin/env python3
# file: scripts/audit_markdown_links.py
# version: 1
"""Validate that local inline Markdown link targets exist."""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
import urllib.parse
_IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"})
_INLINE_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
def _selected_files(raw_paths: list[str]) -> tuple[list[pathlib.Path], list[str]]:
files: list[pathlib.Path] = []
errors: list[str] = []
for raw_path in raw_paths:
path = pathlib.Path(raw_path)
if path.is_dir():
files.extend(
candidate
for candidate in path.rglob("*.md")
if candidate.is_file() and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts)
)
elif path.is_file() and path.suffix.lower() == ".md":
files.append(path)
else:
errors.append(f"Markdown link audit: unsupported or missing path: {path}")
return sorted(set(files)), errors
def _destination(raw_destination: str) -> str:
destination = raw_destination.strip()
if destination.startswith("<"):
closing = destination.find(">")
return destination[1:closing] if closing > 0 else destination
return destination.split(maxsplit=1)[0] if destination else ""
def _local_target(destination: str) -> str | None:
if not destination or destination.startswith("#") or destination.startswith("//"):
return None
parsed = urllib.parse.urlsplit(destination)
if parsed.scheme or parsed.netloc:
return None
target = urllib.parse.unquote(parsed.path)
return target or None
def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]:
errors: list[str] = []
checked = 0
fence: str | None = None
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
marker = stripped[:3]
if fence is None:
fence = marker
elif fence == marker:
fence = None
continue
if fence is not None:
continue
for match in _INLINE_LINK.finditer(line):
destination = _destination(match.group(1))
target = _local_target(destination)
if target is None:
continue
checked += 1
if target.startswith("/"):
candidate = pathlib.Path.cwd() / target.lstrip("/")
else:
candidate = path.parent / target
if not candidate.exists():
errors.append(f"{path}:{line_number}: missing local Markdown target: {destination}")
return checked, errors
def main() -> int:
"""Run the read-only local Markdown link audit."""
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit")
arguments = parser.parse_args()
files, selection_errors = _selected_files(arguments.paths)
if selection_errors:
for error in selection_errors:
print(error, file=sys.stderr)
return 2
if not files:
print("Markdown link audit: no Markdown files selected", file=sys.stderr)
return 2
link_count = 0
errors: list[str] = []
for path in files:
current_count, current_errors = _audit_file(path)
link_count += current_count
errors.extend(current_errors)
if errors:
for error in errors:
print(error, file=sys.stderr)
print(f"Markdown link audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr)
return 1
print(f"Markdown link audit: clean ({link_count} local link(s), {len(files)} file(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())