1
0

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

@@ -35,7 +35,22 @@ final class ModuleManifest
/** @var array<string, list<string>> Asset group name → file list */
public readonly array $assetGroups;
/** @var list<array{name: string, handler: string, schedule?: string}> */
/**
* @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>
* }>
*/
public readonly array $schedulerJobs;
/** @var list<class-string> LayoutContextProvider FQCNs */
@@ -44,9 +59,22 @@ final class ModuleManifest
/** @var list<class-string> SessionProvider FQCNs */
public readonly array $sessionProviders;
/** @var list<string> Permission keys (e.g. 'address_book.view') */
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }>
*/
public readonly array $permissions;
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
public readonly array $authorizationPolicies;
/** @var array<string, string> UI-capability-key → ability-string for layout authorization */
public readonly array $layoutCapabilities;
public readonly ?string $migrationsPath;
public readonly string $basePath;
@@ -76,10 +104,12 @@ final class ModuleManifest
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
$this->searchResources = self::listOf($raw, 'search_resources');
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
$this->schedulerJobs = self::arrayOf($raw, 'scheduler_jobs');
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
$this->sessionProviders = self::listOf($raw, 'session_providers');
$this->permissions = self::listOf($raw, 'permissions');
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
$this->layoutCapabilities = is_array($raw['layout_capabilities'] ?? null) ? $raw['layout_capabilities'] : [];
$migrationsPath = $raw['migrations_path'] ?? null;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
@@ -112,4 +142,224 @@ final class ModuleManifest
$value = $raw[$key] ?? [];
return is_array($value) ? $value : [];
}
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
private static function normalizePermissions(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
);
}
$permissions = [];
foreach (array_values($value) as $index => $permission) {
if (!is_array($permission)) {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
);
}
$key = trim((string) ($permission['key'] ?? ''));
$description = trim((string) ($permission['description'] ?? ''));
if ($key === '' || $description === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
);
}
$permissions[] = [
'key' => $key,
'description' => $description,
'active' => (int) ((bool) ($permission['active'] ?? true)),
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
];
}
return $permissions;
}
/**
* @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>
* }>
*/
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
);
}
$jobs = [];
foreach (array_values($value) as $index => $job) {
if (!is_array($job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
);
}
$requiredKeys = [
'job_key',
'handler',
'label',
'description',
'default_enabled',
'default_timezone',
'default_schedule_type',
'default_schedule_interval',
'default_schedule_time',
'default_schedule_weekdays_csv',
'default_catchup_once',
'allowed_schedule_types',
];
foreach ($requiredKeys as $requiredKey) {
if (!array_key_exists($requiredKey, $job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
);
}
}
$jobKey = trim((string) ($job['job_key'] ?? ''));
$handler = trim((string) ($job['handler'] ?? ''));
$label = trim((string) ($job['label'] ?? ''));
$description = trim((string) ($job['description'] ?? ''));
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
$moduleId,
$index
)
);
}
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
);
}
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
if (!in_array($defaultType, $allowedTypes, true)) {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
$moduleId,
$index,
$defaultType
)
);
}
$defaultInterval = (int) $job['default_schedule_interval'];
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
$defaultWeekdays = self::normalizeWeekdaysCsv(
$job['default_schedule_weekdays_csv'],
$moduleId,
$index
);
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
);
}
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
);
}
$jobs[] = [
'job_key' => $jobKey,
'handler' => $handler,
'label' => $label,
'description' => $description,
'default_enabled' => (int) ((bool) $job['default_enabled']),
'default_timezone' => $defaultTimezone,
'default_schedule_type' => $defaultType,
'default_schedule_interval' => $defaultInterval,
'default_schedule_time' => $defaultTime,
'default_schedule_weekdays_csv' => $defaultWeekdays,
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
'allowed_schedule_types' => $allowedTypes,
];
}
return $jobs;
}
/**
* @return list<string>
*/
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
{
if (!is_array($value) || $value === []) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
);
}
$types = [];
foreach ($value as $rawType) {
$type = strtolower(trim((string) $rawType));
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
);
}
$types[] = $type;
}
$types = array_values(array_unique($types));
sort($types, SORT_STRING);
return $types;
}
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
if ($days === []) {
return null;
}
$normalizedDays = [];
foreach ($days as $day) {
if (!preg_match('/^[1-7]$/', $day)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
);
}
$normalizedDays[] = (int) $day;
}
$normalizedDays = array_values(array_unique($normalizedDays));
sort($normalizedDays, SORT_NUMERIC);
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
}
}