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

@@ -1,7 +1,7 @@
/**
* Real-time password validation hints — checks length, uppercase, digit, special char.
*/
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const rules = {
min: (value, min) => value.length >= min,
@@ -19,9 +19,15 @@ const rules = {
}
};
export function initPasswordHints() {
const containers = document.querySelectorAll('[data-password-hints]');
if (!containers.length) {return;}
export function initPasswordHints(root = document, options = {}) {
const selector = String(options.selector || '[data-password-hints]').trim() || '[data-password-hints]';
const host = resolveHost(root);
const containers = Array.from(host.querySelectorAll(selector));
if (!containers.length) {
return { destroy: () => {} };
}
const cleanupFns = [];
containers.forEach((container) => {
if (!(container instanceof HTMLElement)) {return;}
@@ -30,9 +36,10 @@ export function initPasswordHints() {
const passwordSelector = container.dataset.passwordInput;
const confirmSelector = container.dataset.confirmInput;
const emailSelector = container.dataset.emailInput;
const passwordInput = passwordSelector ? document.querySelector(passwordSelector) : null;
const confirmInput = confirmSelector ? document.querySelector(confirmSelector) : null;
const emailInput = emailSelector ? document.querySelector(emailSelector) : null;
const scope = document;
const passwordInput = passwordSelector ? scope.querySelector(passwordSelector) : null;
const confirmInput = confirmSelector ? scope.querySelector(confirmSelector) : null;
const emailInput = emailSelector ? scope.querySelector(emailSelector) : null;
if (passwordSelector && !passwordInput) {
warnOnce('UI_EL_MISSING', `Missing password input: ${passwordSelector}`, { module: 'password-hints' });
}
@@ -79,16 +86,28 @@ export function initPasswordHints() {
});
};
if (passwordInput) {passwordInput.addEventListener('input', evaluate);}
if (confirmInput) {confirmInput.addEventListener('input', evaluate);}
if (emailInput) {emailInput.addEventListener('input', evaluate);}
if (passwordInput) {
passwordInput.addEventListener('input', evaluate);
cleanupFns.push(() => passwordInput.removeEventListener('input', evaluate));
}
if (confirmInput) {
confirmInput.addEventListener('input', evaluate);
cleanupFns.push(() => confirmInput.removeEventListener('input', evaluate));
}
if (emailInput) {
emailInput.addEventListener('input', evaluate);
cleanupFns.push(() => emailInput.removeEventListener('input', evaluate));
}
evaluate();
container.dataset.passwordHintsBound = '1';
cleanupFns.push(() => {
delete container.dataset.passwordHintsBound;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPasswordHints);
} else {
initPasswordHints();
const destroy = () => {
cleanupFns.forEach((cleanup) => cleanup());
};
return { destroy };
}