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:
@@ -100,7 +100,7 @@ class ContactsPlugin(BasePlugin):
|
|||||||
page_routes=[
|
page_routes=[
|
||||||
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'),
|
||||||
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'),
|
||||||
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMergePage', protected=True, permission='contacts:read'),
|
FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'),
|
||||||
],
|
],
|
||||||
permissions=[
|
permissions=[
|
||||||
"contacts:read",
|
"contacts:read",
|
||||||
|
|||||||
@@ -12,46 +12,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AppWindow } from 'lucide-react';
|
import { AppWindow } from 'lucide-react';
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
|
import { useMiniapps, type MiniAppDef } from '@/api/miniapps';
|
||||||
|
import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated';
|
||||||
|
|
||||||
export interface WidgetComponentProps {
|
export interface WidgetComponentProps {
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetRegistry: Record<string, React.LazyExoticComponent<React.ComponentType<WidgetComponentProps>>> = {
|
|
||||||
'@/components/dashboard/RecentContactsWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/TasksSummaryWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/CalendarUpcomingWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/ContactsStatsWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/AuditActivityWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/SystemMetricsWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/DmsFoldersWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/MailUnreadWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/WikiRecentWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/GraphOverviewWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget }))
|
|
||||||
),
|
|
||||||
'@/components/dashboard/AutomationStatusWidget': lazy(() =>
|
|
||||||
import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget }))
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MiniAppHostProps {
|
interface MiniAppHostProps {
|
||||||
appId: string;
|
appId: string;
|
||||||
@@ -86,7 +53,10 @@ export function MiniAppHost({ appId, settings, def }: MiniAppHostProps) {
|
|||||||
const resolved = def ?? data?.items.find((a) => a.app_id === appId);
|
const resolved = def ?? data?.items.find((a) => a.app_id === appId);
|
||||||
|
|
||||||
if (resolved && resolved.component) {
|
if (resolved && resolved.component) {
|
||||||
const WidgetComponent = widgetRegistry[resolved.component];
|
// Phase Q3: widgets resolve via the GENERATED component map — a plugin
|
||||||
|
// contributes dashboard widgets through its manifest, no central registry.
|
||||||
|
const widgetFactory = PLUGIN_COMPONENT_MAP[resolved.component];
|
||||||
|
const WidgetComponent = widgetFactory ? lazy(widgetFactory) : undefined;
|
||||||
if (WidgetComponent) {
|
if (WidgetComponent) {
|
||||||
return (
|
return (
|
||||||
<div data-testid={`miniapp-${appId}`}>
|
<div data-testid={`miniapp-${appId}`}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Suspense, lazy, Component, ReactNode } from 'react';
|
import React, { Suspense, lazy, Component, ReactNode } from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { logError } from '@/utils/errorLogger';
|
import { logError } from '@/utils/errorLogger';
|
||||||
|
import { PLUGIN_COMPONENT_MAP } from '@/generated/pluginComponents.generated';
|
||||||
|
|
||||||
// ── Error Boundary ──────────────────────────────────────────────────────
|
// ── Error Boundary ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,66 +92,18 @@ class PluginErrorBoundary extends Component<PluginErrorBoundaryProps, PluginErro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Static Chunk Map (ARCH-019) ─────────────────────────────────────────
|
// ── Plugin Component Map (Phase Q3, ARCH-019 successor) ──────────────────
|
||||||
//
|
//
|
||||||
// Vite cannot statically analyze dynamic import paths built at runtime
|
// The chunk map is GENERATED from the plugin manifests by
|
||||||
// (`import(/* @vite-ignore */ path)`), so production builds would fail to
|
// scripts/generate_component_map.py (frontend/src/generated/
|
||||||
// load plugin pages. All plugin-contributed component paths are known at
|
// pluginComponents.generated.ts). A plugin contributes its frontend
|
||||||
// build time — they are declared in the backend manifests — so we register
|
// components via its manifest (page_routes / settings_pages / miniapps /
|
||||||
// them here as explicit lazy imports. Vite chunks each one correctly.
|
// dashboard_widgets) — no central frontend file needs editing anymore.
|
||||||
//
|
// Unknown paths (truly external plugins) still fall back to the runtime
|
||||||
// Unknown paths (e.g. third-party plugins added later) fall back to the
|
// dynamic import, which works in dev mode.
|
||||||
// runtime dynamic import, which works in dev mode.
|
|
||||||
|
|
||||||
type ComponentModule = Record<string, unknown> & { default?: React.ComponentType<any> };
|
|
||||||
type LazyComponentFactory = () => Promise<{ default: React.ComponentType<any> }>;
|
type LazyComponentFactory = () => Promise<{ default: React.ComponentType<any> }>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a module namespace to the { default } shape React.lazy expects:
|
|
||||||
* uses the default export when present, otherwise the first named export
|
|
||||||
* (same fallback as the runtime dynamic-import path below).
|
|
||||||
*/
|
|
||||||
function normalizeModule(m: ComponentModule): { default: React.ComponentType<any> } {
|
|
||||||
const Comp = (m.default ?? m[Object.keys(m)[0]]) as React.ComponentType<any>;
|
|
||||||
return { default: Comp };
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATIC_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
|
|
||||||
// Pages
|
|
||||||
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then(normalizeModule),
|
|
||||||
// BUG (ghost page) fixed in Block I-D: page now exists.
|
|
||||||
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
|
|
||||||
'@/pages/AISettings': () => import('@/pages/AISettings').then(normalizeModule),
|
|
||||||
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then(normalizeModule),
|
|
||||||
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then(normalizeModule),
|
|
||||||
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
|
|
||||||
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
|
|
||||||
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
|
|
||||||
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
|
|
||||||
'@/pages/DedupMergePage': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
|
|
||||||
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then(normalizeModule),
|
|
||||||
'@/pages/Dms': () => import('@/pages/Dms').then(normalizeModule),
|
|
||||||
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then(normalizeModule),
|
|
||||||
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then(normalizeModule),
|
|
||||||
'@/pages/ImportExport': () => import('@/pages/ImportExport').then(normalizeModule),
|
|
||||||
'@/pages/Mail': () => import('@/pages/Mail').then(normalizeModule),
|
|
||||||
'@/pages/MailSettings': () => import('@/pages/MailSettings').then(normalizeModule),
|
|
||||||
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then(normalizeModule),
|
|
||||||
'@/pages/Reports': () => import('@/pages/Reports').then(normalizeModule),
|
|
||||||
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then(normalizeModule),
|
|
||||||
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then(normalizeModule),
|
|
||||||
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then(normalizeModule),
|
|
||||||
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then(normalizeModule),
|
|
||||||
'@/pages/Tasks': () => import('@/pages/Tasks').then(normalizeModule),
|
|
||||||
'@/pages/Wiki': () => import('@/pages/Wiki').then(normalizeModule),
|
|
||||||
'@/pages/Workflows': () => import('@/pages/Workflows').then(normalizeModule),
|
|
||||||
// NOTE: The backend manifests also declare contact detail tabs
|
|
||||||
// (@/components/contact/Contact*Tab) whose components do not exist in the
|
|
||||||
// frontend yet — they are intentionally NOT registered here; they fall
|
|
||||||
// through to the runtime fallback and surface via the error boundary.
|
|
||||||
// See PROGRESS.md "Ghost components" finding.
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Lazy Component Cache ────────────────────────────────────────────────
|
// ── Lazy Component Cache ────────────────────────────────────────────────
|
||||||
|
|
||||||
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
|
const componentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<any>>>();
|
||||||
@@ -160,8 +113,9 @@ function getLazyComponent(componentPath: string): React.LazyExoticComponent<Reac
|
|||||||
return componentCache.get(componentPath)!;
|
return componentCache.get(componentPath)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Known plugin components: statically chunked at build time (ARCH-019)
|
// Known plugin components: statically chunked at build time via the
|
||||||
const factory = STATIC_COMPONENT_MAP[componentPath];
|
// GENERATED map (Phase Q3 — manifests are the single source of truth).
|
||||||
|
const factory = (PLUGIN_COMPONENT_MAP as Record<string, LazyComponentFactory>)[componentPath];
|
||||||
if (factory) {
|
if (factory) {
|
||||||
const LazyComp = lazy(factory);
|
const LazyComp = lazy(factory);
|
||||||
componentCache.set(componentPath, LazyComp);
|
componentCache.set(componentPath, LazyComp);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// 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> = {
|
||||||
|
'@/components/dashboard/AuditActivityWidget': () => import('@/components/dashboard/AuditActivityWidget').then((m) => ({ default: m.AuditActivityWidget })),
|
||||||
|
'@/components/dashboard/AutomationStatusWidget': () => import('@/components/dashboard/AutomationStatusWidget').then((m) => ({ default: m.AutomationStatusWidget })),
|
||||||
|
'@/components/dashboard/CalendarUpcomingWidget': () => import('@/components/dashboard/CalendarUpcomingWidget').then((m) => ({ default: m.CalendarUpcomingWidget })),
|
||||||
|
'@/components/dashboard/ContactsStatsWidget': () => import('@/components/dashboard/ContactsStatsWidget').then((m) => ({ default: m.ContactsStatsWidget })),
|
||||||
|
'@/components/dashboard/DmsFoldersWidget': () => import('@/components/dashboard/DmsFoldersWidget').then((m) => ({ default: m.DmsFoldersWidget })),
|
||||||
|
'@/components/dashboard/GraphOverviewWidget': () => import('@/components/dashboard/GraphOverviewWidget').then((m) => ({ default: m.GraphOverviewWidget })),
|
||||||
|
'@/components/dashboard/MailUnreadWidget': () => import('@/components/dashboard/MailUnreadWidget').then((m) => ({ default: m.MailUnreadWidget })),
|
||||||
|
'@/components/dashboard/RecentContactsWidget': () => import('@/components/dashboard/RecentContactsWidget').then((m) => ({ default: m.RecentContactsWidget })),
|
||||||
|
'@/components/dashboard/SystemMetricsWidget': () => import('@/components/dashboard/SystemMetricsWidget').then((m) => ({ default: m.SystemMetricsWidget })),
|
||||||
|
'@/components/dashboard/TasksSummaryWidget': () => import('@/components/dashboard/TasksSummaryWidget').then((m) => ({ default: m.TasksSummaryWidget })),
|
||||||
|
'@/components/dashboard/WikiRecentWidget': () => import('@/components/dashboard/WikiRecentWidget').then((m) => ({ default: m.WikiRecentWidget })),
|
||||||
|
'@/pages/AIAssistant': () => import('@/pages/AIAssistant').then(normalizeModule),
|
||||||
|
'@/pages/AISettings': () => import('@/pages/AISettings').then((m) => ({ default: m.AISettingsPage })),
|
||||||
|
'@/pages/AgentDashboard': () => import('@/pages/AgentDashboard').then((m) => ({ default: m.AgentDashboardPage })),
|
||||||
|
'@/pages/AutomationDashboard': () => import('@/pages/AutomationDashboard').then((m) => ({ default: m.AutomationDashboardPage })),
|
||||||
|
'@/pages/AutomationSettings': () => import('@/pages/AutomationSettings').then((m) => ({ default: m.AutomationSettingsPage })),
|
||||||
|
'@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule),
|
||||||
|
'@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule),
|
||||||
|
'@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })),
|
||||||
|
'@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })),
|
||||||
|
'@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })),
|
||||||
|
'@/pages/Dms': () => import('@/pages/Dms').then((m) => ({ default: m.DmsPage })),
|
||||||
|
'@/pages/DmsTrash': () => import('@/pages/DmsTrash').then((m) => ({ default: m.DmsTrashPage })),
|
||||||
|
'@/pages/DocumentSettings': () => import('@/pages/DocumentSettings').then((m) => ({ default: m.DocumentSettingsPage })),
|
||||||
|
'@/pages/GlobalSearchResults': () => import('@/pages/GlobalSearchResults').then((m) => ({ default: m.GlobalSearchResultsPage })),
|
||||||
|
'@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })),
|
||||||
|
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
|
||||||
|
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
|
||||||
|
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })),
|
||||||
|
'@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })),
|
||||||
|
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })),
|
||||||
|
'@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })),
|
||||||
|
'@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })),
|
||||||
|
'@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then((m) => ({ default: m.SettingsUsersPage })),
|
||||||
|
'@/pages/Tasks': () => import('@/pages/Tasks').then((m) => ({ default: m.TasksPage })),
|
||||||
|
'@/pages/Wiki': () => import('@/pages/Wiki').then((m) => ({ default: m.WikiPage })),
|
||||||
|
'@/pages/Workflows': () => import('@/pages/Workflows').then((m) => ({ default: m.WorkflowsPage })),
|
||||||
|
};
|
||||||
@@ -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