*/ public readonly array $routes; /** @var list */ public readonly array $publicPaths; /** @var list Container registrar FQCNs */ public readonly array $containerRegistrars; /** @var array Keyed by slot name (e.g. 'aside.tab_panel') */ public readonly array $uiSlots; /** @var list SearchResourceProvider FQCNs */ public readonly array $searchResources; /** @var array> Asset group name → file list */ public readonly array $assetGroups; /** * @var list * }> */ public readonly array $schedulerJobs; /** @var list LayoutContextProvider FQCNs */ public readonly array $layoutContextProviders; /** @var list SessionProvider FQCNs */ public readonly array $sessionProviders; /** * @var list */ public readonly array $permissions; /** @var list AuthorizationPolicyInterface FQCNs */ public readonly array $authorizationPolicies; /** @var array UI-capability-key → ability-string for layout authorization */ public readonly array $layoutCapabilities; public readonly ?string $migrationsPath; public readonly string $basePath; /** * @param array $raw The manifest array returned by module.php * @param string $basePath Absolute path to the module directory */ private function __construct(array $raw, string $basePath) { $this->basePath = $basePath; // — required fields ———————————————————————————— if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') { throw new InvalidArgumentException('Module manifest must have a non-empty string "id".'); } $this->id = trim($raw['id']); $this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0'; $this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false); $this->loadOrder = (int) ($raw['load_order'] ?? 100); // — optional contribution arrays ——————————————— $this->routes = self::arrayOf($raw, 'routes'); $this->publicPaths = self::listOf($raw, 'public_paths'); $this->containerRegistrars = self::listOf($raw, 'container_registrars'); $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::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id); $this->layoutContextProviders = self::listOf($raw, 'layout_context_providers'); $this->sessionProviders = self::listOf($raw, 'session_providers'); $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) !== '' ? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/') : null; } /** * @param array $raw */ public static function fromArray(array $raw, string $basePath): self { return new self($raw, $basePath); } /** * @return list */ private static function listOf(array $raw, string $key): array { $value = $raw[$key] ?? []; return is_array($value) ? array_values($value) : []; } /** * @return array */ private static function arrayOf(array $raw, string $key): array { $value = $raw[$key] ?? []; return is_array($value) ? $value : []; } /** * @return list */ 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 * }> */ 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 */ 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)); } }