#!/usr/bin/env python """Build hand-authored documentation folders from their markdown sources. Companion to collect.py. While collect.py pulls *finished* HTML and MCP bundles out of source repos, build.py compiles folders that keep their sources in this hub. Any top-level folder with ``src/mkdocs.*.yml`` gets, for each config: 1. a MkDocs HTML build into the target dir the config declares (e.g. ``standards/src/mkdocs.wiki.yml`` -> ``standards/wiki/``), and 2. a doc-forge-shaped MCP bundle under ``/mcp/`` (``index.json``, ``nav.json``, ``modules/*.json``), regenerated from the lib pages (``src/lib/*.md``) so the bundle mirrors the lib site. Each MkDocs config owns a dedicated site dir (declared via ``site_dir``), so builds never touch ``src/`` or any other kind's output. Usage: python build.py # build every folder with src/mkdocs.*.yml python build.py --name standards python build.py --dry-run """ from __future__ import annotations import argparse import json import re import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent NUM_PREFIX = re.compile(r"^\d+[_-]?") def find_src_dirs(root: Path = ROOT) -> list[Path]: """Return top-level folders containing ``src/mkdocs.*.yml``. Skips hidden directories, the mcp bundle container, and the generated portal index. """ found: list[Path] = [] for child in sorted(root.iterdir()): if not child.is_dir() or child.name.startswith("."): continue if child.name in ("mcp", "_index"): continue src = child / "src" if src.is_dir() and list(src.glob("mkdocs.*.yml")): found.append(child) return found def build_html(folder: Path, dry_run: bool = False) -> None: """Compile each ``src/mkdocs.*.yml`` config of ``folder`` into HTML. Each config declares its own ``docs_dir`` and ``site_dir``, mirroring doc-forge (``mkdocs.wiki.yml`` -> ``/wiki/``, ``mkdocs.lib.yml`` -> ``/lib/``). Args: folder (Path): The hub folder owning ``src/mkdocs.*.yml``. dry_run (bool): Print the plan without executing. """ name = folder.name for cfg in sorted((folder / "src").glob("mkdocs.*.yml")): site = cfg.name.removeprefix("mkdocs.").removesuffix(".yml") if dry_run: print(f"[BLD] {name} mkdocs build -f {cfg.relative_to(ROOT)} " f"(-> {name}/{site}/)") continue result = subprocess.run( [sys.executable, "-m", "mkdocs", "build", "-f", str(cfg)], capture_output=True, text=True, ) if result.returncode != 0: print(f"[ERR] {name} mkdocs failed:\n{result.stdout}\n{result.stderr}", file=sys.stderr) raise SystemExit(1) print(f"[BLD] {name} {site} site built into {folder.relative_to(ROOT)}/{site}/") # ── MCP bundle ─────────────────────────────────────────────────────────────── def _module_name(stem: str) -> str: """Map a markdown filename to a module name (strip the NN_ prefix).""" return NUM_PREFIX.sub("", stem) or stem def _read_title(path: Path) -> str: """Return the first markdown H1 title, or a prettified fallback.""" for line in path.read_text(encoding="utf-8").splitlines(): if line.startswith("# "): return line[2:].strip() return _module_name(path.stem).replace("_", " ").title() def build_bundle(folder: Path, dry_run: bool = False) -> None: """Regenerate ``/mcp`` from ``folder/src/lib`` markdown. The bundle mirrors the lib site (in the same way doc-forge's MCP output mirrors its lib documentation). Writes ``index.json``, ``nav.json`` and ``modules/*.json`` in the doc-forge MCP bundle shape. Hand-authored extras (e.g. ``libraries.json``) are left untouched. Args: folder (Path): The hub folder owning a ``src/lib``. dry_run (bool): Print the plan without executing. """ name = folder.name lib = folder / "src" / "lib" if not lib.is_dir(): return out_dir = folder / "mcp" modules_dir = out_dir / "modules" if dry_run: print(f"[MCP] {name} {lib.relative_to(ROOT)}/*.md " f"-> {out_dir.relative_to(ROOT)}/") return if modules_dir.exists(): shutil.rmtree(modules_dir) modules_dir.mkdir(parents=True, exist_ok=True) def write_module(module: str, body: str) -> None: payload = { "module": module, "content": { "path": module, "docstring": body.strip() + "\n", "objects": {}, }, } (modules_dir / f"{module}.json").write_text( json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8" ) nav = [] for path in sorted(lib.glob("*.md")): module = _module_name(path.stem) write_module(module, path.read_text(encoding="utf-8")) nav.append({ "module": module, "resource": f"doc://modules/{module}", "title": _read_title(path), }) (out_dir / "nav.json").write_text( json.dumps(nav, indent=2, ensure_ascii=False), encoding="utf-8" ) (out_dir / "index.json").write_text( json.dumps( { "project": name, "type": name, "modules_count": len(nav), "source": "hand-authored", }, indent=2, ensure_ascii=False, ), encoding="utf-8", ) print(f"[MCP] {name} bundle written to {out_dir.relative_to(ROOT)}/ ({len(nav)} modules)") # ── CLI ────────────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser( description="Build hub-authored doc folders (src/ -> HTML + MCP bundle)" ) parser.add_argument("--name", help="Only build a single folder") parser.add_argument("--dry-run", action="store_true", help="Show the plan without writing") args = parser.parse_args() folders = [f for f in find_src_dirs() if not args.name or f.name == args.name] if not folders: print("No src/ doc folders found. Run build.py from the hub root.") return for folder in folders: build_bundle(folder, dry_run=args.dry_run) build_html(folder, dry_run=args.dry_run) if __name__ == "__main__": main()