386 lines
14 KiB
Python
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>
|
|
© 2025 Aetoskia Developer Network
|
|
</footer>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
SECTION_ORDER = (
|
|
("apps", "Apps"),
|
|
("tutorial", "Tutorial"),
|
|
("services", "Services"),
|
|
("libraries", "Libraries"),
|
|
)
|
|
DEFAULT_SECTION = "libraries"
|
|
|
|
LINK_KINDS = (("api", "view api"), ("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()
|