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

@@ -498,9 +498,27 @@ function sortByDescription(array &$items): void
*/
function layoutHasAdminPanel(array $layoutAuth): bool
{
// Collect capability keys contributed by modules — these are non-admin
// capabilities that should not trigger the admin panel visibility.
$moduleCapabilities = [];
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
foreach ($registry->getUiSlots() as $contributions) {
foreach ($contributions as $contribution) {
$perm = is_array($contribution) ? trim((string) ($contribution['permission'] ?? '')) : '';
if ($perm !== '') {
$moduleCapabilities[$perm] = true;
}
}
}
} catch (\Throwable) {
// fail-open: if module registry is not available, skip module capabilities
}
$capabilityKeys = array_keys(\MintyPHP\Service\Access\UiCapabilityMap::LAYOUT);
foreach ($capabilityKeys as $key) {
if ($key === 'can_view_address_book') {
if (isset($moduleCapabilities[$key])) {
continue;
}
if (!empty($layoutAuth[$key])) {
@@ -540,43 +558,63 @@ function appNormalizePositiveIntList(mixed $value): array
}
/**
* Normalize address-book custom field query keys/values.
* Reserved top-level keys in layout navigation context that modules must not override.
*
* @param array<string, mixed> $rawQuery
* @return array<string, string|list<int>>
* @return list<string>
*/
function appNormalizeAddressBookCustomFilterQuery(array $rawQuery): array
function appLayoutNavReservedKeys(): array
{
$normalized = [];
foreach ($rawQuery as $rawKey => $rawValue) {
$key = strtolower(trim((string) $rawKey));
return [
'hasAdminPanel',
'moduleSlots',
'currentTenant',
'availableTenants',
'tenantQueryParam',
'tenantAvatar',
'csrfKey',
'csrfToken',
];
}
/**
* Merge one module layout-provider payload into layout navigation context.
*
* Provider keys are contract-validated and must be namespaced (`module.key`).
*
* @param array<string, mixed> $layoutNav
* @param array<string, mixed> $providerData
* @return array<string, mixed>
*/
function appMergeLayoutNavProviderData(array $layoutNav, array $providerData, string $providerClass): array
{
$reserved = array_fill_keys(appLayoutNavReservedKeys(), true);
foreach ($providerData as $rawKey => $value) {
$key = trim((string) $rawKey);
if ($key === '') {
continue;
throw new \RuntimeException(
"Layout context provider '{$providerClass}' returned an empty top-level key."
);
}
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
$value = trim((string) $rawValue);
if ($value !== '') {
$normalized[$key] = $value;
}
continue;
if (isset($reserved[$key])) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' must not override reserved layout key '{$key}'."
);
}
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
$ids = appNormalizePositiveIntList($rawValue);
if ($ids) {
$normalized[$key] = $ids;
}
continue;
if (array_key_exists($key, $layoutNav)) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' collides on existing layout key '{$key}'."
);
}
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
$value = trim((string) $rawValue);
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
if ($dt && $dt->format('Y-m-d') === $value) {
$normalized[$key] = $value;
}
if (!preg_match('/^[a-z0-9]+[a-z0-9._-]*$/', $key) || !str_contains($key, '.')) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' key '{$key}' must be namespaced (e.g. '<module_id>.data')."
);
}
$layoutNav[$key] = $value;
}
ksort($normalized, SORT_STRING);
return $normalized;
return $layoutNav;
}
/**
@@ -628,9 +666,6 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
'name' => $tenantName,
'hasAvatar' => $tenantHasAvatar,
],
'bookmarks' => is_array($session['user_bookmarks'] ?? null)
? $session['user_bookmarks']
: ['groups' => [], 'ungrouped' => []],
'csrfKey' => $csrfKey,
'csrfToken' => $csrfToken,
];
@@ -641,19 +676,30 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $moduleRegistry */
$moduleRegistry = app(\MintyPHP\App\Module\ModuleRegistry::class);
/** @var \MintyPHP\App\AppContainer $container */
$container = $GLOBALS['minty_app_container'];
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$provider = new $providerClass();
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
$layoutNav = array_merge($layoutNav, $provider->provide($session, $container));
}
}
} catch (\Throwable) {
// fail-open: if module registry is not available, skip module providers
return $layoutNav;
}
$container = $GLOBALS['minty_app_container'] ?? null;
if (!$container instanceof \MintyPHP\App\AppContainer) {
return $layoutNav;
}
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$provider = new $providerClass();
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
$providerData = $provider->provide($session, $container);
if (!is_array($providerData)) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' must return an array."
);
}
$layoutNav = appMergeLayoutNavProviderData($layoutNav, $providerData, $providerClass);
}
}
return $layoutNav;