#!/usr/bin/env python3 """Generate the frontend plugin component import map (Phase Q3). Scans all builtin plugin manifests (app/plugins/builtins/*/plugin.py) and the core miniapp definitions (app/core/system_miniapps.py) for frontend component paths (``component="@/..."``) and generates ``frontend/src/generated/pluginComponents.generated.ts`` with one static lazy-import per component. Why: Vite cannot statically analyze dynamic import() paths built at runtime, so plugin pages must be registered as explicit lazy imports for production builds. Previously this lived in two hand-maintained central lists (PluginLoader STATIC_COMPONENT_MAP + MiniAppHost widgetRegistry) — a new plugin had to touch core frontend files. Now the map is GENERATED from the plugin declarations themselves: a plugin contributes its components via the manifest and this script wires them. Guarantees: - Fails (exit 1) on GHOST components: a manifest path whose file does not exist aborts the generation instead of shipping a dead entry. - Detects the export style per file (default export vs. named exports) and wires the correct import. - Deterministic output (sorted paths) so CI can diff-check freshness. Usage: python3 scripts/generate_component_map.py # write the file python3 scripts/generate_component_map.py --check # exit 1 if outdated """ from __future__ import annotations import argparse import re import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent FRONTEND_SRC = REPO / "frontend" / "src" OUT_FILE = FRONTEND_SRC / "generated" / "pluginComponents.generated.ts" # Sources that declare frontend component paths PLUGIN_FILES = sorted((REPO / "app" / "plugins" / "builtins").glob("*/plugin.py")) CORE_FILES = [REPO / "app" / "core" / "system_miniapps.py"] PATH_PATTERN = re.compile(r'["\'](@/[^"\']+)["\']') HEADER = """// AUTO-GENERATED by scripts/generate_component_map.py — DO NOT EDIT. // Regenerate with: python3 scripts/generate_component_map.py // Sources: app/plugins/builtins/*/plugin.py + app/core/system_miniapps.py // Failure mode: the generator refuses ghost components (manifest path without // a matching frontend file), so this map is always loadable. /* eslint-disable */ // NOTE: ComponentType — components take their own specific props and // consumers (PluginLoader, MiniAppHost) pass arbitrary props; `unknown` // would forbid all JSX attributes (Phase Q3). import type { ComponentType } from 'react'; type ComponentModule = Record & { default?: ComponentType }; export type LazyComponentFactory = () => Promise<{ default: ComponentType }>; function normalizeModule(m: ComponentModule): { default: ComponentType } { const Comp = (m.default ?? m[Object.keys(m)[0]]) as ComponentType; return { default: Comp }; } export const PLUGIN_COMPONENT_MAP: Record = { """ def collect_paths() -> set[str]: """Extract all @/ component paths from plugin manifests and core files.""" paths: set[str] = set() sources = PLUGIN_FILES + CORE_FILES if not sources: raise SystemExit("No plugin manifests found — wrong working directory?") for f in sources: text = f.read_text(encoding="utf-8") for line in text.splitlines(): stripped = line.strip() # Only lines that actually declare a component field, not comments if stripped.startswith("#"): continue if "component" not in line: continue for match in PATH_PATTERN.findall(line): paths.add(match) return paths def resolve_file(path_alias: str) -> Path | None: """Resolve an @/ alias to an existing frontend file.""" rel = path_alias.replace("@/", "", 1) base = FRONTEND_SRC / rel candidates = [ base.with_suffix(".tsx"), base.with_suffix(".ts"), base.with_suffix(".jsx"), base / "index.tsx", base / "index.ts", ] for c in candidates: if c.is_file(): return c return None def detect_export_style(file: Path, expected_name: str) -> tuple[str, str | None]: """Inspect the component file and decide how to import it. Returns (style, name): - ("default", None): file has `export default` → use normalizeModule - ("named", NAME): file has a named export matching the expected name - ("single", NAME): exactly one named export → normalizeModule would pick it, but we wire it explicitly for clarity """ text = file.read_text(encoding="utf-8") has_default = bool(re.search(r"^export\s+default\b", text, re.M)) if has_default: return "default", None names: list[str] = [] for m in re.finditer(r"^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)", text, re.M): if m.group(1): names.append(m.group(1)) for m in re.finditer(r"^export\s+const\s+(\w+)", text, re.M): names.append(m.group(1)) for m in re.finditer(r"^export\s+class\s+(\w+)", text, re.M): names.append(m.group(1)) if expected_name in names: return "named", expected_name if len(names) == 1: return "single", names[0] raise SystemExit( f"GHOST EXPORT: {file} has no default export and no named export " f"matching '{expected_name}' (found: {names})" ) def expected_component_name(file: Path) -> str: return file.with_suffix("").name def build_map(paths: set[str]) -> str: lines: list[str] = [] ghosts: list[str] = [] for alias in sorted(paths): file = resolve_file(alias) if file is None: ghosts.append(alias) continue style, name = detect_export_style(file, expected_component_name(file)) if style == "default": lines.append( f" '{alias}': () => import('{alias}').then(normalizeModule)," ) else: assert name is not None lines.append( f" '{alias}': () => " f"import('{alias}').then((m) => ({{ default: m.{name} }}))," ) if ghosts: print("GHOST components — manifest paths without frontend file:", file=sys.stderr) for g in ghosts: print(f" {g}", file=sys.stderr) raise SystemExit(1) return HEADER + "\n".join(lines) + "\n};\n" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="verify the generated file is up to date") args = parser.parse_args() paths = collect_paths() content = build_map(paths) if args.check: if not OUT_FILE.is_file(): print(f"MISSING: {OUT_FILE} — run the generator", file=sys.stderr) return 1 current = OUT_FILE.read_text(encoding="utf-8") if current != content: print(f"OUTDATED: {OUT_FILE} — rerun the generator", file=sys.stderr) return 1 print(f"OK: {OUT_FILE.name} up to date ({len(paths)} components)") return 0 OUT_FILE.parent.mkdir(parents=True, exist_ok=True) OUT_FILE.write_text(content, encoding="utf-8") print(f"Generated {OUT_FILE} with {len(paths)} components") return 0 if __name__ == "__main__": raise SystemExit(main())