Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
223 lines
6.6 KiB
PHP
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);
|
|
}
|
|
}
|