aboutsummaryrefslogtreecommitdiff
path: root/tools/check_docs.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/check_docs.py')
-rw-r--r--tools/check_docs.py125
1 files changed, 125 insertions, 0 deletions
diff --git a/tools/check_docs.py b/tools/check_docs.py
new file mode 100644
index 0000000..977bd11
--- /dev/null
+++ b/tools/check_docs.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""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))