Add a docs README and link checker
Full build / Import + headless tests (push) Successful in 39s
Full build / Full desktop build + optional release (push) Skipped

Add tools/check_docs.py to require READMEs only in tracked docs directories,
validate local links and verify every tracked image is linked. Gitignored
generated files such as realism runs are exempt. Document the command and add
unit tests. Python 97/97.
This commit is contained in:
zwitschi committed 2026-11-02 10:52:15 +01:00
1 parent 7401d4bca0
commit 49bfccc808
4 files changed
+170

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Validate README coverage and local links in the tracked docs tree.
Only directories containing tracked files are required to have a README, so
generated/gitignored folders such as docs/screens/realism are exempt.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import unquote
ROOT = Path(__file__).resolve().parent.parent
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
HTML_SRC = re.compile(r"""(?:src|href)=["']([^"']+)["']""")
def _git_tracked(root: Path) -> list[Path]:
"""Return repository-relative tracked paths (fallback: all docs files)."""
try:
out = subprocess.check_output(
["git", "-C", str(root), "ls-files", "-z", "docs"],
stderr=subprocess.DEVNULL,
)
except (OSError, subprocess.CalledProcessError):
return [p.relative_to(root) for p in (root / "docs").rglob("*") if p.is_file()]
return [Path(item.decode()) for item in out.split(b"\0") if item]
def _is_ignored(root: Path, target: Path) -> bool:
"""True for local generated files excluded from the repository (e.g. realism/previews)."""
try:
rel = target.resolve().relative_to(root.resolve())
except ValueError:
return False
try:
return subprocess.run(
["git", "-C", str(root), "check-ignore", "-q", "--", str(rel)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
).returncode == 0
except OSError:
return False
def _local_target(readme: Path, target: str) -> Path | None:
target = unquote(target.split("#", 1)[0].strip())
if not target or "://" in target or target.startswith(("mailto:", "tel:")):
return None
return (readme.parent / target).resolve()
def check_docs(root: Path = ROOT, tracked: list[Path] | None = None) -> list[str]:
"""Return validation errors; an empty list means the docs tree is valid."""
root = root.resolve()
docs = root / "docs"
errors: list[str] = []
if not docs.is_dir():
return [f"missing docs directory: {docs}"]
files = tracked if tracked is not None else _git_tracked(root)
tracked_files = {root / p for p in files if str(p).startswith("docs/") or str(p) == "docs"}
tracked_dirs = {p.parent for p in tracked_files if p.parent != docs}
tracked_dirs.add(docs)
readmes: dict[Path, str] = {}
for directory in sorted(tracked_dirs):
readme = directory / "README.md"
if not readme.is_file():
errors.append(f"missing README.md in tracked docs directory: {directory.relative_to(root)}")
continue
text = readme.read_text(encoding="utf-8")
readmes[directory] = text
linked_names = set()
for pattern in (MARKDOWN_LINK, HTML_SRC):
for match in pattern.finditer(text):
target = _local_target(readme, match.group(1))
if target is None:
continue
linked_names.add(target.name)
if not target.exists() and not _is_ignored(root, target):
errors.append(f"{readme.relative_to(root)}: broken link {match.group(1)}")
# Every tracked image in the folder should be visible from its README.
for image in sorted(tracked_files):
if image.parent == directory and image.suffix.lower() in IMAGE_EXTS \
and image.name not in linked_names:
errors.append(f"{readme.relative_to(root)}: image not linked: {image.name}")
return errors
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=ROOT, help="repository root")
args = parser.parse_args(argv)
errors = check_docs(args.root)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
print(f"check_docs: {len(errors)} error(s)", file=sys.stderr)
return 1
print("check_docs: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())