#!/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())