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:
168
web/js/core/app-component-runtime.js
Normal file
168
web/js/core/app-component-runtime.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
web/js/core/app-fslightbox-bridge.js
Normal file
21
web/js/core/app-fslightbox-bridge.js
Normal 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();
|
||||
};
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
45
web/js/core/app-ui-channels.js
Normal file
45
web/js/core/app-ui-channels.js
Normal 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);
|
||||
};
|
||||
};
|
||||
67
web/js/core/app-ui-storage.js
Normal file
67
web/js/core/app-ui-storage.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user