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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user