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,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Theme;
|
||||
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Settings\SnippetSettings;
|
||||
use Throwable;
|
||||
|
||||
final class RegistersSnippetSlots
|
||||
{
|
||||
public static function boot(): void
|
||||
{
|
||||
try {
|
||||
$snippets = app(SnippetSettings::class);
|
||||
$map = $snippets->slotMap();
|
||||
} catch (Throwable) {
|
||||
// Settings group may be missing until migrations finish.
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($map as $slot => $html) {
|
||||
if (! is_string($html) || trim($html) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = $html;
|
||||
Hook::listen(ThemeSlot::event($slot), function (string $buffer) use ($payload): string {
|
||||
return $buffer.$payload;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Theme;
|
||||
|
||||
use App\Settings\GeneralSettings;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ThemeManager
|
||||
{
|
||||
protected string $activeTheme = 'default';
|
||||
|
||||
/** @var array<string, array<string, mixed>> */
|
||||
protected array $discovered = [];
|
||||
|
||||
public function discover(): Collection
|
||||
{
|
||||
$themePath = config('larablog.theme_path', base_path('themes'));
|
||||
|
||||
if (! is_dir($themePath)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$this->discovered = collect(File::directories($themePath))
|
||||
->mapWithKeys(function (string $directory) use ($themePath): array {
|
||||
$slug = basename($directory);
|
||||
$manifestPath = $directory.'/theme.json';
|
||||
$manifest = [];
|
||||
|
||||
if (is_file($manifestPath)) {
|
||||
$decoded = json_decode((string) file_get_contents($manifestPath), true);
|
||||
$manifest = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$theme = array_merge([
|
||||
'slug' => $slug,
|
||||
'name' => $slug,
|
||||
'path' => $directory,
|
||||
], $manifest);
|
||||
|
||||
$fallback = $slug === 'default' ? null : $themePath.'/default/views';
|
||||
$theme['slot_report'] = ThemeSlotReport::analyze($slug, $manifest, $directory, $fallback);
|
||||
|
||||
return [$slug => $theme];
|
||||
})
|
||||
->all();
|
||||
|
||||
return collect($this->discovered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots declared in theme.json (expanded). Empty if undeclared.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function declaredSlots(?string $slug = null): array
|
||||
{
|
||||
$slug ??= $this->active();
|
||||
$theme = $this->discover()->get($slug);
|
||||
|
||||
if (! is_array($theme)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $theme['slot_report']['declared'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function slotReport(?string $slug = null): array
|
||||
{
|
||||
$slug ??= $this->active();
|
||||
$theme = $this->discover()->get($slug);
|
||||
|
||||
if (! is_array($theme) || ! isset($theme['slot_report'])) {
|
||||
return ThemeSlotReport::analyze($slug, [], $this->path($slug));
|
||||
}
|
||||
|
||||
return $theme['slot_report'];
|
||||
}
|
||||
|
||||
public function supportsSlot(string $slot, ?string $slug = null): bool
|
||||
{
|
||||
$declared = $this->declaredSlots($slug);
|
||||
|
||||
if ($declared === []) {
|
||||
// Undeclared themes: assume unknown (not supported for soft checks).
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($slot, $declared, true);
|
||||
}
|
||||
|
||||
public function setActive(string $slug): void
|
||||
{
|
||||
$themes = $this->discover();
|
||||
|
||||
if (! $themes->has($slug)) {
|
||||
throw new InvalidArgumentException("Theme [{$slug}] was not found.");
|
||||
}
|
||||
|
||||
$this->activeTheme = $slug;
|
||||
|
||||
$settings = $this->settings();
|
||||
|
||||
if ($settings !== null) {
|
||||
$settings->active_theme = $slug;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->registerViewNamespaces();
|
||||
}
|
||||
|
||||
public function active(): string
|
||||
{
|
||||
try {
|
||||
$settings = app(GeneralSettings::class);
|
||||
|
||||
if (filled($settings->active_theme)) {
|
||||
return $settings->active_theme;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Settings may be unavailable or incomplete during migrations.
|
||||
}
|
||||
|
||||
return $this->activeTheme;
|
||||
}
|
||||
|
||||
public function path(?string $slug = null): string
|
||||
{
|
||||
$slug ??= $this->active();
|
||||
|
||||
return rtrim(config('larablog.theme_path', base_path('themes')), '/').'/'.$slug;
|
||||
}
|
||||
|
||||
public function registerViewNamespaces(): void
|
||||
{
|
||||
try {
|
||||
$themePath = config('larablog.theme_path', base_path('themes'));
|
||||
$defaultPath = $themePath.'/default';
|
||||
|
||||
if (is_dir($defaultPath)) {
|
||||
View::addNamespace('theme', $defaultPath.'/views');
|
||||
}
|
||||
|
||||
$active = $this->active();
|
||||
$activePath = $this->path($active).'/views';
|
||||
|
||||
if ($active !== 'default' && is_dir($activePath)) {
|
||||
View::prependNamespace('theme', $activePath);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Ignore during incomplete settings/bootstrap states.
|
||||
}
|
||||
}
|
||||
|
||||
public function assetUrl(string $path): string
|
||||
{
|
||||
$trimmed = ltrim($path, '/');
|
||||
|
||||
return url('/themes/'.$this->active().'/'.$trimmed);
|
||||
}
|
||||
|
||||
public function ensureActiveThemeExists(): void
|
||||
{
|
||||
if (! is_dir($this->path($this->active()))) {
|
||||
if ($this->active() !== 'default' && is_dir($this->path('default'))) {
|
||||
$this->activeTheme = 'default';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('No valid theme directory found.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function settings(): ?GeneralSettings
|
||||
{
|
||||
try {
|
||||
return app(GeneralSettings::class);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Theme;
|
||||
|
||||
use App\Domain\Plugin\Hook;
|
||||
|
||||
/**
|
||||
* Named injection points for themes / plugins / site snippets.
|
||||
*
|
||||
* Themes call ThemeSlot::render('head') (or @themeslot('head')).
|
||||
* Plugins listen on Hook event "theme.{slot}".
|
||||
* Admin SnippetSettings also appends into these slots at boot.
|
||||
*/
|
||||
final class ThemeSlot
|
||||
{
|
||||
public const HEAD = 'head';
|
||||
|
||||
public const BODY_START = 'body_start';
|
||||
|
||||
public const BODY_END = 'body_end';
|
||||
|
||||
public const HEADER_AFTER = 'header_after';
|
||||
|
||||
public const NAV_AFTER = 'nav_after';
|
||||
|
||||
public const CONTENT_BEFORE = 'content_before';
|
||||
|
||||
public const CONTENT_AFTER = 'content_after';
|
||||
|
||||
public const ARTICLE_TOP = 'article_top';
|
||||
|
||||
public const ARTICLE_BOTTOM = 'article_bottom';
|
||||
|
||||
public const SIDEBAR_BEFORE = 'sidebar_before';
|
||||
|
||||
public const SIDEBAR = 'sidebar';
|
||||
|
||||
public const SIDEBAR_AFTER = 'sidebar_after';
|
||||
|
||||
public const FOOTER_BEFORE = 'footer_before';
|
||||
|
||||
public const FOOTER_AFTER = 'footer_after';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::HEAD,
|
||||
self::BODY_START,
|
||||
self::BODY_END,
|
||||
self::HEADER_AFTER,
|
||||
self::NAV_AFTER,
|
||||
self::CONTENT_BEFORE,
|
||||
self::CONTENT_AFTER,
|
||||
self::ARTICLE_TOP,
|
||||
self::ARTICLE_BOTTOM,
|
||||
self::SIDEBAR_BEFORE,
|
||||
self::SIDEBAR,
|
||||
self::SIDEBAR_AFTER,
|
||||
self::FOOTER_BEFORE,
|
||||
self::FOOTER_AFTER,
|
||||
];
|
||||
}
|
||||
|
||||
public static function event(string $slot): string
|
||||
{
|
||||
return 'theme.'.$slot;
|
||||
}
|
||||
|
||||
public static function render(string $slot): string
|
||||
{
|
||||
return Hook::gather(self::event($slot), '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft contract for theme authors & admin UI.
|
||||
* Sizes are recommendations only — the active theme's CSS wins.
|
||||
*
|
||||
* @return array<string, array{label: string, place: string, size: string, multi: bool}>
|
||||
*/
|
||||
public static function catalog(): array
|
||||
{
|
||||
$catalog = [];
|
||||
|
||||
foreach (self::all() as $slot) {
|
||||
$catalog[$slot] = [
|
||||
'label' => __('admin.slots.'.$slot.'.label'),
|
||||
'place' => __('admin.slots.'.$slot.'.place'),
|
||||
'size' => __('admin.slots.'.$slot.'.size'),
|
||||
'multi' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $catalog;
|
||||
}
|
||||
|
||||
public static function hint(string $slot): string
|
||||
{
|
||||
$meta = self::catalog()[$slot] ?? null;
|
||||
if ($meta === null) {
|
||||
return __('admin.slots.custom', ['slot' => $slot]);
|
||||
}
|
||||
|
||||
return __('admin.slots.hint', [
|
||||
'place' => $meta['place'],
|
||||
'size' => $meta['size'],
|
||||
'slot' => $slot,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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