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:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
@@ -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;
});
}
}
}
+192
View File
@@ -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;
}
}
}
+112
View File
@@ -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,
]);
}
}
+222
View File
@@ -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);
}
}