librelad 31b73f9670 feat(webui): auto-discover features from folders, mirroring the theme system
Themes are already modular via folder discovery (GET /api/themes/list scans
themes/<name>/). This brings the SAME model to pages:
- backend/routes/features.js: public GET /api/features/list scans
  frontend/features/<id>/feature.json and returns the page manifest. The
  Node process reads its own bind-mounted /app/frontend — no runFileOp /
  regen / source-array plumbing needed (sidesteps the shell-generator gotchas).
- features/<id>/feature.json: each page now self-describes (id, routes,
  module, handler, navId, nav, order). 6 real features + 3 redirect-only
  (config/peers/ssh) so behaviour is preserved exactly.
- kernel loadManifest() prefers /api/features/list, falls back to the static
  features/manifest.dev.json when the endpoint isn't up yet.

Result: dropping a features/<id>/ folder registers a page; deleting it
removes it — zero central edits, exactly like dropping a theme folder.
(Backend route needs a Node restart to activate; the static-manifest
fallback keeps everything working until then.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 00:18:20 +01:00

103 lines
4.0 KiB
JavaScript

// 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 page manifest. Prefers the live scan endpoint
// (/api/features/list — folder-discovered, exactly like /api/themes/list),
// and falls back to the checked-in static manifest if the endpoint is
// unavailable (e.g. the backend hasn't restarted to pick up the route yet).
// Returns the manifest or null on total failure (callers fall back to their
// built-in defaults).
async loadManifest() {
if (this._manifest) return this._manifest;
const sources = ['/api/features/list', '/features/manifest.dev.json'];
for (const url of sources) {
try {
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) continue;
const data = await res.json();
if (data && Array.isArray(data.features) && data.features.length) {
this._manifest = data;
return this._manifest;
}
} catch (err) {
console.warn(`[features] manifest source ${url} failed:`, err.message);
}
}
console.warn('[features] no manifest source available, callers will fall back');
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 }));
},
};
})();