Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to contribute routes, UI slots, search providers, layout context, session lifecycle, and permissions. Modules are activated via config/modules.php or APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions, slot keys) cause a fail-fast with a clear error message. Extract the address book from hardcoded Core integration points into the first module (modules/addressbook/). The module provides: - Aside icon-bar tab + People panel via UI slot system - Global search resource via AddressBookSearchProvider - Layout context data via AddressBookLayoutProvider - Session lifecycle via AddressBookSessionProvider Core cleanup removes address-book hardcodings from SearchSqlResourceProvider, SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(), and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic (AddressBookService) remain in Core as they gate general user visibility. Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture tests verifying zero hardcoded address-book references in search/templates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
116 lines
4.2 KiB
PHP
116 lines
4.2 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{name: string, handler: string, schedule?: 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<string> Permission keys (e.g. 'address_book.view') */
|
|
public readonly array $permissions;
|
|
|
|
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::arrayOf($raw, 'scheduler_jobs');
|
|
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
|
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
|
$this->permissions = self::listOf($raw, 'permissions');
|
|
|
|
$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 : [];
|
|
}
|
|
}
|