#!/usr/bin/env python3 # Author: cirakg & claude ai # Summary: Offline checker for broken relative links and heading anchors in the Markdown docs. """Check the project's Markdown files for broken internal links. What it checks, offline (`make docs` runs without network): * relative links to files: the target must exist in the repository; * anchors (`#section`), in the same file or another Markdown file: a heading with that GitHub-style anchor must exist. External links (http, https, mailto) are not fetched; the network is off. Usage: check_docs.py [ROOT] (ROOT defaults to the current directory) Exit: 0 when every link resolves, 1 otherwise. Each problem is printed as `file:line: message`. """ from __future__ import annotations import re import sys from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path # Folders that hold generated or third-party files, never project docs. SKIP_DIRS = {".git", ".cache", "build", "node_modules"} # [text](target) or [text](target "title"); the target may be wrapped in <...>. LINK_RE = re.compile(r"\[(?:[^\]\\]|\\.)*\]\(\s*(<[^>]*>|[^)\s]+)(?:\s+\"[^\"]*\")?\s*\)") HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") FENCE_RE = re.compile(r"^\s*(```|~~~)") INLINE_CODE_RE = re.compile(r"`[^`]*`") EXTERNAL_PREFIXES = ("http://", "https://", "mailto:") @dataclass(frozen=True) class Problem: """One broken link, reported as `file:line: message`.""" path: Path line: int message: str def __str__(self) -> str: return f"{self.path}:{self.line}: {self.message}" def slugify(heading: str) -> str: """Return the anchor GitHub generates for a heading. GitHub lowercases the text, drops everything except letters, digits, spaces, hyphens and underscores, then turns spaces into hyphens. Inline markup is removed first: `code` keeps its text, [text](url) keeps `text`. """ text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", heading) text = text.replace("`", "").replace("*", "").strip().lower() text = re.sub(r"[^\w\- ]", "", text) return text.replace(" ", "-") def iter_lines_outside_code(text: str) -> Iterator[tuple[int, str]]: """Yield (line number, line) for lines outside fenced code blocks.""" in_fence = False for number, line in enumerate(text.splitlines(), start=1): if FENCE_RE.match(line): in_fence = not in_fence continue if not in_fence: yield number, line def anchors_of(path: Path) -> set[str]: """All heading anchors of a Markdown file, with GitHub's -1, -2 suffixes for repeated headings.""" seen: dict[str, int] = {} anchors: set[str] = set() for _, line in iter_lines_outside_code(path.read_text(encoding="utf-8")): match = HEADING_RE.match(line) if not match: continue slug = slugify(match.group(2)) count = seen.get(slug, 0) anchors.add(slug if count == 0 else f"{slug}-{count}") seen[slug] = count + 1 return anchors def markdown_files(root: Path) -> list[Path]: """Every Markdown file of the project, skipping generated folders.""" return sorted(p for p in root.rglob("*.md") if not any(part in SKIP_DIRS for part in p.relative_to(root).parts)) def check_file(path: Path, root: Path, cache: dict[Path, set[str]]) -> list[Problem]: """Check every link of one Markdown file.""" problems: list[Problem] = [] rel = path.relative_to(root) for number, line in iter_lines_outside_code(path.read_text(encoding="utf-8")): for match in LINK_RE.finditer(INLINE_CODE_RE.sub("", line)): target = match.group(1).strip("<>") if target.startswith(EXTERNAL_PREFIXES): continue file_part, _, anchor = target.partition("#") dest = path if not file_part else (path.parent / file_part).resolve() if not dest.exists(): problems.append(Problem(rel, number, f"missing file '{file_part}'")) continue if anchor and dest.suffix == ".md": if dest not in cache: cache[dest] = anchors_of(dest) if anchor not in cache[dest]: problems.append(Problem(rel, number, f"no heading for anchor '#{anchor}' in {dest.name}")) return problems def main(argv: list[str]) -> int: root = Path(argv[1] if len(argv) > 1 else ".").resolve() cache: dict[Path, set[str]] = {} files = markdown_files(root) problems = [p for f in files for p in check_file(f, root, cache)] for problem in problems: print(problem) print(f"check_docs: {len(files)} Markdown files, {len(problems)} broken links") return 1 if problems else 0 if __name__ == "__main__": sys.exit(main(sys.argv))