forked from fa/breadcrumb-the-shire
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>
691 lines
27 KiB
PHP
691 lines
27 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\App\Module;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Loads, validates and merges module manifests.
|
|
*
|
|
* Modules are discovered from a configurable directory and activated via an
|
|
* allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
|
|
*
|
|
* The registry is immutable after construction — all contributions are merged
|
|
* at boot time and available through typed getters.
|
|
*/
|
|
final class ModuleRegistry
|
|
{
|
|
/** @var array<string, list<string>> */
|
|
private const UI_SLOT_REQUIRED_KEYS = [
|
|
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
|
|
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
|
|
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
|
|
'topbar.right_item' => ['key', 'template'],
|
|
'layout.body_end_template' => ['key', 'template'],
|
|
'layout.head_style' => ['key', 'path'],
|
|
'runtime.component' => ['key', 'script'],
|
|
];
|
|
|
|
/** @var array<string, ModuleManifest> keyed by module id */
|
|
private array $modules = [];
|
|
|
|
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
|
|
private array $mergedRoutes = [];
|
|
|
|
/** @var list<string> merged public paths */
|
|
private array $mergedPublicPaths = [];
|
|
|
|
/** @var list<class-string> merged container registrar FQCNs */
|
|
private array $mergedContainerRegistrars = [];
|
|
|
|
/** @var array<string, list<mixed>> merged UI slots keyed by slot name */
|
|
private array $mergedUiSlots = [];
|
|
|
|
/** @var list<class-string> merged search resource provider FQCNs */
|
|
private array $mergedSearchResources = [];
|
|
|
|
/** @var array<string, list<string>> merged asset groups */
|
|
private array $mergedAssetGroups = [];
|
|
|
|
/** @var list<class-string> merged layout context provider FQCNs */
|
|
private array $mergedLayoutContextProviders = [];
|
|
|
|
/** @var list<class-string> merged session provider FQCNs */
|
|
private array $mergedSessionProviders = [];
|
|
|
|
/**
|
|
* @var list<array{
|
|
* key: string,
|
|
* description: string,
|
|
* active: int,
|
|
* is_system: int
|
|
* }> merged permissions
|
|
*/
|
|
private array $mergedPermissions = [];
|
|
|
|
/** @var list<class-string> merged authorization policy FQCNs */
|
|
private array $mergedAuthorizationPolicies = [];
|
|
|
|
/** @var array<string, string> merged layout capabilities: UI-key → ability */
|
|
private array $mergedLayoutCapabilities = [];
|
|
|
|
/**
|
|
* @var list<array{
|
|
* job_key: string,
|
|
* handler: string,
|
|
* label: string,
|
|
* description: string,
|
|
* default_enabled: int,
|
|
* default_timezone: string,
|
|
* default_schedule_type: string,
|
|
* default_schedule_interval: int,
|
|
* default_schedule_time: ?string,
|
|
* default_schedule_weekdays_csv: ?string,
|
|
* default_catchup_once: int,
|
|
* allowed_schedule_types: list<string>,
|
|
* module_id: string
|
|
* }>
|
|
*/
|
|
private array $mergedSchedulerJobs = [];
|
|
|
|
/** @var array<string, string> route target => owning module id */
|
|
private array $routeTargetOwners = [];
|
|
|
|
/** @var array<string, string> route path => owning module id */
|
|
private array $routePathOwners = [];
|
|
|
|
/** @var array<string, string> permission key => owning module id */
|
|
private array $permissionOwners = [];
|
|
|
|
/** @var array<string, string> scheduler job key => owning module id */
|
|
private array $schedulerJobOwners = [];
|
|
|
|
/**
|
|
* Boot the registry from a modules directory and activation config.
|
|
*
|
|
* @param string $modulesDir Absolute path to the modules/ directory
|
|
* @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
|
|
*/
|
|
public static function boot(string $modulesDir, array $enabledModuleIds): self
|
|
{
|
|
$registry = new self();
|
|
|
|
if (!is_dir($modulesDir)) {
|
|
return $registry;
|
|
}
|
|
|
|
// Discover and load manifests for enabled modules
|
|
$manifests = [];
|
|
foreach ($enabledModuleIds as $moduleId) {
|
|
$moduleId = trim($moduleId);
|
|
if ($moduleId === '') {
|
|
continue;
|
|
}
|
|
|
|
$modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
|
|
$manifestFile = $modulePath . '/module.php';
|
|
|
|
if (!is_file($manifestFile)) {
|
|
throw new RuntimeException(
|
|
"Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
|
|
);
|
|
}
|
|
|
|
$raw = require $manifestFile;
|
|
if (!is_array($raw)) {
|
|
throw new RuntimeException(
|
|
"Module manifest at '{$manifestFile}' must return an array."
|
|
);
|
|
}
|
|
|
|
$manifest = ModuleManifest::fromArray($raw, $modulePath);
|
|
|
|
if ($manifest->id !== $moduleId) {
|
|
throw new RuntimeException(
|
|
"Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
|
|
);
|
|
}
|
|
|
|
$manifests[] = $manifest;
|
|
}
|
|
|
|
// Sort deterministically: load_order ASC, then id ASC
|
|
usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
|
|
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
|
|
});
|
|
|
|
// Allow module-local classes (modules/<id>/lib/...) to autoload.
|
|
ModuleAutoloader::register($manifests);
|
|
|
|
// Register and merge
|
|
foreach ($manifests as $manifest) {
|
|
$registry->registerModule($manifest);
|
|
}
|
|
|
|
$registry->finalizeMergedContributions();
|
|
|
|
return $registry;
|
|
}
|
|
|
|
/**
|
|
* Resolve enabled module IDs from config array + ENV override.
|
|
*
|
|
* @param array{enabled_modules?: list<string>} $config From config/modules.php
|
|
* @return list<string>
|
|
*/
|
|
public static function resolveEnabledModules(array $config): array
|
|
{
|
|
$fromConfig = $config['enabled_modules'] ?? [];
|
|
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
|
|
|
|
// Semantics:
|
|
// - not set => use config/modules.php
|
|
// - set '' => explicit "no modules"
|
|
// - set list => exact list from env
|
|
if ($fromEnvRaw !== false) {
|
|
$fromEnv = trim((string) $fromEnvRaw);
|
|
$fromConfig = $fromEnv === ''
|
|
? []
|
|
: array_map('trim', explode(',', $fromEnv));
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
|
|
}
|
|
|
|
private function registerModule(ModuleManifest $manifest): void
|
|
{
|
|
if (isset($this->modules[$manifest->id])) {
|
|
throw new RuntimeException(
|
|
"Duplicate module id: '{$manifest->id}' is already registered."
|
|
);
|
|
}
|
|
|
|
$this->modules[$manifest->id] = $manifest;
|
|
|
|
// — Merge routes (fail-fast on collision) ——————————————
|
|
foreach ($manifest->routes as $rawRoute) {
|
|
$route = $this->normalizeRoute($rawRoute, $manifest->id);
|
|
$path = $route['path'];
|
|
$target = $route['target'];
|
|
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
|
|
$owner = $this->routeTargetOwners[$target];
|
|
throw new RuntimeException(
|
|
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
|
|
);
|
|
}
|
|
if (isset($this->routePathOwners[$path])) {
|
|
$owner = $this->routePathOwners[$path];
|
|
throw new RuntimeException(
|
|
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
|
|
);
|
|
}
|
|
$this->routeTargetOwners[$target] = $manifest->id;
|
|
$this->routePathOwners[$path] = $manifest->id;
|
|
$this->mergedRoutes[] = $route;
|
|
if (!empty($route['public'])) {
|
|
$this->mergedPublicPaths[] = $path;
|
|
}
|
|
}
|
|
|
|
// — Merge public paths ———————————————————————————
|
|
array_push($this->mergedPublicPaths, ...$manifest->publicPaths);
|
|
|
|
// — Merge container registrars ————————————————————
|
|
array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);
|
|
|
|
// — Merge UI slots (fail-fast on duplicate keys within same slot) ——
|
|
foreach ($manifest->uiSlots as $slotName => $contributions) {
|
|
if (!isset($this->mergedUiSlots[$slotName])) {
|
|
$this->mergedUiSlots[$slotName] = [];
|
|
}
|
|
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
|
foreach ($contributionList as $contribution) {
|
|
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id);
|
|
$key = (string) $normalizedContribution['key'];
|
|
foreach ($this->mergedUiSlots[$slotName] as $existing) {
|
|
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
|
|
if ($existingKey === $key) {
|
|
throw new RuntimeException(
|
|
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
|
|
);
|
|
}
|
|
}
|
|
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
|
|
}
|
|
}
|
|
|
|
// — Merge search resources (fail-fast on duplicate provider) ——
|
|
foreach ($manifest->searchResources as $provider) {
|
|
if (!is_string($provider) || trim($provider) === '') {
|
|
throw new RuntimeException(
|
|
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
|
|
);
|
|
}
|
|
if (in_array($provider, $this->mergedSearchResources, true)) {
|
|
throw new RuntimeException(
|
|
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
|
|
);
|
|
}
|
|
$this->mergedSearchResources[] = $provider;
|
|
}
|
|
|
|
// — Merge asset groups ————————————————————————————
|
|
foreach ($manifest->assetGroups as $groupName => $files) {
|
|
if (isset($this->mergedAssetGroups[$groupName])) {
|
|
throw new RuntimeException(
|
|
"Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
|
|
);
|
|
}
|
|
$this->mergedAssetGroups[$groupName] = $files;
|
|
}
|
|
|
|
// — Merge permissions (fail-fast on duplicate) ———————
|
|
foreach ($manifest->permissions as $permission) {
|
|
$permissionKey = trim((string) $permission['key']);
|
|
if ($permissionKey === '') {
|
|
throw new RuntimeException(
|
|
"Permission in module '{$manifest->id}' must define non-empty 'key'."
|
|
);
|
|
}
|
|
|
|
if (isset($this->permissionOwners[$permissionKey])) {
|
|
$owner = $this->permissionOwners[$permissionKey];
|
|
throw new RuntimeException(
|
|
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
|
);
|
|
}
|
|
|
|
$this->permissionOwners[$permissionKey] = $manifest->id;
|
|
$this->mergedPermissions[] = [
|
|
'key' => $permissionKey,
|
|
'description' => trim((string) $permission['description']),
|
|
'active' => (int) $permission['active'],
|
|
'is_system' => (int) $permission['is_system'],
|
|
];
|
|
}
|
|
|
|
// — Merge layout context providers ————————————————
|
|
foreach ($manifest->layoutContextProviders as $providerClass) {
|
|
if (!is_string($providerClass) || trim($providerClass) === '') {
|
|
throw new RuntimeException(
|
|
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
|
|
);
|
|
}
|
|
$this->mergedLayoutContextProviders[] = $providerClass;
|
|
}
|
|
|
|
// — Merge session providers ——————————————————————
|
|
foreach ($manifest->sessionProviders as $providerClass) {
|
|
if (!is_string($providerClass) || trim($providerClass) === '') {
|
|
throw new RuntimeException(
|
|
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
|
|
);
|
|
}
|
|
$this->mergedSessionProviders[] = $providerClass;
|
|
}
|
|
|
|
// — Merge authorization policies (fail-fast on duplicate) ———
|
|
foreach ($manifest->authorizationPolicies as $policyClass) {
|
|
if (!is_string($policyClass) || trim($policyClass) === '') {
|
|
throw new RuntimeException(
|
|
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
|
|
);
|
|
}
|
|
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
|
|
throw new RuntimeException(
|
|
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
|
|
);
|
|
}
|
|
$this->mergedAuthorizationPolicies[] = $policyClass;
|
|
}
|
|
|
|
// — Merge layout capabilities (fail-fast on duplicate key) ———
|
|
foreach ($manifest->layoutCapabilities as $uiKey => $ability) {
|
|
$uiKey = trim((string) $uiKey);
|
|
$ability = trim((string) $ability);
|
|
if ($uiKey === '' || $ability === '') {
|
|
throw new RuntimeException(
|
|
"Layout capability in module '{$manifest->id}' must have non-empty key and ability."
|
|
);
|
|
}
|
|
if (isset($this->mergedLayoutCapabilities[$uiKey])) {
|
|
throw new RuntimeException(
|
|
"Layout capability conflict: key '{$uiKey}' from module '{$manifest->id}' is already registered."
|
|
);
|
|
}
|
|
$this->mergedLayoutCapabilities[$uiKey] = $ability;
|
|
}
|
|
|
|
// — Merge scheduler jobs —————————————————————————
|
|
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
|
$jobKey = trim((string) $schedulerJob['job_key']);
|
|
if ($jobKey === '') {
|
|
throw new RuntimeException(
|
|
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
|
|
);
|
|
}
|
|
if (isset($this->schedulerJobOwners[$jobKey])) {
|
|
$owner = $this->schedulerJobOwners[$jobKey];
|
|
throw new RuntimeException(
|
|
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
|
);
|
|
}
|
|
|
|
$this->schedulerJobOwners[$jobKey] = $manifest->id;
|
|
$this->mergedSchedulerJobs[] = [
|
|
'job_key' => $jobKey,
|
|
'handler' => trim((string) $schedulerJob['handler']),
|
|
'label' => trim((string) $schedulerJob['label']),
|
|
'description' => trim((string) $schedulerJob['description']),
|
|
'default_enabled' => (int) $schedulerJob['default_enabled'],
|
|
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
|
|
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
|
|
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
|
|
'default_schedule_time' => $schedulerJob['default_schedule_time'],
|
|
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
|
|
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
|
|
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
|
|
'module_id' => $manifest->id,
|
|
];
|
|
}
|
|
}
|
|
|
|
private function finalizeMergedContributions(): void
|
|
{
|
|
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
|
$this->mergedPublicPaths,
|
|
static fn (mixed $path): bool => trim((string) $path) !== ''
|
|
)));
|
|
|
|
foreach ($this->mergedUiSlots as $slotName => $items) {
|
|
usort($items, static function (array $a, array $b): int {
|
|
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
|
|
if ($orderCmp !== 0) {
|
|
return $orderCmp;
|
|
}
|
|
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
|
|
if ($moduleCmp !== 0) {
|
|
return $moduleCmp;
|
|
}
|
|
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
|
});
|
|
|
|
foreach ($items as &$item) {
|
|
unset($item['__module_id']);
|
|
}
|
|
unset($item);
|
|
|
|
$this->mergedUiSlots[$slotName] = $items;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{path: string, target: string, module_id: string, public?: bool}
|
|
*/
|
|
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
|
|
{
|
|
if (!is_array($rawRoute)) {
|
|
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
|
|
}
|
|
|
|
$path = trim((string) ($rawRoute['path'] ?? ''));
|
|
$target = trim((string) ($rawRoute['target'] ?? ''));
|
|
if ($path === '' || $target === '') {
|
|
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
|
|
}
|
|
|
|
$normalized = [
|
|
'path' => $path,
|
|
'target' => $target,
|
|
'module_id' => $moduleId,
|
|
];
|
|
|
|
if (array_key_exists('public', $rawRoute)) {
|
|
$normalized['public'] = (bool) $rawRoute['public'];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId): array
|
|
{
|
|
if (!is_array($rawContribution)) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
|
|
);
|
|
}
|
|
|
|
$key = trim((string) ($rawContribution['key'] ?? ''));
|
|
if ($key === '') {
|
|
throw new RuntimeException(
|
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
|
|
);
|
|
}
|
|
|
|
foreach (self::UI_SLOT_REQUIRED_KEYS[$slotName] ?? [] as $requiredKey) {
|
|
$requiredValue = trim((string) ($rawContribution[$requiredKey] ?? ''));
|
|
if ($requiredValue === '') {
|
|
throw new RuntimeException(
|
|
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' is missing required key '{$requiredKey}'."
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->validateUiSlotContributionByType($slotName, $rawContribution, $moduleId, $key);
|
|
|
|
$normalized = $rawContribution;
|
|
$normalized['key'] = $key;
|
|
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
|
|
$normalized['__module_id'] = $moduleId;
|
|
$normalized['module_id'] = $moduleId;
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawContribution
|
|
*/
|
|
private function validateUiSlotContributionByType(
|
|
string $slotName,
|
|
array $rawContribution,
|
|
string $moduleId,
|
|
string $slotKey
|
|
): void {
|
|
if (in_array($slotName, ['aside.tab_panel', 'topbar.right_item', 'layout.body_end_template'], true)) {
|
|
$templatePath = trim((string) ($rawContribution['panel_template'] ?? $rawContribution['template'] ?? ''));
|
|
if ($templatePath === '' || !is_file($templatePath)) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot '{$slotName}' in module '{$moduleId}' references missing template '{$templatePath}'."
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($slotName === 'layout.head_style') {
|
|
$path = trim((string) ($rawContribution['path'] ?? ''));
|
|
if ($path === '' || str_contains($path, '..')) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'layout.head_style' in module '{$moduleId}' has invalid path '{$path}'."
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($slotName === 'user.edit.aside_action') {
|
|
$type = strtolower(trim((string) ($rawContribution['type'] ?? 'link')));
|
|
if ($type !== 'link' && $type !== 'form') {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' has invalid type '{$type}'."
|
|
);
|
|
}
|
|
if ($type === 'form') {
|
|
$action = trim((string) ($rawContribution['action'] ?? ''));
|
|
if ($action === '') {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'action' for type=form."
|
|
);
|
|
}
|
|
} else {
|
|
$href = trim((string) ($rawContribution['href'] ?? ''));
|
|
if ($href === '') {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'href' for type=link."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($slotName === 'runtime.component') {
|
|
$script = trim((string) ($rawContribution['script'] ?? ''));
|
|
if ($script === '' || str_contains($script, '..')) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid script '{$script}'."
|
|
);
|
|
}
|
|
|
|
$phase = strtolower(trim((string) ($rawContribution['phase'] ?? 'late')));
|
|
if (!in_array($phase, ['early', 'default', 'late'], true)) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid phase '{$phase}'."
|
|
);
|
|
}
|
|
|
|
$configPath = trim((string) ($rawContribution['config_path'] ?? ''));
|
|
if ($configPath !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/', $configPath)) {
|
|
throw new RuntimeException(
|
|
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid config_path '{$configPath}'."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Getters ────────────────────────────────────────────────────────
|
|
|
|
/** @return array<string, ModuleManifest> */
|
|
public function getModules(): array
|
|
{
|
|
return $this->modules;
|
|
}
|
|
|
|
public function hasModule(string $id): bool
|
|
{
|
|
return isset($this->modules[$id]);
|
|
}
|
|
|
|
public function getModule(string $id): ModuleManifest
|
|
{
|
|
if (!isset($this->modules[$id])) {
|
|
throw new RuntimeException("Module '{$id}' is not registered.");
|
|
}
|
|
return $this->modules[$id];
|
|
}
|
|
|
|
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
|
|
public function getRoutes(): array
|
|
{
|
|
return $this->mergedRoutes;
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function getPublicPaths(): array
|
|
{
|
|
return $this->mergedPublicPaths;
|
|
}
|
|
|
|
/** @return list<class-string> */
|
|
public function getContainerRegistrars(): array
|
|
{
|
|
return $this->mergedContainerRegistrars;
|
|
}
|
|
|
|
/** @return array<string, list<mixed>> */
|
|
public function getUiSlots(): array
|
|
{
|
|
return $this->mergedUiSlots;
|
|
}
|
|
|
|
/**
|
|
* @return list<mixed> Contributions for a specific slot
|
|
*/
|
|
public function getSlotContributions(string $slotName): array
|
|
{
|
|
return $this->mergedUiSlots[$slotName] ?? [];
|
|
}
|
|
|
|
/** @return list<class-string> */
|
|
public function getSearchResources(): array
|
|
{
|
|
return $this->mergedSearchResources;
|
|
}
|
|
|
|
/** @return array<string, list<string>> */
|
|
public function getAssetGroups(): array
|
|
{
|
|
return $this->mergedAssetGroups;
|
|
}
|
|
|
|
/** @return list<class-string> */
|
|
public function getLayoutContextProviders(): array
|
|
{
|
|
return $this->mergedLayoutContextProviders;
|
|
}
|
|
|
|
/** @return list<class-string> */
|
|
public function getSessionProviders(): array
|
|
{
|
|
return $this->mergedSessionProviders;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
|
*/
|
|
public function getPermissions(): array
|
|
{
|
|
return $this->mergedPermissions;
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function getPermissionKeys(): array
|
|
{
|
|
return array_values(array_map(
|
|
static fn (array $permission): string => (string) $permission['key'],
|
|
$this->mergedPermissions
|
|
));
|
|
}
|
|
|
|
/** @return list<class-string> */
|
|
public function getAuthorizationPolicies(): array
|
|
{
|
|
return $this->mergedAuthorizationPolicies;
|
|
}
|
|
|
|
/** @return array<string, string> UI-capability-key → ability-string */
|
|
public function getLayoutCapabilities(): array
|
|
{
|
|
return $this->mergedLayoutCapabilities;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* job_key: string,
|
|
* handler: string,
|
|
* label: string,
|
|
* description: string,
|
|
* default_enabled: int,
|
|
* default_timezone: string,
|
|
* default_schedule_type: string,
|
|
* default_schedule_interval: int,
|
|
* default_schedule_time: ?string,
|
|
* default_schedule_weekdays_csv: ?string,
|
|
* default_catchup_once: int,
|
|
* allowed_schedule_types: list<string>,
|
|
* module_id: string
|
|
* }>
|
|
*/
|
|
public function getSchedulerJobs(): array
|
|
{
|
|
return $this->mergedSchedulerJobs;
|
|
}
|
|
}
|