- Replace ModuleAutoloader with Composer ClassLoader (addPsr4 via spl_autoload_functions) - Eliminate layout_capabilities mapping; UI slots reference ability strings directly - Resolve relative template paths in ModuleRegistry instead of baking __DIR__ into manifests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
362 lines
14 KiB
PHP
362 lines
14 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\App\Module;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* Immutable value object describing a single module's contributions.
|
|
*
|
|
* Each module ships a `module.php` that returns an associative array.
|
|
* ModuleManifest validates and normalises that array into a typed contract.
|
|
*/
|
|
final class ModuleManifest
|
|
{
|
|
public readonly string $id;
|
|
public readonly string $version;
|
|
public readonly bool $enabledByDefault;
|
|
public readonly int $loadOrder;
|
|
|
|
/** @var array<int, array{path: string, target: string, public?: bool}> */
|
|
public readonly array $routes;
|
|
|
|
/** @var list<string> */
|
|
public readonly array $publicPaths;
|
|
|
|
/** @var list<class-string> Container registrar FQCNs */
|
|
public readonly array $containerRegistrars;
|
|
|
|
/** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
|
|
public readonly array $uiSlots;
|
|
|
|
/** @var list<class-string> SearchResourceProvider FQCNs */
|
|
public readonly array $searchResources;
|
|
|
|
/** @var array<string, list<string>> Asset group name → file list */
|
|
public readonly array $assetGroups;
|
|
|
|
/**
|
|
* @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 */
|
|
public readonly array $layoutContextProviders;
|
|
|
|
/** @var list<class-string> SessionProvider FQCNs */
|
|
public readonly array $sessionProviders;
|
|
|
|
/**
|
|
* @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;
|
|
|
|
public readonly ?string $migrationsPath;
|
|
|
|
public readonly string $basePath;
|
|
|
|
/**
|
|
* @param array<string, mixed> $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');
|
|
|
|
$migrationsPath = $raw['migrations_path'] ?? null;
|
|
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
|
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $raw
|
|
*/
|
|
public static function fromArray(array $raw, string $basePath): self
|
|
{
|
|
return new self($raw, $basePath);
|
|
}
|
|
|
|
/**
|
|
* @return list<mixed>
|
|
*/
|
|
private static function listOf(array $raw, string $key): array
|
|
{
|
|
$value = $raw[$key] ?? [];
|
|
return is_array($value) ? array_values($value) : [];
|
|
}
|
|
|
|
/**
|
|
* @return array<int|string, mixed>
|
|
*/
|
|
private static function arrayOf(array $raw, string $key): array
|
|
{
|
|
$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));
|
|
}
|
|
}
|