#!/usr/bin/env python3 """Read SUMMARY.md as the only chapter ordering source.""" from __future__ import annotations import argparse import os import re import sys from pathlib import Path LINK_RE = re.compile(r"^\s*-\s+\[[^]]+\]\(([^)]+\.md)\)\s*$") def default_book_dir() -> Path: env = os.environ.get("BOOK_DIR") if env: return Path(env) return Path.cwd() def chapter_paths(book_dir: Path) -> list[Path]: summary = book_dir / "SUMMARY.md" paths: list[Path] = [] seen: set[Path] = set() for line_number, line in enumerate(summary.read_text(encoding="utf-8").splitlines(), 1): match = LINK_RE.match(line) if not match: continue relative = Path(match.group(1)) if relative.is_absolute() or ".." in relative.parts: raise ValueError(f"SUMMARY.md:{line_number}: chapter path must stay inside the book: {relative}") path = (book_dir / relative).resolve() if path in seen: raise ValueError(f"SUMMARY.md:{line_number}: duplicate chapter: {relative}") if not path.is_file(): raise ValueError(f"SUMMARY.md:{line_number}: missing chapter: {relative}") seen.add(path) paths.append(path) if not paths: raise ValueError("SUMMARY.md contains no Markdown chapter links") return paths def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--book-dir", type=Path, default=default_book_dir()) parser.add_argument("--null", action="store_true", help="terminate paths with NUL") args = parser.parse_args() try: paths = chapter_paths(args.book_dir.resolve()) except (OSError, ValueError) as error: print(error, file=sys.stderr) return 1 separator = "\0" if args.null else "\n" sys.stdout.write(separator.join(str(path) for path in paths) + separator) return 0 if __name__ == "__main__": raise SystemExit(main())