- 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
354 lines
13 KiB
Python
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>
|
|
© 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} →</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()
|