Files
docs/collect.py
Vishesh 'ironeagle' Bangotra 0c25cbb19f feat: unify docs portal and MCP servers into one config-driven service
- add config.yml as single source of truth for collected repos/categories
- add collect.py CLI to pull lib/api/wiki site builds and mcp bundles from
  source repos and regenerate nginx.conf + _index/index.html
- port mcp runtime (main.py/core.py/config.py): nginx + health + FastMCP
  servers started from config.yml
- docker: python:3.13-slim + nginx, expose 8000-8006 + 9000 (html portal)
- drone: publish html (6007:9000) and MCP ports 8000-8006
- move mongo-ops docs to wiki/, add auth-server (api) and hexa (lib)
- refresh lib site builds (lib/ prefix) and collect mcp bundles
2026-09-11 15:14:51 +05:30

299 lines
10 KiB
Python

#!/usr/bin/env python
"""Collect documentation from source repos into this repository and regenerate
the static index and nginx configuration.
Usage:
python collect.py # copy artifacts, regenerate outputs
python collect.py --dry-run # list planned actions without writing
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent
CONFIG_FILE = ROOT / "config.yml"
KIND_DIR = {"lib": "libs", "api": "apis", "wiki": "wiki"}
SITE_DIR = "site"
MCP_DIR = "mcp"
TITLE_CSS = "font-size:1.6rem;margin-bottom:1.2rem;color:#2dd4bf;"
CARD_CSS = (
"background:#161b22;border:1px solid #30363d;border-radius:12px;"
"padding:1.8rem;text-align:left;"
"transition:transform 0.15s ease,box-shadow 0.15s ease;"
)
INDENT = " "
# ── helpers ──────────────────────────────────────────────────────────────────
def load_config() -> dict:
data = yaml.safe_load(CONFIG_FILE.read_text(encoding="utf-8"))
if "service" not in data or "repos" not in data:
print("ERROR: config.yml must define 'service' and 'repos' keys", file=sys.stderr)
sys.exit(1)
return data
def _copy_dir(src: Path, dst: Path) -> None:
if dst.exists():
shutil.rmtree(dst, ignore_errors=True)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
def _safe_title(entry: dict) -> str:
return entry.get("title") or entry["name"].replace("-", " ").title()
def _safe_description(entry: dict) -> str:
return entry.get("description") or f"Documentation for {_safe_title(entry)}"
# ── index.html generator ────────────────────────────────────────────────────
INDEX_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aetoskia Developer Documentation</title>
<style>
:root {{
--bg: #0d1117;
--card: #161b22;
--text: #e6edf3;
--muted: #8b949e;
--accent: #2dd4bf;
--accent-dim: #1e9184;
--border: #30363d;
--font-main: 'Inter', 'Roboto', sans-serif;
--font-code: 'JetBrains Mono', monospace;
}}
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{
background:var(--bg); color:var(--text); font-family:var(--font-main);
min-height:100vh; display:flex; flex-direction:column;
align-items:center; justify-content:flex-start; padding:3rem 1.5rem;
}}
header {{ text-align:center; margin-bottom:3rem; }}
h1 {{ font-size:2.5rem; color:var(--accent); letter-spacing:0.5px; margin-bottom:0.5rem; }}
.subtitle {{ color:var(--muted); font-size:1rem; }}
.badge {{
display:inline-block; margin-top:0.8rem; background:#30363d; color:#fff;
padding:3px 8px; border-radius:6px; font-size:0.85rem; font-weight:600;
border:1px solid #484f58;
}}
.section {{ width:100%; max-width:900px; margin-bottom:2.5rem; }}
.section h2 {{
font-size:1.2rem; color:var(--muted); text-transform:uppercase;
letter-spacing:1.5px; margin-bottom:1rem; border-bottom:1px solid var(--border);
padding-bottom:0.4rem;
}}
.grid {{
display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr));
gap:1.5rem; width:100%;
}}
.card {{
background:var(--card); border:1px solid var(--border); border-radius:12px;
padding:1.8rem; text-align:left;
transition:transform 0.15s ease,box-shadow 0.15s ease;
}}
.card:hover {{
transform:translateY(-4px); box-shadow:0 0 20px rgba(45,212,191,0.2);
border-color:var(--accent);
}}
.card h3 {{ color:var(--accent); font-size:1.4rem; margin-bottom:0.4rem; }}
.card p {{ color:var(--muted); font-size:0.95rem; line-height:1.6; }}
.card a {{
color:var(--accent); text-decoration:none; display:inline-block;
margin-top:0.8rem; font-weight:500;
}}
.card a:hover {{ color:var(--accent-dim); text-decoration:underline; }}
footer {{ margin-top:4rem; text-align:center; color:var(--muted); font-size:0.9rem; }}
</style>
</head>
<body>
<header>
<h1>Aetoskia Developer Docs</h1>
<p class="subtitle">Central documentation hub for Aetoskia projects</p>
</header>
{body}
<footer>
&copy; 2025 Aetoskia Developer Network
</footer>
</body>
</html>"""
def _render_section(title: str, cards: list[tuple[str, str, str, str]]) -> str:
"""cards = [(safe_title, description, href, label), ...]"""
if not cards:
return ""
lines = [f'<section class="section">', f"<h2>{title}</h2>", '<div class="grid">']
for t, desc, href, label in cards:
lines.append(f' <div class="card">')
lines.append(f" <h3>{t}</h3>")
lines.append(f" <p>{desc}</p>")
lines.append(f' <a href="{href}" target="_blank">{label} &rarr;</a>')
lines.append(f" </div>")
lines.append("</div>")
lines.append("</section>")
return "\n".join(lines)
def build_index_html(config: dict) -> str:
lib_cards: list[tuple[str, str, str, str]] = []
api_cards: list[tuple[str, str, str, str]] = []
wiki_cards: list[tuple[str, str, str, str]] = []
tutorial_cards: list[tuple[str, str, str, str]] = []
for entry in config.get("repos", []):
docs = entry.get("docs") or {}
name = entry["name"]
title = _safe_title(entry)
desc = _safe_description(entry)
if docs.get("lib"):
lib_cards.append((title, desc, f"/libs/{name}/", "View Documentation"))
if docs.get("api"):
api_cards.append((title, desc, f"/apis/{name}/", "View Documentation"))
if docs.get("wiki"):
wiki_cards.append((title, desc, f"/wiki/{name}/", "View Documentation"))
for entry in config.get("static", []):
kind = entry.get("kind")
name = entry["repo"]
title = _safe_title(entry)
desc = _safe_description(entry)
if kind == "api":
api_cards.append((title, desc, f"/apis/{name}/", "View Documentation"))
elif kind == "tutorial":
tutorial_cards.append((title, desc, f"/tutorials/{name}/", "View Tutorial"))
body = "\n\n".join(filter(None, [
_render_section("Libraries", lib_cards),
_render_section("APIs", api_cards),
_render_section("Wiki", wiki_cards),
_render_section("Tutorials", tutorial_cards),
]))
return INDEX_TEMPLATE.format(body=body)
# ── nginx.conf generator ────────────────────────────────────────────────────
NGINX_TEMPLATE = r"""server {{
listen {port};
server_name _;
root /usr/share/nginx/html;
index index.html api/index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
}}
"""
def build_nginx_conf(port: int) -> str:
return NGINX_TEMPLATE.format(port=port)
# ── collect logic ────────────────────────────────────────────────────────────
def collect(config: dict, dry_run: bool = False) -> list[str]:
service = config["service"]
errors: list[str] = []
copied: list[str] = []
for entry in config.get("repos", []):
name = entry["name"]
source = Path(entry["source"]).resolve()
docs = entry.get("docs") or {}
if not source.is_dir():
errors.append(f"{name}: source repo not found ({source})")
continue
for kind in ("lib", "api", "wiki"):
site_rel = docs.get(kind)
if not site_rel:
continue
src = source / site_rel
if not src.is_dir():
errors.append(f"{name}: {kind} site missing ({src})")
continue
dst = ROOT / KIND_DIR[kind] / name / SITE_DIR
copied.append(f"{kind:>4} {name} {dst.relative_to(ROOT)}")
if not dry_run:
_copy_dir(src, dst)
mcp = docs.get("mcp")
if mcp:
src = source / mcp["bundle"]
if not src.is_dir():
errors.append(f"{name}: mcp bundle missing ({src})")
continue
dst = ROOT / MCP_DIR / name
copied.append(f" mcp {name} {dst.relative_to(ROOT)}")
if not dry_run:
_copy_dir(src, dst)
for entry in config.get("static", []):
p = ROOT / entry["path"]
if not p.is_dir():
errors.append(f"static {entry['repo']}: path missing ({p})")
else:
copied.append(f"static {entry['repo']} {entry['path']}")
if not dry_run:
index_html = build_index_html(config)
(ROOT / "_index" / "index.html").write_text(index_html, encoding="utf-8")
nginx_conf = build_nginx_conf(service["html_port"])
(ROOT / "nginx.conf").write_text(nginx_conf, encoding="utf-8")
print(f"[OK] _index/index.html regenerated")
print(f"[OK] nginx.conf regenerated (listen {service['html_port']})")
for line in copied:
print(f"[COP]{line}")
if errors:
print("\nERRORS:", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
return errors
# ── CLI ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="Collect docs into the aetos-docs service repo")
parser.add_argument("--dry-run", action="store_true", help="Show planned actions without writing")
args = parser.parse_args()
config = load_config()
errors = collect(config, dry_run=args.dry_run)
if errors:
sys.exit(1)
if __name__ == "__main__":
main()