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:
@@ -9,6 +9,11 @@ use MintyPHP\App\AppContainer;
|
||||
*
|
||||
* Called from appBuildLayoutNavContext() after Core data assembly.
|
||||
* Return value is merged into the $layoutNav array passed to templates.
|
||||
*
|
||||
* IMPORTANT: Only called in web request context. CLI/scheduler callers
|
||||
* must not invoke layout context providers. Implementations should use
|
||||
* try/catch around request-dependent calls (e.g. requestInput()) as a
|
||||
* defensive measure.
|
||||
*/
|
||||
interface LayoutContextProvider
|
||||
{
|
||||
|
||||
@@ -13,6 +13,13 @@ interface SearchResourceProvider
|
||||
/**
|
||||
* Return search resource definitions.
|
||||
*
|
||||
* Supported SQL placeholders (replaced before parameter binding):
|
||||
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
|
||||
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
|
||||
*
|
||||
* All remaining `?` placeholders are bound to the LIKE search term.
|
||||
* The last `?` in preview_sql is reserved for the preview limit.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* label: string,
|
||||
* permission: string,
|
||||
|
||||
56
lib/App/Module/ModuleAutoloader.php
Normal file
56
lib/App/Module/ModuleAutoloader.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
/**
|
||||
* Runtime autoloader for module-local PHP classes.
|
||||
*
|
||||
* Modules can place PHP code under modules/<id>/lib and use the same
|
||||
* MintyPHP namespace root as core classes.
|
||||
*/
|
||||
final class ModuleAutoloader
|
||||
{
|
||||
/** @var list<string> */
|
||||
private static array $moduleLibDirs = [];
|
||||
|
||||
private static bool $registered = false;
|
||||
|
||||
/**
|
||||
* @param list<ModuleManifest> $manifests
|
||||
*/
|
||||
public static function register(array $manifests): void
|
||||
{
|
||||
$dirs = self::$moduleLibDirs;
|
||||
foreach ($manifests as $manifest) {
|
||||
$libDir = rtrim($manifest->basePath, '/') . '/lib';
|
||||
if (is_dir($libDir)) {
|
||||
$dirs[] = $libDir;
|
||||
}
|
||||
}
|
||||
self::$moduleLibDirs = array_values(array_unique($dirs));
|
||||
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
spl_autoload_register([self::class, 'autoload'], true, true);
|
||||
self::$registered = true;
|
||||
}
|
||||
|
||||
private static function autoload(string $class): void
|
||||
{
|
||||
$prefix = 'MintyPHP\\';
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativePath = str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
|
||||
foreach (self::$moduleLibDirs as $libDir) {
|
||||
$candidate = $libDir . '/' . $relativePath;
|
||||
if (is_file($candidate)) {
|
||||
require_once $candidate;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
91
lib/App/Module/ModulePermissionSynchronizer.php
Normal file
91
lib/App/Module/ModulePermissionSynchronizer.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Synchronizes module-defined permissions into the permissions table.
|
||||
*/
|
||||
final class ModulePermissionSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleRegistry $moduleRegistry,
|
||||
private readonly PermissionRepositoryInterface $permissionRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, updated: int, unchanged: int, total: int}
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$unchanged = 0;
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
|
||||
$total++;
|
||||
$key = trim((string) $permissionDefinition['key']);
|
||||
if ($key === '') {
|
||||
throw new \RuntimeException('Module permission definition has empty key.');
|
||||
}
|
||||
|
||||
$desired = [
|
||||
'key' => $key,
|
||||
'description' => trim((string) $permissionDefinition['description']),
|
||||
'active' => (int) $permissionDefinition['active'],
|
||||
'is_system' => (int) $permissionDefinition['is_system'],
|
||||
];
|
||||
|
||||
$existing = $this->permissionRepository->findByKey($key);
|
||||
if (!is_array($existing)) {
|
||||
$createdId = $this->permissionRepository->create($desired);
|
||||
if ($createdId === null) {
|
||||
throw new \RuntimeException("Failed to create module permission '{$key}'.");
|
||||
}
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingId = (int) ($existing['id'] ?? 0);
|
||||
if ($existingId <= 0) {
|
||||
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
|
||||
}
|
||||
|
||||
if ($this->isPermissionEqual($existing, $desired)) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
|
||||
if (!$wasUpdated) {
|
||||
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'unchanged' => $unchanged,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $existing
|
||||
* @param array<string, mixed> $desired
|
||||
*/
|
||||
private function isPermissionEqual(array $existing, array $desired): bool
|
||||
{
|
||||
$existingDescription = trim((string) ($existing['description'] ?? ''));
|
||||
$existingActive = (int) ($existing['active'] ?? 0);
|
||||
$existingSystem = (int) ($existing['is_system'] ?? 0);
|
||||
|
||||
return $existingDescription === (string) ($desired['description'] ?? '')
|
||||
&& $existingActive === (int) ($desired['active'] ?? 0)
|
||||
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,21 @@ use RuntimeException;
|
||||
*/
|
||||
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, public?: bool}> merged routes */
|
||||
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
|
||||
private array $mergedRoutes = [];
|
||||
|
||||
/** @var list<string> merged public paths */
|
||||
@@ -42,12 +53,53 @@ final class ModuleRegistry
|
||||
/** @var list<class-string> merged session provider FQCNs */
|
||||
private array $mergedSessionProviders = [];
|
||||
|
||||
/** @var list<string> merged permissions */
|
||||
/**
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* description: string,
|
||||
* active: int,
|
||||
* is_system: int
|
||||
* }> merged permissions
|
||||
*/
|
||||
private array $mergedPermissions = [];
|
||||
|
||||
/** @var list<array{name: string, handler: string, schedule?: string}> */
|
||||
/** @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.
|
||||
*
|
||||
@@ -102,11 +154,16 @@ final class ModuleRegistry
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -119,10 +176,17 @@ final class ModuleRegistry
|
||||
public static function resolveEnabledModules(array $config): array
|
||||
{
|
||||
$fromConfig = $config['enabled_modules'] ?? [];
|
||||
$fromEnv = trim((string) getenv('APP_ENABLED_MODULES'));
|
||||
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
|
||||
|
||||
if ($fromEnv !== '') {
|
||||
$fromConfig = array_map('trim', explode(',', $fromEnv));
|
||||
// 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) !== '')));
|
||||
@@ -139,15 +203,28 @@ final class ModuleRegistry
|
||||
$this->modules[$manifest->id] = $manifest;
|
||||
|
||||
// — Merge routes (fail-fast on collision) ——————————————
|
||||
$existingTargets = array_column($this->mergedRoutes, 'target');
|
||||
foreach ($manifest->routes as $route) {
|
||||
foreach ($manifest->routes as $rawRoute) {
|
||||
$route = $this->normalizeRoute($rawRoute, $manifest->id);
|
||||
$path = $route['path'];
|
||||
$target = $route['target'];
|
||||
if (in_array($target, $existingTargets, true)) {
|
||||
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 an existing route."
|
||||
"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 ———————————————————————————
|
||||
@@ -163,23 +240,27 @@ final class ModuleRegistry
|
||||
}
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $contribution) {
|
||||
$key = is_array($contribution) ? ($contribution['key'] ?? null) : null;
|
||||
if ($key !== null) {
|
||||
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."
|
||||
);
|
||||
}
|
||||
$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][] = $contribution;
|
||||
$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."
|
||||
@@ -200,22 +281,283 @@ final class ModuleRegistry
|
||||
|
||||
// — Merge permissions (fail-fast on duplicate) ———————
|
||||
foreach ($manifest->permissions as $permission) {
|
||||
if (in_array($permission, $this->mergedPermissions, true)) {
|
||||
$permissionKey = trim((string) $permission['key']);
|
||||
if ($permissionKey === '') {
|
||||
throw new RuntimeException(
|
||||
"Permission conflict: '{$permission}' from module '{$manifest->id}' is already registered."
|
||||
"Permission in module '{$manifest->id}' must define non-empty 'key'."
|
||||
);
|
||||
}
|
||||
$this->mergedPermissions[] = $permission;
|
||||
|
||||
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 ————————————————
|
||||
array_push($this->mergedLayoutContextProviders, ...$manifest->layoutContextProviders);
|
||||
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 ——————————————————————
|
||||
array_push($this->mergedSessionProviders, ...$manifest->sessionProviders);
|
||||
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 —————————————————————————
|
||||
array_push($this->mergedSchedulerJobs, ...$manifest->schedulerJobs);
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -239,7 +581,7 @@ final class ModuleRegistry
|
||||
return $this->modules[$id];
|
||||
}
|
||||
|
||||
/** @return array<int, array{path: string, target: string, public?: bool}> */
|
||||
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
|
||||
public function getRoutes(): array
|
||||
{
|
||||
return $this->mergedRoutes;
|
||||
@@ -295,13 +637,52 @@ final class ModuleRegistry
|
||||
return $this->mergedSessionProviders;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||
*/
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->mergedPermissions;
|
||||
}
|
||||
|
||||
/** @return list<array{name: string, handler: string, schedule?: string}> */
|
||||
/** @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;
|
||||
|
||||
178
lib/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
178
lib/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Publishes module web assets to a runtime web/modules directory.
|
||||
*/
|
||||
final class ModuleRuntimeAssetPublisher
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{created: int, updated: int, removed: int, unchanged: int}
|
||||
*/
|
||||
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
|
||||
{
|
||||
$normalizedMode = strtolower(trim($mode));
|
||||
if ($normalizedMode === '') {
|
||||
$normalizedMode = 'symlink';
|
||||
}
|
||||
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
|
||||
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
|
||||
}
|
||||
|
||||
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
|
||||
}
|
||||
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
|
||||
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
|
||||
|
||||
$desired = [];
|
||||
foreach ($modules as $manifest) {
|
||||
if (!$manifest instanceof ModuleManifest) {
|
||||
continue;
|
||||
}
|
||||
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
|
||||
if (!is_dir($moduleWebDir)) {
|
||||
continue;
|
||||
}
|
||||
$desired[$manifest->id] = $moduleWebDir;
|
||||
}
|
||||
|
||||
$entries = scandir($runtimeModulesDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (isset($desired[$entry])) {
|
||||
continue;
|
||||
}
|
||||
$path = $runtimeModulesDir . '/' . $entry;
|
||||
$this->removePath($path);
|
||||
$result['removed']++;
|
||||
}
|
||||
|
||||
foreach ($desired as $moduleId => $moduleWebDir) {
|
||||
$targetPath = $runtimeModulesDir . '/' . $moduleId;
|
||||
$targetExists = file_exists($targetPath) || is_link($targetPath);
|
||||
|
||||
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
|
||||
$targetReal = realpath($targetPath);
|
||||
$sourceReal = realpath($moduleWebDir);
|
||||
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
|
||||
$result['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$this->removePath($targetPath);
|
||||
}
|
||||
|
||||
if ($normalizedMode === 'symlink') {
|
||||
if (!@symlink($moduleWebDir, $targetPath)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
|
||||
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->copyDirectory($moduleWebDir, $targetPath);
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$result['updated']++;
|
||||
} else {
|
||||
$result['created']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function removePath(string $path): void
|
||||
{
|
||||
if (is_link($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove symlink: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove file: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($path);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan directory: {$path}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$this->removePath($path . '/' . $entry);
|
||||
}
|
||||
|
||||
if (!rmdir($path)) {
|
||||
throw new RuntimeException("Cannot remove directory: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
private function copyDirectory(string $sourceDir, string $targetDir): void
|
||||
{
|
||||
if (!is_dir($sourceDir)) {
|
||||
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
|
||||
}
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
|
||||
}
|
||||
|
||||
$entries = scandir($sourceDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $sourceDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
|
||||
if (is_link($source)) {
|
||||
$linkTarget = readlink($source);
|
||||
if ($linkTarget === false || !symlink($linkTarget, $target)) {
|
||||
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (is_dir($source)) {
|
||||
$this->copyDirectory($source, $target);
|
||||
continue;
|
||||
}
|
||||
if (!is_file($source)) {
|
||||
continue;
|
||||
}
|
||||
if (!copy($source, $target)) {
|
||||
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
lib/App/Module/ModuleRuntimePageBuilder.php
Normal file
135
lib/App/Module/ModuleRuntimePageBuilder.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
||||
*/
|
||||
final class ModuleRuntimePageBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
|
||||
{
|
||||
$corePages = realpath($corePagesDir);
|
||||
if ($corePages === false) {
|
||||
throw new RuntimeException('Core pages/ directory not found.');
|
||||
}
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$this->pointRuntimeToCore($runtimeDir, $corePages);
|
||||
return ['core_entries' => 0, 'module_entries' => 0];
|
||||
}
|
||||
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
|
||||
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
|
||||
}
|
||||
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages directory.');
|
||||
}
|
||||
|
||||
$coreCount = 0;
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$coreCount++;
|
||||
}
|
||||
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $runtimeDir . '/' . $entry;
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'core_entries' => $coreCount,
|
||||
'module_entries' => $modulePageCount,
|
||||
];
|
||||
}
|
||||
|
||||
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||
{
|
||||
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
}
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!@symlink($corePages, $runtimeDir)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
|
||||
}
|
||||
}
|
||||
|
||||
private function removeDirectoryContents(string $dir): void
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
if (is_link($path)) {
|
||||
unlink($path);
|
||||
continue;
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($path);
|
||||
continue;
|
||||
}
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user