feat(webui): phase 0a — feature-module kernel scaffold (passive)

Adds the foundation of the feature-module architecture
(docs/frontend-modularization.md) as inert, additive code:
- kernel/feature-registry.js: window.LP.features — runtime register(),
  manifest loader, route-table + nav builders.
- features/manifest.dev.json: hand-committed manifest mirroring spa.js
  setupRoutes() exactly (route -> handler + navId).
- index.html loads the kernel before spa.js.

Zero behaviour change: nothing consults the registry yet. Phase 0b flips
routing to be registry-driven with the spa.js Map as fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-29 22:28:19 +01:00
parent 8312f2222f
commit 2eaa5857a1
3 changed files with 162 additions and 0 deletions

View File

@ -0,0 +1,65 @@
{
"version": 1,
"note": "Phase-0 hand-committed manifest. Mirrors spa.js setupRoutes() exactly. Will be replaced by the generated /data/webui/generated/features.json once the scan generator lands (see docs/frontend-modularization.md). 'handler' names the LibrePortalSPAClean method that still does the rendering during the strangler migration; 'navId' is the existing topbar element id for active-state highlighting.",
"features": [
{
"id": "dashboard",
"routes": ["/", "/dashboard"],
"handler": "handleDashboard",
"navId": "nav-dashboard",
"nav": { "label": "Dashboard", "order": 10 }
},
{
"id": "apps",
"routes": ["/apps", "/apps*"],
"handler": "handleApps",
"navId": "nav-app-center",
"nav": { "label": "App Center", "order": 20 }
},
{
"id": "app-detail",
"routes": ["/app", "/app*"],
"handler": "handleAppDetail",
"navId": "nav-app-center"
},
{
"id": "admin",
"routes": ["/admin", "/admin*"],
"handler": "handleAdmin",
"navId": "nav-config",
"nav": { "label": "Admin", "order": 40 }
},
{
"id": "config-redirect",
"routes": ["/config", "/config*"],
"handler": "handleConfigRedirect",
"navId": "nav-config"
},
{
"id": "tasks",
"routes": ["/tasks", "/tasks*"],
"handler": "handleTasks",
"navId": "nav-tasks",
"nav": { "label": "Tasks", "order": 60 }
},
{
"id": "backup",
"routes": ["/backup", "/backup*"],
"handler": "handleBackup",
"navId": "nav-backup",
"nav": { "label": "Backups", "order": 50 }
},
{
"id": "peers",
"routes": ["/peers", "/peers*"],
"handler": "handlePeers",
"navId": "nav-config"
},
{
"id": "ssh",
"routes": ["/ssh", "/ssh*"],
"handler": "handleSsh",
"navId": "nav-config"
}
]
}

View File

@ -102,6 +102,10 @@
<script src="/js/system/setup-wizard.js"></script>
<script src="/js/system/setup-completion-watcher.js"></script>
<script src="/js/system/system-orchestrator.js"></script>
<!-- Feature-module kernel. Currently passive: defines window.LP.features and
loads the page manifest; spa.js consults it for routing. See
docs/frontend-modularization.md. -->
<script src="/kernel/feature-registry.js"></script>
<!--
Page-specific controllers are loaded on demand by spa.js / config-manager.js
when the user navigates to the relevant route. Keeping them out of the

View File

@ -0,0 +1,93 @@
// kernel/feature-registry.js — the single source of truth for "what pages exist".
//
// This is the first piece of the feature-module architecture (see
// docs/frontend-modularization.md). It replaces the four hand-maintained
// registries the SPA grew over time:
// 1. the eager <script> list in index.html,
// 2. spa.js's hardcoded `routes` Map (setupRoutes),
// 3. system-loader.js's component registry,
// 4. config-manager.js's renderConfig() category if-chain.
//
// In this first phase it is deliberately *passive*: it loads a checked-in
// manifest describing today's pages and exposes a routes Map. spa.js consults
// it for routing; rendering still happens in the existing handleX() bodies.
// Later phases replace handlers with self-registering feature modules that call
// LP.features.register({...}) — the runtime registry below already supports it.
(function () {
const LP = (window.LP = window.LP || {});
LP.features = {
// Runtime registrations (feature index.js files call register()). Unused in
// phase 0 — present so later phases can land without touching this file.
_modules: new Map(),
// The parsed manifest (the generated/checked-in description of all pages).
_manifest: null,
// Register a live feature module. id must be unique; last-wins with a warn
// so a duplicate during migration is loud, not silent.
register(def) {
if (!def || !def.id) {
console.error('[features] register() called without an id', def);
return;
}
if (this._modules.has(def.id)) {
console.warn(`[features] duplicate registration for "${def.id}" — last wins`);
}
this._modules.set(def.id, def);
return def;
},
get(id) {
return this._modules.get(id) || null;
},
// Fetch + cache the manifest. Read-only GET of a static/generated file,
// same pattern as DataLoader.loadApps(). Returns the manifest or null on
// failure (callers fall back to their built-in defaults).
async loadManifest(url = '/features/manifest.dev.json') {
if (this._manifest) return this._manifest;
try {
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
this._manifest = Array.isArray(data.features) ? data : { version: 0, features: [] };
return this._manifest;
} catch (err) {
console.warn('[features] manifest load failed, callers will fall back:', err.message);
return null;
}
},
// The manifest entries, or [] if not loaded.
list() {
return (this._manifest && this._manifest.features) || [];
},
// Build a route-pattern -> entry Map from the manifest. The pattern strings
// (including the trailing-'*' wildcards) are kept verbatim so the consumer's
// matching logic is unchanged from spa.js's findRouteHandler.
buildRouteTable() {
const table = new Map();
for (const f of this.list()) {
for (const route of (f.routes || [])) {
if (table.has(route)) {
console.warn(`[features] duplicate route "${route}" (feature "${f.id}") — last wins`);
}
table.set(route, f);
}
}
return table;
},
// Nav entries (topbar) derived from the manifest, sorted by nav.order.
// Phase 0 keeps topbar.html authoritative; this is here for the nav kernel
// that lands with the topbar migration.
navItems() {
return this.list()
.filter(f => f.nav && f.nav.label)
.sort((a, b) => (a.nav.order || 0) - (b.nav.order || 0))
.map(f => ({ id: f.id, ...f.nav, routes: f.routes }));
},
};
})();