$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]; } // ── Staging: build in a sibling directory, swap on success ──── $stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid(); $this->ensureCleanDirectory($stagingDir); try { $result = $this->populateDirectory($stagingDir, $corePages, $modules); } catch (\Throwable $e) { $this->removeDirectory($stagingDir); throw $e; } // ── Atomic swap ────────────────────────────────────────────── $this->swapIntoLive($stagingDir, $runtimeDir); return $result; } 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. * * The digest includes module IDs, each manifest file content hash, * and top-level page-entry signatures so that manifest-only or * page-structure changes are detected even when the module ID list * is unchanged. * * @param array $modules */ public static function expectedFingerprint(array $modules): string { $parts = []; $manifests = $modules; uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id); foreach ($manifests as $manifest) { $manifestFile = $manifest->basePath . '/module.php'; $manifestHash = is_file($manifestFile) ? md5_file($manifestFile) : 'missing'; $pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages'); $parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries); } if ($parts === []) { return ''; } return hash('sha256', implode('|', $parts)); } /** * 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)); } // ── Internal helpers ──────────────────────────────────────────── /** * Populate a directory with core + module page symlinks. * * @param array $modules * @return array{core_entries: int, module_entries: int} */ private function populateDirectory(string $targetDir, string $corePages, array $modules): array { $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 = $targetDir . '/' . $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 = $targetDir . '/' . $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, ]; } /** * Swap staging directory into the live runtime path. * * Uses rename() for an atomic filesystem operation (same filesystem). */ private function swapIntoLive(string $stagingDir, string $runtimeDir): void { // Remove existing live target $oldDir = null; if (is_link($runtimeDir)) { unlink($runtimeDir); } elseif (is_dir($runtimeDir)) { // Move old live dir aside, then remove after swap $oldDir = $runtimeDir . '.old-' . getmypid(); if (!@rename($runtimeDir, $oldDir)) { $this->removeDirectoryContents($runtimeDir); rmdir($runtimeDir); $oldDir = null; } } else { $oldDir = null; } // Atomic rename of staging → live if (!@rename($stagingDir, $runtimeDir)) { throw new RuntimeException( "Failed to swap staging directory into live runtime path: {$stagingDir} → {$runtimeDir}" ); } // Clean up old directory if we moved it aside if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) { $this->removeDirectory($oldDir); } } private function ensureCleanDirectory(string $dir): void { if (is_dir($dir)) { $this->removeDirectoryContents($dir); rmdir($dir); } if (is_link($dir)) { unlink($dir); } if (!mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException('Cannot create staging directory: ' . $dir); } } /** * Return sorted top-level entry names from a module pages directory. * * @return list */ private static function topLevelPageEntries(string $pagesDir): array { if (!is_dir($pagesDir)) { return []; } $entries = scandir($pagesDir); if ($entries === false) { return []; } $entries = array_values(array_filter( $entries, static fn (string $e): bool => $e !== '.' && $e !== '..', )); sort($entries); return $entries; } private function removeDirectory(string $dir): void { if (is_link($dir)) { unlink($dir); return; } if (!is_dir($dir)) { return; } $this->removeDirectoryContents($dir); rmdir($dir); } 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); } } }