"""ChristianLempa Boilerplates compatibility layer. Detects, parses and renders templates from the boilerplates-library format: / template.json (metadata + variable schema) files/ compose.yaml (Jinja-like template with << var >> and <%- if %> syntax) Renders boilerplate templates into a flat directory that Server Up can manage just like any other stack — i.e. a single compose file (+ optional .env). """ from __future__ import annotations import json, re, shutil from pathlib import Path from typing import Any # Jinja delimiters used by the boilerplates library # Variable expressions use << >> and statement blocks use <% %>. _VAR_OPEN, _VAR_CLOSE = "<<", ">>" _BLK_OPEN, _BLK_CLOSE = "<%", "%>" # ── Detection ──────────────────────────────────────────────────────────────── def is_boilerplate(d: Path) -> bool: """True if the directory follows the ChristianLempa boilerplate layout.""" if not d.is_dir(): return False tj = d / "template.json" fd = d / "files" return tj.is_file() and fd.is_dir() def read_template(d: Path) -> dict | None: tj = d / "template.json" if not tj.exists(): return None try: return json.loads(tj.read_text(encoding="utf-8")) except Exception: return None def metadata(d: Path) -> dict: """Return Server-Up-style metadata extracted from template.json.""" t = read_template(d) or {} md = t.get("metadata") or {} icon = md.get("icon") or {} icon_url = "" if isinstance(icon, dict): prov = icon.get("provider") iid = icon.get("id") if prov == "selfhst" and iid: icon_url = f"https://cdn.jsdelivr.net/gh/selfhst/icons/png/{iid}.png" elif prov == "dashboard-icons" and iid: icon_url = f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{iid}.png" elif iid and (iid.startswith("http://") or iid.startswith("https://")): icon_url = iid return { "name": md.get("name") or d.name, "description": md.get("description", ""), "tags": md.get("tags", []) or [], "logo_url": icon_url, "version": (md.get("version") or {}).get("name", ""), "kind": t.get("kind", "compose"), "format": "boilerplate", "draft": bool(md.get("draft")), } # ── Variable schema → Server Up form fields ────────────────────────────────── def fields(d: Path) -> list[dict]: """Flatten template.json variables into a list of UI fields. Each field has: name, type, title, group, default, required, options (for enum), needs (visibility deps), placeholder, description. """ t = read_template(d) or {} out: list[dict] = [] for grp in t.get("variables", []) or []: gname = grp.get("title") or grp.get("name", "") toggle = grp.get("toggle") for item in grp.get("items", []) or []: cfg = item.get("config") or {} out.append({ "name": item.get("name", ""), "type": item.get("type", "str"), "title": item.get("title") or item.get("name", ""), "group": gname, "group_toggle": toggle, "default": item.get("default"), "required": bool(item.get("required")), "options": cfg.get("options") or [], "needs": item.get("needs") or [], "placeholder": cfg.get("placeholder", ""), "description": item.get("description", ""), }) return out # ── Templating engine ──────────────────────────────────────────────────────── class BoilerplateError(Exception): pass def _coerce(value: Any, typ: str): if typ == "bool": if isinstance(value, bool): return value s = str(value).strip().lower() return s in ("1", "true", "yes", "on") if typ == "int": try: return int(str(value).strip()) except (ValueError, TypeError): return 0 return str(value) if value is not None else "" def build_context(d: Path, values: dict[str, Any]) -> dict[str, Any]: """Merge user-provided values with defaults declared in template.json, coercing each value to its declared type.""" ctx: dict[str, Any] = {} for f in fields(d): name = f["name"] if name in values and values[name] not in (None, ""): ctx[name] = _coerce(values[name], f["type"]) elif f.get("default") is not None: ctx[name] = _coerce(f["default"], f["type"]) else: # Sensible defaults so Jinja doesn't blow up on undefined vars ctx[name] = "" if f["type"] != "bool" else False # Allow extra values to pass through (e.g. derived service_name) for k, v in values.items(): if k not in ctx and v is not None: ctx[k] = v return ctx _env = None def _jinja_env(): """Create (once) and return the Jinja2 environment for the boilerplates delimiters.""" global _env if _env is None: try: from jinja2 import Environment, ChainableUndefined except ImportError as e: raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e _env = Environment( variable_start_string=_VAR_OPEN, variable_end_string=_VAR_CLOSE, block_start_string=_BLK_OPEN, block_end_string=_BLK_CLOSE, comment_start_string="<#", comment_end_string="#>", trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True, undefined=ChainableUndefined, ) return _env def render_text(template: str, ctx: dict[str, Any]) -> str: env = _jinja_env() try: return env.from_string(template).render(**ctx) except Exception as e: raise BoilerplateError(f"render failed: {e}") from e def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]: """Render every file under /files/ into /, applying the template engine to text-like files. Binary files are copied verbatim. Returns the list of relative paths written. """ files_dir = src / "files" if not files_dir.is_dir(): raise BoilerplateError(f"no files/ directory in {src}") ctx = build_context(src, values) dest.mkdir(parents=True, exist_ok=True) written: list[str] = [] for p in sorted(files_dir.rglob("*")): if not p.is_file(): continue rel = p.relative_to(files_dir) out = dest / rel out.parent.mkdir(parents=True, exist_ok=True) if _looks_textual(p): try: txt = p.read_text(encoding="utf-8") except UnicodeDecodeError: shutil.copy2(p, out) written.append(str(rel)) continue rendered = render_text(txt, ctx) # Drop blocks of pure whitespace left over after conditionals strip rendered = _tidy(rendered) out.write_text(rendered, encoding="utf-8") else: shutil.copy2(p, out) written.append(str(rel)) # Always ensure the default compose name exists; rename if needed _normalize_compose_name(dest) return written # ── Helpers ────────────────────────────────────────────────────────────────── _TEXT_EXTS = {".yml", ".yaml", ".env", ".conf", ".cfg", ".ini", ".json", ".toml", ".sh", ".md", ".txt", ".tmpl", ".tpl", ".j2", ""} def _looks_textual(p: Path) -> bool: if p.suffix.lower() in _TEXT_EXTS: return True # also accept dotfiles like ".env" if p.name.startswith(".") and not p.suffix: return True try: chunk = p.read_bytes()[:512] chunk.decode("utf-8") return b"\x00" not in chunk except Exception: return False def _tidy(text: str) -> str: # Collapse 3+ blank lines into 2 — block conditionals leave gaps behind. return re.sub(r"\n{3,}", "\n\n", text) def _normalize_compose_name(dest: Path): """Ensure there's a docker-compose.yml at root; rename compose.yaml if not.""" if (dest / "docker-compose.yml").exists() or (dest / "compose.yml").exists(): return cy = dest / "compose.yaml" if cy.exists(): # Some tooling expects docker-compose.yml; symlink-style rename. cy.rename(dest / "docker-compose.yml")