#!/usr/bin/env python3 """Generate the site's read-only service catalog from the API's single source. Source : api/src/service-catalog.ts (COMPLIANCE_SERVICES -- the authority) Output : site/src/lib/service-catalog.generated.ts (SERVICE_META for display) Run host-side before the site build (deploy.sh / site prebuild). The prod box has python3 but not node, so this is Python (matches scripts/sync_nav.py). Never edit the generated file by hand: to change a price/name, edit the API catalog. The catalog is a static TS object literal (string keys, number/bool/string values, trailing commas, // comments). We parse it into Python dicts. """ import json import re from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "api/src/service-catalog.ts" OUT = ROOT / "site/src/lib/service-catalog.generated.ts" def _unescape(s: str) -> str: """Unescape only TS string escapes we expect (\\" and \\\\); the source is already UTF-8, so we must NOT unicode_escape-decode (that mangles e.g. -> / section sign).""" return s.replace('\\"', '"').replace("\\\\", "\\") def parse_catalog(ts: str) -> dict: """Parse COMPLIANCE_SERVICES entries: name, price_cents, gov_fee_label.""" m = re.search(r"export const COMPLIANCE_SERVICES[^=]*=\s*\{(.*)\n\};", ts, re.S) if not m: raise SystemExit("[gen-service-catalog] could not locate COMPLIANCE_SERVICES literal") body = m.group(1) out = {} # Each entry: "slug": { ... }, with the inner object possibly multi-line. for em in re.finditer(r'"([a-z0-9\-]+)":\s*\{(.*?)\}', body, re.S): slug = em.group(1) inner = em.group(2) name_m = re.search(r'name:\s*"((?:[^"\\]|\\.)*)"', inner) price_m = re.search(r"price_cents:\s*(\d+)", inner) gov_m = re.search(r'gov_fee_label:\s*"((?:[^"\\]|\\.)*)"', inner) interval_m = re.search(r'billing_interval:\s*"(month|year)"', inner) methods_m = re.search(r"allowed_methods:\s*\[([^\]]*)\]", inner) if not name_m or not price_m: continue entry = { "name": _unescape(name_m.group(1)), "price_cents": int(price_m.group(1)), } if gov_m: entry["gov_fee_label"] = _unescape(gov_m.group(1)) if interval_m: entry["billing_interval"] = interval_m.group(1) if methods_m: entry["allowed_methods"] = re.findall(r'"([a-z]+)"', methods_m.group(1)) out[slug] = entry return out def render(catalog: dict) -> str: lines = [] for slug in sorted(catalog): s = catalog[slug] parts = [f"name: {json.dumps(s["name"], ensure_ascii=False)}", f"price_cents: {s['price_cents']}"] if s.get("gov_fee_label"): parts.append(f"gov_fee_label: {json.dumps(s["gov_fee_label"], ensure_ascii=False)}") if s.get("billing_interval"): parts.append(f"billing_interval: {json.dumps(s['billing_interval'], ensure_ascii=False)}") if s.get("allowed_methods"): parts.append(f"allowed_methods: {json.dumps(s['allowed_methods'], ensure_ascii=False)}") lines.append(f" {json.dumps(slug, ensure_ascii=False)}: {{ {', '.join(parts)} }},") return ( "/**\n" " * AUTO-GENERATED -- DO NOT EDIT.\n" " *\n" " * Source of truth: api/src/service-catalog.ts (COMPLIANCE_SERVICES).\n" " * Regenerate with: python3 scripts/gen-service-catalog.py (runs on deploy).\n" " *\n" " * Display-only subset (name, price_cents, gov_fee_label) of the API catalog,\n" " * so the public site can never drift from what the API actually charges.\n" " */\n\n" "export interface ServiceMeta {\n" " name: string;\n" " price_cents: number;\n" " gov_fee_label?: string;\n" ' billing_interval?: "month" | "year";\n' ' allowed_methods?: ("card" | "ach" | "paypal" | "klarna" | "crypto")[];\n' "}\n\n" "export const SERVICE_META: Record = {\n" + "\n".join(lines) + "\n};\n" ) if __name__ == "__main__": catalog = parse_catalog(SRC.read_text()) OUT.write_text(render(catalog)) print(f"[gen-service-catalog] wrote {len(catalog)} services -> {OUT}")