Files
larablog/app/Domain/Theme/ThemeSlotReport.php
T
ak 263b98b218 Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
2026-08-12 01:15:38 +08:00

223 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Domain\Theme;
use Illuminate\Support\Facades\File;
/**
* Compares theme.json "slots" with standard catalog + Blade usage.
*
* @phpstan-type Report array{
* slug: string,
* declared: list<string>,
* scanned: list<string>,
* status: 'full'|'partial'|'undeclared'|'mismatch',
* missing_standard: list<string>,
* declared_but_unused: list<string>,
* used_but_undeclared: list<string>,
* label: string,
* }
*/
final class ThemeSlotReport
{
/**
* @param array<string, mixed> $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<string>|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<string> */
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<string> */
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<string, true> 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<string> */
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);
}
}