Files
breadcrumb-the-shire/web/js/core/app-component-runtime.js
fs c7b8fd516a feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00

169 lines
4.6 KiB
JavaScript

/**
* Component runtime for lifecycle-managed UI modules.
*
* Contract:
* - component init signature: init(root, config)
* - init result must expose destroy(): void
*/
import { warnOnce, resolveHost } from './app-dom.js';
import { readPageConfig } from './app-page-config.js';
const toObject = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value;
};
const readByPath = (value, path) => {
const normalizedPath = String(path || '').trim();
if (normalizedPath === '') {
return undefined;
}
const segments = normalizedPath.split('.').map((item) => item.trim()).filter(Boolean);
let current = value;
for (const segment of segments) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = current[segment];
}
return current;
};
export function createComponentRuntime(options = {}) {
const {
root = document,
configId = 'app-components',
} = options;
const host = resolveHost(root);
const pageConfig = toObject(readPageConfig(configId, document) || {});
const componentRegistry = new Map();
const register = (name, init, componentOptions = {}) => {
const key = String(name || '').trim();
if (key === '') {
warnOnce('UI_COMPONENT_INVALID', 'Cannot register component without name', { module: 'component-runtime' });
return false;
}
if (typeof init !== 'function') {
warnOnce('UI_COMPONENT_INVALID', `Component "${key}" init is not a function`, { module: 'component-runtime', component: key });
return false;
}
componentRegistry.set(key, {
name: key,
init,
options: toObject(componentOptions),
api: null,
});
return true;
};
const mount = (name) => {
const key = String(name || '').trim();
const definition = componentRegistry.get(key);
if (!definition) {
return null;
}
const destroyDefinition = () => {
if (Array.isArray(definition.api)) {
definition.api.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
definition.api = null;
return;
}
if (definition.api && typeof definition.api.destroy === 'function') {
definition.api.destroy();
}
definition.api = null;
};
destroyDefinition();
const configPath = String(definition.options.configPath || '').trim();
const fallbackConfig = toObject(definition.options.defaultConfig || {});
const resolvedConfig = toObject(
configPath !== '' ? readByPath(pageConfig, configPath) : undefined
);
const mergedConfig = { ...fallbackConfig, ...resolvedConfig };
const selector = String(definition.options.selector || '').trim();
const scope = String(definition.options.scope || '').trim().toLowerCase();
const targets = scope === 'global' || selector === ''
? [host]
: Array.from(host.querySelectorAll(selector));
if (targets.length === 0) {
return null;
}
const instances = [];
targets.forEach((target) => {
const api = definition.init(target, mergedConfig);
if (!api || typeof api.destroy !== 'function') {
warnOnce(
'UI_COMPONENT_CONTRACT',
`Component "${key}" must return an API with destroy()`,
{ module: 'component-runtime', component: key }
);
return;
}
instances.push(api);
});
if (instances.length === 0) {
return null;
}
definition.api = instances;
return instances.length === 1 ? instances[0] : instances;
};
const mountAll = () => {
componentRegistry.forEach((_, key) => {
mount(key);
});
};
const destroy = (name = null) => {
const destroyDefinition = (definition) => {
if (Array.isArray(definition.api)) {
definition.api.forEach((instance) => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
} else if (definition.api && typeof definition.api.destroy === 'function') {
definition.api.destroy();
}
definition.api = null;
};
if (name !== null) {
const key = String(name || '').trim();
const definition = componentRegistry.get(key);
if (definition) {
destroyDefinition(definition);
}
return;
}
componentRegistry.forEach((definition) => {
destroyDefinition(definition);
});
};
return {
register,
mount,
mountAll,
destroy,
getPageConfig: () => pageConfig,
};
}