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>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -0,0 +1,168 @@
/**
* 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,
};
}

View File

@@ -1,6 +1,8 @@
/**
* Persists open/closed state of details elements to localStorage by key.
*/
import { createUiStorage } from './app-ui-storage.js';
const toElement = (value) => {
if (value instanceof HTMLElement) {
return value;
@@ -17,12 +19,12 @@ const normalizeKeys = (value) => Array.from(new Set(
.filter(Boolean)
));
const readStoredKeys = (storageKey) => {
if (!storageKey) {
const readStoredKeys = (storage, scopedStorageKey) => {
if (!scopedStorageKey) {
return [];
}
try {
const raw = window.localStorage.getItem(storageKey);
const raw = storage.getItem(scopedStorageKey);
if (!raw) {
return [];
}
@@ -33,12 +35,12 @@ const readStoredKeys = (storageKey) => {
}
};
const writeStoredKeys = (storageKey, keys) => {
if (!storageKey) {
const writeStoredKeys = (storage, scopedStorageKey, keys) => {
if (!scopedStorageKey) {
return;
}
try {
window.localStorage.setItem(storageKey, JSON.stringify(normalizeKeys(keys)));
storage.setItem(scopedStorageKey, JSON.stringify(normalizeKeys(keys)));
} catch (error) {
// ignore storage errors
}
@@ -61,6 +63,15 @@ export function initPersistedDetailsGroup(options = {}) {
if (storageKey === '') {
return null;
}
const storage = createUiStorage({
namespace: options.storageNamespace || 'app-ui',
version: options.storageVersion || 'v1',
scope: options.storageScope || 'details-open',
});
const scopedStorageKey = storage.buildKey('state', storageKey);
if (!scopedStorageKey) {
return null;
}
const detailsSelector = String(options.detailsSelector || 'details[data-details-key]').trim();
const alwaysOpenSelector = String(options.alwaysOpenSelector || 'details[data-details-always-open]').trim();
@@ -119,7 +130,7 @@ export function initPersistedDetailsGroup(options = {}) {
};
const writeOpenKeys = (keys) => {
writeStoredKeys(storageKey, mergeOpenKeys(keys));
writeStoredKeys(storage, scopedStorageKey, mergeOpenKeys(keys));
};
const persistCurrentState = () => {
@@ -142,7 +153,7 @@ export function initPersistedDetailsGroup(options = {}) {
};
const apply = () => {
const storedKeys = readStoredKeys(storageKey);
const storedKeys = readStoredKeys(storage, scopedStorageKey);
const ensureKeys = resolveEnsureKeys(ensureOpenKeysSource);
if (storedKeys.length > 0) {
@@ -224,7 +235,7 @@ export function initPersistedDetailsGroup(options = {}) {
details.removeEventListener('toggle', onAlwaysOpenToggle);
});
},
readOpenKeys: () => readStoredKeys(storageKey),
readOpenKeys: () => readStoredKeys(storage, scopedStorageKey),
writeOpenKeys,
};
}

View File

@@ -55,3 +55,11 @@ export const requireAll = (selector, details = {}) => {
};
export const optionalEl = (selector) => document.querySelector(selector);
/**
* Resolve a mount root to a valid query host.
* Components receive a `root` parameter that may be document, an element, or undefined.
* Returns the root if it supports querySelectorAll, otherwise falls back to document.
*/
export const resolveHost = (root) =>
root && typeof root.querySelectorAll === 'function' ? root : document;

View File

@@ -0,0 +1,21 @@
/**
* Shared bridge for scheduling fsLightbox refresh calls without global window APIs.
*/
let refreshPending = false;
export const requestFsLightboxRefresh = () => {
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
refreshPending = false;
return true;
}
refreshPending = true;
return false;
};
export const flushPendingFsLightboxRefresh = () => {
if (!refreshPending) {
return false;
}
return requestFsLightboxRefresh();
};

View File

@@ -1,5 +1,7 @@
import { warnOnce } from './app-dom.js';
import { confirmDialog } from './app-confirm-dialog.js';
import { requestFsLightboxRefresh } from './app-fslightbox-bridge.js';
import { createUiStorage } from './app-ui-storage.js';
import { gridFiltersFromSchema, withCurrentListReturn } from '../pages/app-list-utils.js';
import { initListFilterExperience } from '../pages/app-list-filter-experience.js';
import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js';
@@ -29,30 +31,6 @@ const normalizePageSizeOptions = (options) => {
return normalized.length ? normalized : [...DEFAULT_PAGE_SIZE_OPTIONS];
};
const safeStorageGet = (key) => {
const storageKey = String(key || '').trim();
if (storageKey === '') {
return null;
}
try {
return window.localStorage.getItem(storageKey);
} catch {
return null;
}
};
const safeStorageSet = (key, value) => {
const storageKey = String(key || '').trim();
if (storageKey === '') {
return;
}
try {
window.localStorage.setItem(storageKey, value);
} catch {
// localStorage can be unavailable; fail safe.
}
};
const escapeHtmlAttr = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
@@ -217,6 +195,14 @@ export function createServerGrid(options) {
const pageSizeStorageKey = pageSizeEnabled
? (String(pageSizeConfig.storageKey || autoPageSizeStorageKey).trim() || autoPageSizeStorageKey)
: '';
const pageSizeStorage = createUiStorage({
namespace: pageSizeConfig.storageNamespace || 'app-ui',
version: pageSizeConfig.storageVersion || 'v1',
scope: pageSizeConfig.storageScope || 'grid-page-size',
});
const scopedPageSizeStorageKey = pageSizeEnabled
? pageSizeStorage.buildKey('state', pageSizeStorageKey)
: '';
const parseAllowedLimit = (value) => {
if (!pageSizeEnabled) {
return parsePositiveInt(value);
@@ -243,13 +229,15 @@ export function createServerGrid(options) {
if (urlLimit !== null) {
currentLimit = urlLimit;
} else if (pageSizeEnabled) {
const storedLimit = parseAllowedLimit(safeStorageGet(pageSizeStorageKey));
const storedLimit = parseAllowedLimit(
scopedPageSizeStorageKey ? pageSizeStorage.getItem(scopedPageSizeStorageKey) : null
);
if (storedLimit !== null) {
currentLimit = storedLimit;
}
}
if (pageSizeEnabled) {
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
let currentPage = urlStateConfig.syncPage ? parsePage(params.get(pageParam)) : 1;
let currentSort = null;
@@ -680,8 +668,8 @@ export function createServerGrid(options) {
const nextPage = Number.isFinite(parsedPage) && parsedPage >= 0 ? parsedPage : 0;
const nextLimit = parsedLimit ?? currentLimit;
currentLimit = nextLimit;
if (pageSizeEnabled) {
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
currentPage = nextPage + 1;
if (urlSync && (urlStateConfig.syncPage || syncLimit)) {
@@ -1060,7 +1048,9 @@ export function createServerGrid(options) {
}
currentLimit = nextLimit;
currentPage = 1;
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
syncPageSizeControl();
updateGrid({ resetPage: true, urlHistoryMode: 'push' });
});
@@ -1258,7 +1248,9 @@ export function createServerGrid(options) {
const nextLimit = parseAllowedLimit(state?.limit);
if (nextLimit !== null) {
currentLimit = nextLimit;
safeStorageSet(pageSizeStorageKey, String(currentLimit));
if (scopedPageSizeStorageKey) {
pageSizeStorage.setItem(scopedPageSizeStorageKey, String(currentLimit));
}
syncPageSizeControl();
}
};
@@ -1312,15 +1304,7 @@ export function createServerGrid(options) {
grid.on('ready', () => {
initSelectAll();
setTimeout(() => {
if (typeof window.requestFsLightboxRefresh === 'function') {
window.requestFsLightboxRefresh();
return;
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
} else {
window.__fsLightboxRefreshPending = true;
}
requestFsLightboxRefresh();
}, 0);
});

View File

@@ -0,0 +1,45 @@
/**
* Shared UI event channels for cross-component communication.
*/
const SIDEBAR_EVENT = 'app:sidebar';
const ASIDE_PANELS_EVENT = 'app:aside-panels';
const dispatch = (eventName, detail = {}) => {
document.dispatchEvent(new CustomEvent(eventName, { detail }));
};
export const requestSidebarAction = (action, options = {}) => {
const normalizedAction = String(action || '').trim();
if (normalizedAction === '') {
return;
}
dispatch(SIDEBAR_EVENT, {
action: normalizedAction,
persist: Boolean(options.persist),
});
};
export const requestAsidePanelOpen = (panel, options = {}) => {
const normalizedPanel = String(panel || '').trim();
if (normalizedPanel === '') {
return;
}
dispatch(ASIDE_PANELS_EVENT, {
action: 'open',
panel: normalizedPanel,
fromUser: options.fromUser !== false,
});
};
export const bindAsidePanelChannel = (handler) => {
if (typeof handler !== 'function') {
return () => {};
}
const listener = (event) => {
handler(event instanceof CustomEvent ? event.detail || {} : {});
};
document.addEventListener(ASIDE_PANELS_EVENT, listener);
return () => {
document.removeEventListener(ASIDE_PANELS_EVENT, listener);
};
};

View File

@@ -0,0 +1,67 @@
/**
* Namespaced localStorage helper for UI components.
*/
const safeStorageGet = (key) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return null;
}
try {
return window.localStorage.getItem(normalized);
} catch {
return null;
}
};
const safeStorageSet = (key, value) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return;
}
try {
window.localStorage.setItem(normalized, value);
} catch {
// localStorage may be unavailable; fail safe.
}
};
const safeStorageRemove = (key) => {
const normalized = String(key || '').trim();
if (normalized === '') {
return;
}
try {
window.localStorage.removeItem(normalized);
} catch {
// localStorage may be unavailable; fail safe.
}
};
const normalizeToken = (value) => String(value ?? '')
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9._-]+/g, '-')
.replaceAll(/^-+|-+$/g, '');
export function createUiStorage(options = {}) {
const namespace = normalizeToken(options.namespace || 'app-ui') || 'app-ui';
const version = normalizeToken(options.version || 'v1') || 'v1';
const scope = normalizeToken(options.scope || '') || 'global';
const basePrefix = `${namespace}:${version}:${scope}`;
const buildKey = (bucket, key) => {
const normalizedBucket = normalizeToken(bucket || 'component') || 'component';
const normalizedKey = normalizeToken(key || '');
if (normalizedKey === '') {
return '';
}
return `${basePrefix}:${normalizedBucket}:${normalizedKey}`;
};
return {
buildKey,
getItem: safeStorageGet,
setItem: safeStorageSet,
removeItem: safeStorageRemove,
};
}