$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}"); } } // ── Fingerprint: detect stale runtime state ───────────────────── /** * Compute the expected fingerprint for a set of modules. * * @param array $modules */ public static function expectedFingerprint(array $modules): string { $ids = array_map(static fn (ModuleManifest $m): string => $m->id, $modules); sort($ids); return implode(',', $ids); } /** * Read the persisted fingerprint from disk. */ public static function readFingerprint(string $runtimeDir): string { $file = dirname($runtimeDir) . '/.module-fingerprint'; return is_file($file) ? trim((string) file_get_contents($file)) : ''; } /** * Write a fingerprint after a successful build so web/index.php can * detect stale runtime state without rebuilding on every request. * * @param array $modules */ public static function writeFingerprint(string $runtimeDir, array $modules): void { $file = dirname($runtimeDir) . '/.module-fingerprint'; file_put_contents($file, self::expectedFingerprint($modules)); } 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); } } }