Files
docs/build.py

193 lines
6.7 KiB
Python

#!/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/<name>/``
(``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`` -> ``<name>/wiki/``, ``mkdocs.lib.yml`` ->
``<name>/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/<name>`` 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 = ROOT / "mcp" / name
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()