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:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -18,159 +18,64 @@
declare(strict_types=1);
chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
require 'config/config.php';
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
$exitCode = 0;
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
require_once __DIR__ . '/module-cli-bootstrap.php';
try {
// Ensure runtime directory exists
$runtimeParent = dirname($runtimeDir);
if (!is_dir($runtimeParent)) {
mkdir($runtimeParent, 0775, true);
}
// Lock to prevent concurrent builds
$lock = fopen($lockFile, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
exit(0);
}
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
$corePages = realpath(__DIR__ . '/../pages');
if ($corePages === false) {
throw new RuntimeException('Core pages/ directory not found.');
}
// If no modules, ensure runtime dir points to core pages
if (count($modules) === 0) {
// Remove existing runtime dir if it's a real directory (not a symlink)
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
}
// Symlink runtime → core pages
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
symlink($corePages, $runtimeDir);
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
exit(0);
}
// With modules: build merged directory
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!is_dir($runtimeDir)) {
mkdir($runtimeDir, 0775, true);
}
// Clean existing symlinks in runtime dir
removeDirectoryContents($runtimeDir);
// Symlink all core page directories/files
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages/ directory.');
}
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
symlink($source, $target);
}
// Add module page symlinks (fail-fast on collision)
$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."
);
}
symlink($source, $target);
$modulePageCount++;
}
}
fwrite(STDOUT, sprintf(
"module-build: runtime pages built — %d core entries + %d module entries.\n",
count($coreEntries) - 2, // minus . and ..
$modulePageCount
));
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
if (isset($lock) && is_resource($lock)) {
flock($lock, LOCK_UN);
fclose($lock);
}
}
exit($exitCode);
/**
* Remove all files and symlinks in a directory (non-recursive, top-level only).
*/
function removeDirectoryContents(string $dir): void
function moduleBuildRun(): int
{
$entries = scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
moduleCliBootstrapApp();
$exitCode = 0;
$runtimeDir = __DIR__ . '/../storage/runtime/pages';
$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock';
try {
// Ensure runtime directory exists
$runtimeParent = dirname($runtimeDir);
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
}
$path = $dir . '/' . $entry;
if (is_link($path)) {
unlink($path);
} elseif (is_dir($path)) {
// Recursively remove directories that were created (shouldn't happen in normal flow)
$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);
// Lock to prevent concurrent builds
$lock = fopen($lockFile, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
fwrite(STDERR, "module-build: another build is in progress, skipping.\n");
return 0;
}
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
if (count($modules) === 0) {
fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n");
} else {
unlink($path);
fwrite(STDOUT, sprintf(
"module-build: runtime pages built — %d core entries + %d module entries.\n",
(int) ($result['core_entries'] ?? 0),
(int) ($result['module_entries'] ?? 0)
));
}
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
if (isset($lock) && is_resource($lock)) {
flock($lock, LOCK_UN);
fclose($lock);
}
}
$vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore);
fwrite(STDOUT, sprintf("module-build: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored));
return $exitCode;
}
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
exit(moduleBuildRun());
}