|
| 1 | +"""Build nav.yml from docs/.pages files recursively.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import sys |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +import yaml |
| 9 | + |
| 10 | +DOCS_DIR = Path("docs") |
| 11 | + |
| 12 | + |
| 13 | +def read_pages(directory: Path) -> dict | None: |
| 14 | + pf = directory / ".pages" |
| 15 | + if not pf.exists(): |
| 16 | + return None |
| 17 | + text = pf.read_text(encoding="utf-8").strip() |
| 18 | + return yaml.safe_load(text) if text else None |
| 19 | + |
| 20 | + |
| 21 | +def expand_dir(title: str | None, abs_dir: Path, docs_rel: Path) -> object: |
| 22 | + """Expand a directory into nav items using its .pages file if present.""" |
| 23 | + pages = read_pages(abs_dir) |
| 24 | + if pages and "nav" in pages and pages["nav"]: |
| 25 | + children = build_nav_list(pages["nav"], abs_dir, docs_rel) |
| 26 | + return {title: children} if title is not None else children |
| 27 | + # No nav — emit a bare directory reference (zensical auto-discovers) |
| 28 | + path_str = str(docs_rel).replace("\\", "/") |
| 29 | + return {title: path_str} if title is not None else path_str |
| 30 | + |
| 31 | + |
| 32 | +def expand_item(item: object, abs_base: Path, docs_rel_base: Path) -> object: |
| 33 | + """Convert one .pages nav entry to MkDocs nav format.""" |
| 34 | + if isinstance(item, str): |
| 35 | + # Plain path, no title |
| 36 | + abs_path = abs_base / item |
| 37 | + docs_rel = docs_rel_base / item |
| 38 | + if abs_path.is_dir(): |
| 39 | + # Inherit title from sub-directory's .pages `title:` key if present |
| 40 | + sub = read_pages(abs_path) |
| 41 | + inherited_title = sub.get("title") if sub else None |
| 42 | + return expand_dir(inherited_title, abs_path, docs_rel) |
| 43 | + return str(docs_rel).replace("\\", "/") |
| 44 | + |
| 45 | + if isinstance(item, dict): |
| 46 | + assert len(item) == 1, f"Expected single-key dict, got: {item}" |
| 47 | + title, value = next(iter(item.items())) |
| 48 | + |
| 49 | + if isinstance(value, list): |
| 50 | + # Inline section: "Title: [children]" |
| 51 | + children = [] |
| 52 | + for child in value: |
| 53 | + resolved = expand_item(child, abs_base, docs_rel_base) |
| 54 | + if resolved is not None: |
| 55 | + if isinstance(resolved, list): |
| 56 | + children.extend(resolved) |
| 57 | + else: |
| 58 | + children.append(resolved) |
| 59 | + return {title: children} |
| 60 | + |
| 61 | + if isinstance(value, str): |
| 62 | + abs_path = abs_base / value |
| 63 | + docs_rel = docs_rel_base / value |
| 64 | + if abs_path.is_dir(): |
| 65 | + return expand_dir(title, abs_path, docs_rel) |
| 66 | + return {title: str(docs_rel).replace("\\", "/")} |
| 67 | + |
| 68 | + return None |
| 69 | + |
| 70 | + |
| 71 | +def build_nav_list(nav: list, abs_dir: Path, docs_rel: Path) -> list: |
| 72 | + result = [] |
| 73 | + for item in nav: |
| 74 | + resolved = expand_item(item, abs_dir, docs_rel) |
| 75 | + if resolved is None: |
| 76 | + continue |
| 77 | + if isinstance(resolved, list): |
| 78 | + result.extend(resolved) |
| 79 | + else: |
| 80 | + result.append(resolved) |
| 81 | + return result |
| 82 | + |
| 83 | + |
| 84 | +def main() -> None: |
| 85 | + pages = read_pages(DOCS_DIR) |
| 86 | + if not pages or "nav" not in pages: |
| 87 | + print("ERROR: docs/.pages missing or has no nav: block", file=sys.stderr) |
| 88 | + sys.exit(1) |
| 89 | + |
| 90 | + nav = build_nav_list(pages["nav"], DOCS_DIR, Path("")) |
| 91 | + |
| 92 | + # Dump with a custom representer that keeps strings unquoted where safe |
| 93 | + # and preserves unicode (e.g. in titles) |
| 94 | + output = yaml.dump( |
| 95 | + {"nav": nav}, |
| 96 | + default_flow_style=False, |
| 97 | + allow_unicode=True, |
| 98 | + width=120, |
| 99 | + indent=2, |
| 100 | + ) |
| 101 | + print(output, end="") |
| 102 | + |
| 103 | + |
| 104 | +if __name__ == "__main__": |
| 105 | + main() |
0 commit comments