, * scanned: list, * status: 'full'|'partial'|'undeclared'|'mismatch', * missing_standard: list, * declared_but_unused: list, * used_but_undeclared: list, * label: string, * } */ final class ThemeSlotReport { /** * @param array $manifest * @return Report */ public static function analyze( string $slug, array $manifest, string $themePath, ?string $fallbackViewsPath = null, ): array { $declared = self::normalizeDeclared($manifest['slots'] ?? null); $scanned = self::scanViewsWithFallback($themePath.'/views', $fallbackViewsPath); $standard = ThemeSlot::all(); if ($declared === null) { return [ 'slug' => $slug, 'declared' => [], 'scanned' => $scanned, 'status' => 'undeclared', 'missing_standard' => $standard, 'declared_but_unused' => [], 'used_but_undeclared' => $scanned, 'label' => __('admin.slots.status_undeclared'), ]; } $missingStandard = array_values(array_diff($standard, $declared)); $declaredButUnused = array_values(array_diff($declared, $scanned)); $usedButUndeclared = array_values(array_diff($scanned, $declared)); if ($missingStandard === [] && $declaredButUnused === [] && $usedButUndeclared === []) { $status = 'full'; $label = __('admin.slots.status_full'); } elseif ($missingStandard !== []) { $status = 'partial'; $label = __('admin.slots.status_partial', ['count' => count($missingStandard)]); } else { $status = 'mismatch'; $label = __('admin.slots.status_mismatch'); } return [ 'slug' => $slug, 'declared' => $declared, 'scanned' => $scanned, 'status' => $status, 'missing_standard' => $missingStandard, 'declared_but_unused' => $declaredButUnused, 'used_but_undeclared' => $usedButUndeclared, 'label' => $label, ]; } /** * @return list|null null = key absent */ public static function normalizeDeclared(mixed $slots): ?array { if ($slots === null) { return null; } if ($slots === '*' || $slots === 'all') { return ThemeSlot::all(); } if (! is_array($slots)) { return []; } $out = []; foreach ($slots as $item) { if (! is_string($item) || $item === '') { continue; } if ($item === '*' || $item === 'all') { foreach (ThemeSlot::all() as $standard) { $out[$standard] = true; } continue; } $out[$item] = true; } $list = array_keys($out); sort($list); return $list; } /** @return list */ public static function scanViewsWithFallback(string $viewsPath, ?string $fallbackViewsPath): array { $found = []; foreach (self::scanViews($viewsPath) as $slot) { $found[$slot] = true; } if ($fallbackViewsPath !== null && is_dir($fallbackViewsPath) && realpath($fallbackViewsPath) !== realpath($viewsPath)) { // Count slots from default views that this theme does not override. $ownFiles = self::relativeBladeFiles($viewsPath); foreach (array_keys(self::relativeBladeFiles($fallbackViewsPath)) as $relative) { if (isset($ownFiles[$relative])) { continue; } $path = $fallbackViewsPath.'/'.$relative; foreach (self::extractSlotsFromFile($path) as $slot) { $found[$slot] = true; } } } $list = array_keys($found); sort($list); return $list; } /** @return list */ public static function scanViews(string $viewsPath): array { if (! is_dir($viewsPath)) { return []; } $found = []; foreach (array_keys(self::relativeBladeFiles($viewsPath)) as $relative) { foreach (self::extractSlotsFromFile($viewsPath.'/'.$relative) as $slot) { $found[$slot] = true; } } $list = array_keys($found); sort($list); return $list; } /** @return array relative path => true */ private static function relativeBladeFiles(string $viewsPath): array { if (! is_dir($viewsPath)) { return []; } $out = []; $root = realpath($viewsPath) ?: $viewsPath; $root = rtrim(str_replace('\\', '/', $root), '/'); foreach (File::allFiles($viewsPath) as $file) { if (! str_ends_with(strtolower($file->getFilename()), '.blade.php')) { continue; } $full = str_replace('\\', '/', $file->getPathname()); $real = realpath($full) ?: $full; $real = str_replace('\\', '/', $real); $relative = str_starts_with($real, $root.'/') ? substr($real, strlen($root) + 1) : $file->getFilename(); $out[$relative] = true; } return $out; } /** @return list */ private static function extractSlotsFromFile(string $path): array { if (! is_file($path)) { return []; } $contents = (string) file_get_contents($path); $found = []; if (preg_match_all("/@themeslot\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m)) { foreach ($m[1] as $slot) { $found[$slot] = true; } } if (preg_match_all("/ThemeSlot::render\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m2)) { foreach ($m2[1] as $slot) { $found[$slot] = true; } } // Fully-qualified calls: \App\Domain\Theme\ThemeSlot::render('sidebar') if (preg_match_all("/\\\\ThemeSlot::render\\(\\s*['\"]([a-z0-9_]+)['\"]\\s*\\)/i", $contents, $m3)) { foreach ($m3[1] as $slot) { $found[$slot] = true; } } return array_keys($found); } }