0.0.2-alpha.4
This commit is contained in:
131
scripts/audit_markdown_links.py
Normal file
131
scripts/audit_markdown_links.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/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", "builds"})
|
||||
_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())
|
||||
Reference in New Issue
Block a user