Files
docs/collect.py
Vishesh 'ironeagle' Bangotra 42e5ed290e feat: list each declared doc kind as its own homepage card
- compute per-kind home URLs (lib/, api/, wiki/) via _find_home_for_kind
- emit one card per declared kind so multi-kind repos (e.g. doc-forge
  with lib + wiki) appear in each matching homepage section
- declare the doc-forge wiki kind and refresh its vendored site with the
  combined lib + wiki build
2026-09-11 23:38:31 +05:30

354 lines
13 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"
MCP_DIR = "mcp"
INDENT = " "
OLD_DIRS = ("libs", "apis", "wiki", "tutorials")
# ── 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)}"
def _find_home(dest: Path) -> str:
"""After copying a site into *dest*, return the relative URL path
where index.html lives. Checks root, lib/, api/ in order."""
if (dest / "index.html").exists():
return ""
if (dest / "lib" / "index.html").exists():
return "lib/"
if (dest / "api" / "index.html").exists():
return "api/"
return ""
def _find_home_for_kind(dest: Path, kind: str) -> str:
"""After copying a site into *dest*, return the relative URL path for a
specific doc kind. A root index.html serves every kind; otherwise each
kind is found under its own subdirectory (lib/, api/, wiki/)."""
if (dest / "index.html").exists():
return ""
sub = dest / kind
if kind in ("lib", "api", "wiki") and (sub / "index.html").exists():
return f"{kind}/"
return _find_home(dest)
# ── 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:
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,
home_urls: dict[str, str],
kind_urls: dict[tuple[str, str], str] | None = None,
) -> 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]] = []
kind_urls = kind_urls or {}
for entry in config.get("repos", []):
docs = entry.get("docs") or {}
name = entry["name"]
title = _safe_title(entry)
desc = _safe_description(entry)
base_url = home_urls.get(name, f"/{name}/")
if docs.get("lib"):
url = kind_urls.get((name, "lib"), base_url)
lib_cards.append((title, desc, url, "View Documentation"))
if docs.get("wiki"):
url = kind_urls.get((name, "wiki"), base_url)
wiki_cards.append((title, desc, url, "View Documentation"))
if docs.get("api"):
url = kind_urls.get((name, "api"), base_url)
api_cards.append((title, desc, url, "View Documentation"))
for entry in config.get("static", []):
kind = entry.get("kind")
name = entry["repo"]
title = _safe_title(entry)
desc = _safe_description(entry)
url = home_urls.get(name, f"/{name}/")
if kind == "api":
api_cards.append((title, desc, url, "View Documentation"))
elif kind == "tutorial":
tutorial_cards.append((title, desc, url, "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 lib/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] = []
home_urls: dict[str, 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 / name
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", []):
name = entry["repo"]
p = ROOT / entry["path"]
dst = ROOT / name
if not p.is_dir():
errors.append(f"static {name}: path missing ({p})")
elif p == dst:
copied.append(f"static {name} already vendored at {dst.relative_to(ROOT)}/")
else:
copied.append(f"static {name} {dst.relative_to(ROOT)}/")
if not dry_run:
_copy_dir(p, dst)
# ── compute home URLs ───────────────────────────────────────────────────
if not dry_run:
kind_urls: dict[tuple[str, str], str] = {}
for entry in config.get("repos", []):
name = entry["name"]
dest = ROOT / name
home_urls[name] = f"/{name}/{_find_home(dest)}"
for kind in ("lib", "api", "wiki"):
if (entry.get("docs") or {}).get(kind):
kind_urls[(name, kind)] = f"/{name}/{_find_home_for_kind(dest, kind)}"
for entry in config.get("static", []):
home_urls[entry["repo"]] = f"/{entry['repo']}/{_find_home(ROOT / entry['repo'])}"
# ── regenerate outputs ──────────────────────────────────────────────
index_html = build_index_html(config, home_urls, kind_urls)
(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 name, url in sorted(home_urls.items()):
print(f"[URL] {name:20s} -> {url}")
for line in copied:
print(f"[COP]{line}")
# ── clean stale category dirs ───────────────────────────────────────────
if not dry_run:
for d in OLD_DIRS:
p = ROOT / d
if p.exists():
shutil.rmtree(p, ignore_errors=True)
print(f"[DEL] {d}/ (stale category dir removed)")
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()