feat(frontend): Q3+Q4 — Komponenten-Chunk-Map wird aus Plugin-Manifesten GENERIERT
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
scripts/generate_component_map.py scannt alle builtin-Manifeste + system_miniapps.py und erzeugt frontend/src/generated/pluginComponents.generated.ts (37 Komponenten). PluginLoader (STATIC_COMPONENT_MAP) und MiniAppHost (widgetRegistry) nutzen die generierte Map — ein Plugin meldet seine Komponenten nur noch im Manifest, keine zentrale Frontend-Datei muss angefasst werden. Garantien: Generator failt hart bei Ghost-Komponenten (bewiesen: exit 1), erkennt default- vs. named-exports, deterministische Ausgabe, --check-Modus fuer CI. Kontakts DedupMergePage-Pfad-Alias auf echte Datei korrigiert. Verifikation: tsc exit 0; production build exit 0; Ghost-Fail-Hard exit 1; Dashboard+MiniAppWindow 17/17; pluginStore 18/18; keine Restreferenzen auf STATIC_COMPONENT_MAP/widgetRegistry.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
#!/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<any> — 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<string, unknown> & { default?: ComponentType<any> };
|
||||
export type LazyComponentFactory = () => Promise<{ default: ComponentType<any> }>;
|
||||
|
||||
function normalizeModule(m: ComponentModule): { default: ComponentType<any> } {
|
||||
const Comp = (m.default ?? m[Object.keys(m)[0]]) as ComponentType<any>;
|
||||
return { default: Comp };
|
||||
}
|
||||
|
||||
export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
|
||||
"""
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user