Add a docs README and link checker
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:
1 parent
7401d4bca0
commit
49bfccc808
4 files changed
+170
No files matched your search
@@ -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())
|
||||
@@ -0,0 +1,52 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import check_docs
|
||||
|
||||
|
||||
class CheckDocsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def _file(self, rel, text=""):
|
||||
path = self.root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_accepts_tracked_directory_with_links_and_images(self):
|
||||
self._file("docs/README.md", "[x](guide.md)")
|
||||
self._file("docs/guide.md", "guide")
|
||||
self._file("docs/images/preview.png", "png")
|
||||
self._file("docs/images/README.md", "")
|
||||
tracked = [Path("docs/README.md"), Path("docs/guide.md"),
|
||||
Path("docs/images/preview.png"), Path("docs/images/README.md")]
|
||||
self.assertEqual(check_docs.check_docs(self.root, tracked), [])
|
||||
|
||||
def test_requires_readme_only_in_tracked_dirs(self):
|
||||
self._file("docs/README.md")
|
||||
self._file("docs/ignored/file.txt")
|
||||
tracked = [Path("docs/README.md"), Path("docs/ignored/file.txt")]
|
||||
errors = check_docs.check_docs(self.root, tracked)
|
||||
self.assertTrue(any("docs/ignored" in e for e in errors))
|
||||
# A directory with no tracked files is not required to have a README.
|
||||
self._file("docs/generated/file.txt")
|
||||
tracked = [Path("docs/README.md")]
|
||||
self.assertEqual(check_docs.check_docs(self.root, tracked), [])
|
||||
|
||||
def test_reports_broken_link_and_unlinked_image(self):
|
||||
self._file("docs/README.md", "[broken](missing.md)")
|
||||
self._file("docs/image.png", "png")
|
||||
tracked = [Path("docs/README.md"), Path("docs/image.png")]
|
||||
errors = check_docs.check_docs(self.root, tracked)
|
||||
self.assertTrue(any("broken link" in e for e in errors))
|
||||
self.assertTrue(any("image not linked" in e for e in errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user