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:
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