192 lines
5.6 KiB
Python
192 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Iterator, Optional, Sequence
|
|
|
|
|
|
EXCLUDED_DIRECTORIES = {".git", "build", "dist", "node_modules", "tmp"}
|
|
FENCE_PATTERN = re.compile(r"^\s*(`{3,}|~{3,})")
|
|
INLINE_CODE_PATTERN = re.compile(r"`[^`]*`")
|
|
INLINE_LINK_PATTERN = re.compile(r"!?\[[^]]*\]\(([^)]*)\)")
|
|
REFERENCE_LINK_PATTERN = re.compile(
|
|
r"^\s*\[[^]]+\]:\s*(?:<([^>]+)>|([^\s]+))"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MarkdownLink:
|
|
line: int
|
|
target: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BrokenLink:
|
|
source: Path
|
|
line: int
|
|
target: str
|
|
resolved_target: Path
|
|
|
|
|
|
@dataclass
|
|
class LinkCheckResult:
|
|
markdown_files: int = 0
|
|
total_links: int = 0
|
|
valid_links: int = 0
|
|
skipped_links: int = 0
|
|
broken_links: list[BrokenLink] = field(default_factory=list)
|
|
|
|
|
|
def extract_links(text: str) -> Iterator[MarkdownLink]:
|
|
fence_character: Optional[str] = None
|
|
|
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
fence_match = FENCE_PATTERN.match(raw_line)
|
|
if fence_match:
|
|
marker_character = fence_match.group(1)[0]
|
|
if fence_character is None:
|
|
fence_character = marker_character
|
|
elif marker_character == fence_character:
|
|
fence_character = None
|
|
continue
|
|
if fence_character is not None:
|
|
continue
|
|
|
|
line = INLINE_CODE_PATTERN.sub("", raw_line)
|
|
for match in INLINE_LINK_PATTERN.finditer(line):
|
|
yield MarkdownLink(line_number, match.group(1).strip())
|
|
|
|
reference_match = REFERENCE_LINK_PATTERN.match(line)
|
|
if reference_match:
|
|
target = reference_match.group(1) or reference_match.group(2)
|
|
yield MarkdownLink(line_number, target.strip())
|
|
|
|
|
|
def iter_markdown_files(root: Path) -> Iterator[Path]:
|
|
for path in sorted(root.rglob("*.md")):
|
|
relative_path = path.relative_to(root)
|
|
if path.name.endswith(".template.md"):
|
|
continue
|
|
if any(part in EXCLUDED_DIRECTORIES for part in relative_path.parts[:-1]):
|
|
continue
|
|
if path.is_file():
|
|
yield path
|
|
|
|
|
|
def _local_target(target: str) -> Optional[str]:
|
|
target = target.strip()
|
|
if target.startswith("<") and target.endswith(">"):
|
|
target = target[1:-1].strip()
|
|
|
|
target = target.split("#", 1)[0]
|
|
if not target:
|
|
return None
|
|
|
|
lower_target = target.lower()
|
|
if lower_target.startswith(("http://", "https://", "mailto:")):
|
|
return None
|
|
if target.startswith("/en/docs/"):
|
|
return None
|
|
return target
|
|
|
|
|
|
def _resolve_target(root: Path, source: Path, target: str) -> Path:
|
|
if target.startswith("/"):
|
|
return (root / target.lstrip("/")).resolve()
|
|
return (source.parent / target).resolve()
|
|
|
|
|
|
def check_repository(root: Path) -> LinkCheckResult:
|
|
root = root.resolve()
|
|
markdown_files = list(iter_markdown_files(root))
|
|
result = LinkCheckResult(markdown_files=len(markdown_files))
|
|
|
|
for source in markdown_files:
|
|
text = source.read_text(encoding="utf-8")
|
|
for link in extract_links(text):
|
|
result.total_links += 1
|
|
local_target = _local_target(link.target)
|
|
if local_target is None:
|
|
result.skipped_links += 1
|
|
continue
|
|
|
|
resolved_target = _resolve_target(root, source, local_target)
|
|
if resolved_target.exists():
|
|
result.valid_links += 1
|
|
continue
|
|
|
|
result.broken_links.append(
|
|
BrokenLink(source, link.line, link.target, resolved_target)
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _display_path(path: Path, root: Path) -> str:
|
|
try:
|
|
return path.relative_to(root).as_posix()
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def print_report(result: LinkCheckResult, root: Path) -> None:
|
|
root = root.resolve()
|
|
print(f"Playbook root: {root}")
|
|
print(f"Markdown files: {result.markdown_files}")
|
|
|
|
for broken in result.broken_links:
|
|
source = _display_path(broken.source, root)
|
|
target = _display_path(broken.resolved_target, root)
|
|
print(f"BROKEN: {source}:{broken.line}")
|
|
print(f" Link: {broken.target}")
|
|
print(f" Target: {target}")
|
|
|
|
print(f"Total links: {result.total_links}")
|
|
print(f"Valid links: {result.valid_links}")
|
|
print(f"Skipped links: {result.skipped_links}")
|
|
print(f"Broken links: {len(result.broken_links)}")
|
|
|
|
|
|
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Check local links in Markdown files.")
|
|
parser.add_argument(
|
|
"--root",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parents[2],
|
|
help="repository root to scan (default: inferred from this script)",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _configure_output() -> None:
|
|
for stream in (sys.stdout, sys.stderr):
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if reconfigure is not None:
|
|
reconfigure(errors="backslashreplace")
|
|
|
|
|
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
_configure_output()
|
|
args = parse_args(argv)
|
|
root = args.root.resolve()
|
|
if not root.is_dir():
|
|
print(f"ERROR: root directory does not exist: {root}")
|
|
return 2
|
|
|
|
result = check_repository(root)
|
|
print_report(result, root)
|
|
if result.broken_links:
|
|
print("Document link check failed.")
|
|
return 1
|
|
|
|
print("All document links passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|