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.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user