Files
docs/collect.py
Vishesh 'ironeagle' Bangotra bd8ba6e67c feat: regroup homepage into apps/tutorial/services/libraries cards
- assign each repo a section via config.yml (blog -> apps,
  media-manager -> tutorial, auth-server -> services, rest -> libraries)
- render one card per repo with a bottom-justified action row containing
  one link per available kind (view docs / view wiki / view lib /
  view tutorial)
- switch card grid to three columns with responsive breakpoints and
  taller cards
2026-09-11 23:49:59 +05:30

386 lines
14 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:1200px; 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(3, 1fr); gap:1.5rem; width:100%;
}}
@media (max-width: 1024px) {{
.grid {{ grid-template-columns:repeat(2, 1fr); }}
}}
@media (max-width: 640px) {{
.grid {{ grid-template-columns:1fr; }}
}}
.card {{
background:var(--card); border:1px solid var(--border); border-radius:12px;
padding:1.6rem; text-align:left; display:flex; flex-direction:column;
height:100%; min-height:200px;
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-actions {{
margin-top:auto; padding-top:1.2rem; display:flex; flex-wrap:wrap; gap:0.6rem;
}}
.card-actions a {{
color:var(--accent); text-decoration:none; font-size:0.85rem; font-weight:600;
padding:0.45rem 0.9rem; border:1px solid var(--border); border-radius:999px;
background:rgba(45,212,191,0.06);
transition:border-color 0.15s ease,background 0.15s ease,color 0.15s ease;
}}
.card-actions a:hover {{
color:var(--accent-dim); border-color:var(--accent);
background:rgba(45,212,191,0.14);
}}
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>"""
SECTION_ORDER = (
("apps", "Apps"),
("tutorial", "Tutorial"),
("services", "Services"),
("libraries", "Libraries"),
)
DEFAULT_SECTION = "libraries"
LINK_KINDS = (("api", "view docs"), ("wiki", "view wiki"), ("lib", "view lib"))
def _card_links(
entry: dict, name: str, base_url: str, kind_urls: dict[tuple[str, str], str]
) -> list[tuple[str, str]]:
"""Build the action links for a repo card from its declared kinds."""
docs = entry.get("docs") or {}
links: list[tuple[str, str]] = []
for kind, label in LINK_KINDS:
if docs.get(kind):
url = kind_urls.get((name, kind), base_url)
links.append((label, url))
if entry.get("kind") == "tutorial":
links.append(("view tutorial", base_url))
return links or [("view docs", base_url)]
def _render_section(title: str, cards: list[tuple[str, str, list[tuple[str, str]]]]) -> str:
if not cards:
return ""
lines = [f'<section class="section">', f"<h2>{title}</h2>", '<div class="grid">']
for t, desc, links in cards:
lines.append(f' <div class="card">')
lines.append(f" <h3>{t}</h3>")
lines.append(f" <p>{desc}</p>")
lines.append(' <div class="card-actions">')
for label, href in links:
lines.append(f' <a href="{href}" target="_blank">{label}</a>')
lines.append(" </div>")
lines.append(" </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:
kind_urls = kind_urls or {}
sections: dict[str, list[tuple[str, str, list[tuple[str, str]]]]] = {}
for entry in config.get("repos", []):
name = entry["name"]
title = _safe_title(entry)
desc = _safe_description(entry)
base_url = home_urls.get(name, f"/{name}/")
section = (entry.get("section") or DEFAULT_SECTION).strip().lower()
sections.setdefault(section, []).append(
(title, desc, _card_links(entry, name, base_url, kind_urls))
)
for entry in config.get("static", []):
name = entry["repo"]
title = _safe_title(entry)
desc = _safe_description(entry)
base_url = home_urls.get(name, f"/{name}/")
section = (entry.get("section") or DEFAULT_SECTION).strip().lower()
sections.setdefault(section, []).append(
(title, desc, _card_links(entry, name, base_url, kind_urls))
)
body = "\n\n".join(
filter(
None,
(_render_section(title, sections.get(key, [])) for key, title in SECTION_ORDER),
)
)
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()