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,44 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Models\Article;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Phase-2 stub: auto-pick or generate a cover image for an article.
* Dispatch later from import scripts / artisan batch; process via Workerman/queue.
*/
class GenerateArticleCoverJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $articleId,
public string $strategy = 'auto', // auto|from_content|generate
) {
$this->onQueue('ai-content');
}
public function handle(): void
{
$article = Article::query()->find($this->articleId);
if ($article === null) {
return;
}
// Intentionally unimplemented in phase 1.
Log::info('GenerateArticleCoverJob stub skipped', [
'article_id' => $article->id,
'strategy' => $this->strategy,
'cover_status' => $article->cover_status,
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Contracts\LlmProvider;
use App\Models\Comment;
use App\Settings\AiSettings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class ModerateCommentJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $commentId,
) {
$this->onQueue('ai-moderation');
}
public function handle(LlmProvider $llm, AiSettings $settings): void
{
if (! $settings->comment_moderation_enabled) {
return;
}
$comment = Comment::query()->find($this->commentId);
if ($comment === null) {
return;
}
if ($comment->moderation_status !== Comment::STATUS_PENDING_AI) {
$comment->update(['moderation_status' => Comment::STATUS_PENDING_AI]);
}
try {
$result = $llm->moderate($comment->content);
$status = match ($result['status'] ?? 'needs_human') {
'approved' => Comment::STATUS_APPROVED,
'rejected' => Comment::STATUS_REJECTED,
default => Comment::STATUS_NEEDS_HUMAN,
};
$comment->forceFill([
'moderation_status' => $status,
'published_at' => $status === Comment::STATUS_APPROVED
? ($comment->published_at ?? now())
: $comment->published_at,
])->save();
} catch (\Throwable $exception) {
Log::warning('Comment moderation failed.', [
'comment_id' => $this->commentId,
'message' => $exception->getMessage(),
]);
$comment->update(['moderation_status' => Comment::STATUS_NEEDS_HUMAN]);
}
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai\Jobs;
use App\Contracts\LlmProvider;
use App\Models\Article;
use App\Settings\AiSettings;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class OptimizeArticleContentJob implements ShouldQueue
{
use Queueable;
public function __construct(
public int $articleId,
) {
$this->onQueue('ai-content');
}
public function handle(LlmProvider $llm, AiSettings $settings): void
{
if (! $settings->content_optimization_enabled) {
return;
}
$article = Article::query()->find($this->articleId);
if ($article === null) {
return;
}
try {
$result = $llm->complete($article->content, [
'title' => $article->title,
'article_id' => $article->id,
]);
$article->forceFill([
'ai_summary' => $result['summary'] ?? null,
'ai_suggestions' => $result['suggestions'] ?? [],
])->save();
} catch (\Throwable $exception) {
Log::warning('Article content optimization failed.', [
'article_id' => $this->articleId,
'message' => $exception->getMessage(),
]);
}
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai;
use App\Contracts\LlmProvider;
use App\Settings\AiSettings;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class OpenAiCompatibleLlmProvider implements LlmProvider
{
public function __construct(
protected AiSettings $settings,
) {}
public function complete(string $prompt, array $context = []): array
{
$response = $this->request([
'model' => $this->settings->model ?? 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'You optimize blog article content. Respond with JSON containing summary (string) and suggestions (array of strings).',
],
[
'role' => 'user',
'content' => $prompt,
],
],
'response_format' => ['type' => 'json_object'],
]);
$content = data_get($response, 'choices.0.message.content');
if (! is_string($content)) {
throw new RuntimeException('LLM completion response missing content.');
}
$decoded = json_decode($content, true);
if (! is_array($decoded)) {
throw new RuntimeException('LLM completion response is not valid JSON.');
}
return [
'summary' => (string) ($decoded['summary'] ?? ''),
'suggestions' => array_values($decoded['suggestions'] ?? []),
];
}
public function moderate(string $content): array
{
$response = $this->request([
'model' => $this->settings->model ?? 'gpt-4o-mini',
'messages' => [
[
'role' => 'system',
'content' => 'Moderate blog comments. Respond with JSON: {"status":"approved|rejected|needs_human","reason":"..."}',
],
[
'role' => 'user',
'content' => $content,
],
],
'response_format' => ['type' => 'json_object'],
]);
$payload = data_get($response, 'choices.0.message.content');
$decoded = is_string($payload) ? json_decode($payload, true) : null;
if (! is_array($decoded) || ! isset($decoded['status'])) {
return [
'status' => 'needs_human',
'reason' => 'Unable to parse moderation response.',
];
}
return [
'status' => (string) $decoded['status'],
'reason' => isset($decoded['reason']) ? (string) $decoded['reason'] : null,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function request(array $payload): array
{
$baseUrl = rtrim($this->settings->api_base_url ?? 'https://api.openai.com/v1', '/');
$response = Http::withToken($this->settings->api_key ?? '')
->acceptJson()
->timeout(60)
->post("{$baseUrl}/chat/completions", $payload)
->throw()
->json();
if (! is_array($response)) {
throw new RuntimeException('LLM provider returned an invalid response.');
}
return $response;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Ai;
use App\Contracts\LlmProvider;
class StubLlmProvider implements LlmProvider
{
public function complete(string $prompt, array $context = []): array
{
return [
'summary' => 'Stub summary for content optimization.',
'suggestions' => [
'Review headings for clarity.',
'Add a concise meta description.',
],
];
}
public function moderate(string $content): array
{
$blocked = str_contains(strtolower($content), 'spam');
return [
'status' => $blocked ? 'rejected' : 'approved',
'reason' => $blocked ? 'Detected spam keyword in stub provider.' : null,
];
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
final class AccessDecision
{
public const ALLOW = 'allow';
public const NEED_LOGIN = 'need_login';
public const NEED_PURCHASE = 'need_purchase';
public const NEED_PASSWORD = 'need_password';
/** @var array<string, int> */
private const SEVERITY = [
self::ALLOW => 0,
self::NEED_LOGIN => 1,
self::NEED_PURCHASE => 2,
self::NEED_PASSWORD => 3,
];
public function __construct(
public string $status,
public ?string $teaserHtml = null,
public ?string $checkoutUrl = null,
public ?string $message = null,
) {}
public static function allow(): self
{
return new self(self::ALLOW);
}
public static function needPassword(?string $message = null): self
{
return new self(self::NEED_PASSWORD, message: $message);
}
public static function needPurchase(?string $teaserHtml = null, ?string $checkoutUrl = null, ?string $message = null): self
{
return new self(self::NEED_PURCHASE, $teaserHtml, $checkoutUrl, $message);
}
public function isAllow(): bool
{
return $this->status === self::ALLOW;
}
public function severity(): int
{
return self::SEVERITY[$this->status] ?? 0;
}
/**
* Only allow tightening (higher severity). Metadata from the stricter side wins when status changes;
* otherwise fill empty meta from $other.
*/
public function tightenWith(self $other): self
{
if ($other->severity() < $this->severity()) {
return new self(
$this->status,
$this->teaserHtml ?? $other->teaserHtml,
$this->checkoutUrl ?? $other->checkoutUrl,
$this->message ?? $other->message,
);
}
if ($other->severity() > $this->severity()) {
return new self(
$other->status,
$other->teaserHtml ?? $this->teaserHtml,
$other->checkoutUrl ?? $this->checkoutUrl,
$other->message ?? $this->message,
);
}
return new self(
$this->status,
$other->teaserHtml ?? $this->teaserHtml,
$other->checkoutUrl ?? $this->checkoutUrl,
$other->message ?? $this->message,
);
}
/**
* @return array{status: string, teaser_html: ?string, checkout_url: ?string, message: ?string}
*/
public function toArray(): array
{
return [
'status' => $this->status,
'teaser_html' => $this->teaserHtml,
'checkout_url' => $this->checkoutUrl,
'message' => $this->message,
];
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Domain\Plugin\Hook;
use App\Models\Article;
use App\Models\User;
use Illuminate\Support\Facades\Session;
class ArticleAccess
{
/**
* Pure decision no HTTP side effects.
*
* @param list<int>|null $unlockedArticleIds session unlock list; null = read from session
*/
public function resolve(Article $article, ?User $user = null, ?array $unlockedArticleIds = null): AccessDecision
{
if ($user !== null && $this->isPrivileged($article, $user)) {
return AccessDecision::allow();
}
if (filled($article->read_password)) {
$unlocked = $unlockedArticleIds ?? (array) Session::get('unlocked_articles', []);
if (! in_array($article->id, $unlocked, true)) {
return AccessDecision::needPassword();
}
}
$decision = AccessDecision::allow();
$context = [
'article' => $article,
'user' => $user,
];
// Fold every listener result instead of letting the last one win, so a
// plugin can only tighten access and a broken listener cannot re-open it.
foreach (Hook::listeners('article.access') as $listener) {
$result = $listener($decision, $context);
if ($result instanceof AccessDecision) {
$decision = $decision->tightenWith($result);
}
}
return $decision;
}
/**
* HTML safe to expose for the given decision (full body or teaser/description).
*/
public function publicHtml(Article $article, AccessDecision $decision): string
{
if ($decision->isAllow()) {
return $article->renderedHtml();
}
if (filled($decision->teaserHtml)) {
return $decision->teaserHtml;
}
return e((string) ($article->description ?: ''));
}
protected function isPrivileged(Article $article, User $user): bool
{
if ((int) $article->user_id === (int) $user->id) {
return true;
}
return $user->hasRole('admin');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Models\Article;
use Illuminate\Support\Str;
class ArticleExcerpt
{
public function __construct(
protected ArticleAccess $access,
) {}
/**
* Safe list/card excerpt never render full body for restricted articles.
*/
public function forList(Article $article, int $limit = 160): string
{
if (filled($article->description)) {
return Str::limit((string) $article->description, $limit);
}
$decision = $this->access->resolve($article, null, []);
if (! $decision->isAllow()) {
$html = $this->access->publicHtml($article, $decision);
return Str::limit(trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), $limit);
}
return Str::limit(trim(strip_tags($article->renderedHtml())), $limit);
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use App\Models\Attachment;
use Illuminate\Support\Facades\Cache;
/**
* Attachment embeds:
* - Legacy HTML: [attach=123] or src/href="[attach=123]"
* - Markdown-safe: ![alt](attach:123) or [label](attach:123)
*
* DB stores references only; URLs are resolved at render/preview time.
*/
class AttachEmbed
{
public const LEGACY_PATTERN = '/\[attach=(\d+)\]/i';
public const MD_PROTOCOL = 'attach:';
public function legacyToMarkdownReference(string $content, ?int $articleId = null): string
{
return preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$name = $attachment?->filename ?: "attachment-{$id}";
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
if ($attachment && $this->isImage($attachment)) {
return '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')';
}
return '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
}, $content) ?? $content;
}
/**
* Protect sablog attach tokens before HTML→Markdown conversion.
* Tokens avoid underscores so html-to-markdown won't escape them.
*
* @return array{0: string, 1: array<string, array{id:int, kind:string, role:string}>}
*/
public function protectLegacyTokensForHtmlConversion(string $html, ?int $articleId = null): array
{
$map = [];
// Attribute values become markdown link/image destinations → restore as attach:ID only.
$html = preg_replace_callback(
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
function (array $matches) use (&$map, $articleId): string {
$id = (int) $matches[3];
$role = 'dest';
$kind = strtolower($matches[1]) === 'src' ? 'image' : 'file';
$token = 'LBATTACHDEST'.$id.'X';
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => $role, 'article_id' => $articleId];
return $matches[1].'='.$matches[2].$token.$matches[2];
},
$html
) ?? $html;
// Bare tokens become full markdown embeds after conversion.
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use (&$map, $articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$kind = ($attachment && $this->isImage($attachment)) ? 'image' : 'file';
$token = 'LBATTACHBARE'.$id.'X';
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => 'bare', 'article_id' => $articleId];
return $token;
}, $html) ?? $html;
return [$html, $map];
}
/**
* @param array<string, array{id:int, kind:string, role:string}> $map
*/
public function restoreProtectedTokensToMarkdown(string $markdown, array $map): string
{
foreach ($map as $token => $meta) {
$id = (int) $meta['id'];
if (($meta['role'] ?? '') === 'dest') {
$markdown = str_replace($token, self::MD_PROTOCOL.$id, $markdown);
continue;
}
$attachment = $this->findAttachment($id, $meta['article_id'] ?? null);
$name = $attachment?->filename ?: "attachment-{$id}";
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
$replacement = ($meta['kind'] ?? 'file') === 'image'
? '!['.$this->escapeMdLabel($alt).']('.self::MD_PROTOCOL.$id.')'
: '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
$markdown = str_replace($token, $replacement, $markdown);
}
return $markdown;
}
/**
* Render-time only: turn attach:123 into /attachment.php?id=123 for CommonMark.
*/
public function expandMarkdownAttachProtocol(string $markdown): string
{
return preg_replace_callback(
'/\]\(attach:(\d+)\)/i',
fn (array $matches): string => ']('.$this->publicEntryUrl((int) $matches[1]).')',
$markdown
) ?? $markdown;
}
public function hydrateHtml(string $html, ?int $articleId = null): string
{
// 1) Attribute form: src/href="[attach=N]" (must run before bare token replace)
$html = preg_replace_callback(
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
function (array $matches): string {
$attr = strtolower($matches[1]);
$quote = $matches[2];
$id = (int) $matches[3];
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
},
$html
) ?? $html;
// 2) Safety net for attach: protocol left in HTML attributes
$html = preg_replace_callback(
'/\b(href|src)=([\'"])attach:(\d+)\2/i',
function (array $matches): string {
$attr = strtolower($matches[1]);
$quote = $matches[2];
$id = (int) $matches[3];
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
},
$html
) ?? $html;
// 3) Bare legacy [attach=id] tokens
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
$id = (int) $matches[1];
$attachment = $this->findAttachment($id, $articleId);
$url = $this->publicEntryUrl($id);
if ($attachment && $this->isImage($attachment)) {
$alt = e(pathinfo($attachment->filename, PATHINFO_FILENAME) ?: $attachment->filename);
return '<img src="'.e($url).'" alt="'.$alt.'" />';
}
$label = e($attachment?->filename ?: "attachment-{$id}");
return '<a href="'.e($url).'">'.$label.'</a>';
}, $html) ?? $html;
return $html;
}
public function publicEntryUrl(int $attachmentId): string
{
return url('/attachment.php?id='.$attachmentId);
}
protected function findAttachment(int $id, ?int $articleId = null): ?Attachment
{
try {
return Cache::remember("attach-embed:{$id}", 60, function () use ($id) {
return Attachment::query()->find($id);
});
} catch (\Throwable) {
return null;
}
}
protected function isImage(Attachment $attachment): bool
{
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
return true;
}
$ext = strtolower(pathinfo($attachment->filename, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
}
protected function escapeMdLabel(string $label): string
{
return str_replace(['[', ']', '!'], ['\\[', '\\]', '\\!'], $label);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
final class ContentFormat
{
public const HTML = 'html';
public const MARKDOWN = 'markdown';
public static function all(): array
{
return [self::HTML, self::MARKDOWN];
}
public static function isValid(string $format): bool
{
return in_array($format, self::all(), true);
}
public static function normalize(?string $format, string $fallback = self::HTML): string
{
$format = strtolower(trim((string) $format));
return self::isValid($format) ? $format : $fallback;
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use DOMDocument;
use DOMElement;
use DOMXPath;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\MarkdownConverter;
use League\HTMLToMarkdown\HtmlConverter;
use Mews\Purifier\Facades\Purifier;
class ContentRenderer
{
public function __construct(
protected AttachEmbed $attachEmbed,
) {}
public function toHtml(string $content, string $format, ?int $articleId = null): string
{
return $this->render($content, $format, $articleId)['html'];
}
/**
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
*/
public function render(string $content, string $format, ?int $articleId = null): array
{
$format = ContentFormat::normalize($format);
if ($format === ContentFormat::MARKDOWN) {
$content = $this->attachEmbed->legacyToMarkdownReference($content, $articleId);
$content = $this->attachEmbed->expandMarkdownAttachProtocol($content);
$html = $this->markdownToHtml($content);
} else {
$html = $content;
}
$html = $this->attachEmbed->hydrateHtml($html, $articleId);
$html = Purifier::clean($html, 'article');
return $this->applyTocAnchors($html);
}
public function markdownToHtml(string $markdown): string
{
$environment = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
'heading_permalink' => [
'html_class' => 'heading-permalink',
'id_prefix' => '',
'fragment_prefix' => '',
'insert' => 'none',
'apply_id_to_heading' => true,
'heading_class' => '',
],
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$environment->addExtension(new HeadingPermalinkExtension);
return (string) (new MarkdownConverter($environment))->convert($markdown);
}
public function htmlToMarkdown(string $html): string
{
$converter = new HtmlConverter([
'strip_tags' => true,
'hard_break' => true,
]);
return trim($converter->convert($html));
}
/**
* Convert sablog HTML article body into Markdown storage form.
* Protects [attach=id] / src="[attach=id]" with placeholders during conversion.
*/
public function convertImportedHtmlToMarkdown(string $html, ?int $articleId = null): string
{
[$protected, $map] = $this->attachEmbed->protectLegacyTokensForHtmlConversion($html, $articleId);
$markdown = $this->htmlToMarkdown($protected);
return $this->attachEmbed->restoreProtectedTokensToMarkdown($markdown, $map);
}
/**
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
*/
protected function applyTocAnchors(string $html): array
{
if (trim($html) === '') {
return ['html' => '', 'toc' => []];
}
$document = new DOMDocument;
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="UTF-8"><div id="larablog-toc-root">'.$html.'</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new DOMXPath($document);
$nodes = $xpath->query('//*[@id="larablog-toc-root"]//*[self::h2 or self::h3 or self::h4]');
if ($nodes === false) {
return ['html' => $html, 'toc' => []];
}
$toc = [];
$usedIds = [];
foreach ($nodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
if ($text === '') {
continue;
}
$level = (int) substr(strtolower($node->tagName), 1);
$id = $node->getAttribute('id');
if ($id === '') {
$id = $this->slugifyHeading($text);
}
$base = $id;
$i = 2;
while (isset($usedIds[$id])) {
$id = $base.'-'.$i;
$i++;
}
$usedIds[$id] = true;
$node->setAttribute('id', $id);
$toc[] = [
'level' => $level,
'id' => $id,
'text' => $text,
];
}
$root = $document->getElementById('larablog-toc-root');
$out = $html;
if ($root instanceof DOMElement) {
$out = '';
foreach ($root->childNodes as $child) {
$out .= $document->saveHTML($child);
}
}
return ['html' => $out, 'toc' => $toc];
}
protected function slugifyHeading(string $text): string
{
$slug = strtolower(trim($text));
$slug = preg_replace('/[^\p{L}\p{N}\-_]+/u', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'section';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Domain\Blog;
use Mews\Purifier\Facades\Purifier;
class HtmlTeaser
{
/**
* Build a teaser from already-rendered HTML.
*
* The result is always a plain-text excerpt and always withholds part of the
* body: when the configured limit covers the whole article we still cut it
* in half, so a gated article can never be served in full through a teaser.
*/
public function truncate(string $html, int $maxChars): string
{
if ($maxChars <= 0 || trim($html) === '') {
return '';
}
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
if ($text === '') {
return '';
}
$length = mb_strlen($text);
$limit = min($maxChars, max(1, (int) floor($length / 2)));
$snippet = mb_substr($text, 0, $limit);
if ($limit < $length) {
$snippet .= '…';
}
return Purifier::clean('<p>'.e($snippet).'</p>', 'article');
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Domain\Media;
use App\Models\Attachment;
use App\Settings\GeneralSettings;
use Illuminate\Http\RedirectResponse;
use Throwable;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class AttachmentStorageService
{
public function uploadLocalFileToDisk(
string $localPath,
string $destinationPath,
?string $disk = null,
string $visibility = Attachment::VISIBILITY_PUBLIC,
): Attachment {
if (! is_file($localPath)) {
throw new RuntimeException("Local file [{$localPath}] does not exist.");
}
$disk ??= config('larablog.attachments_disk', 'attachments');
$normalizedPath = ltrim(str_replace('\\', '/', $destinationPath), '/');
$mime = mime_content_type($localPath) ?: null;
$allowed = config('larablog.allowed_attachment_mimes', []);
if (is_string($mime) && $allowed !== [] && ! in_array($mime, $allowed, true)) {
throw new RuntimeException("MIME type [{$mime}] is not allowed for attachments.");
}
$stream = fopen($localPath, 'r');
if ($stream === false) {
throw new RuntimeException("Unable to read local file [{$localPath}].");
}
Storage::disk($disk)->put($normalizedPath, $stream, [
'visibility' => $visibility === Attachment::VISIBILITY_PUBLIC ? 'public' : 'private',
]);
if (is_resource($stream)) {
fclose($stream);
}
return Attachment::query()->create([
'disk' => $disk,
'path' => $normalizedPath,
'filename' => basename($normalizedPath),
'mime' => $mime,
'size' => filesize($localPath) ?: 0,
'checksum' => hash_file('sha256', $localPath) ?: null,
'visibility' => $visibility,
'synced_at' => now(),
]);
}
public function publicUrl(Attachment $attachment): string
{
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
return $this->temporaryUrl($attachment);
}
$disk = Storage::disk($attachment->disk);
if (method_exists($disk, 'url')) {
return $disk->url($attachment->path);
}
return $this->temporaryUrl($attachment);
}
public function temporaryUrl(Attachment $attachment, int $minutes = 30): string
{
return Storage::disk($attachment->disk)->temporaryUrl(
$attachment->path,
now()->addMinutes($minutes),
[
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($attachment->filename).'"',
],
);
}
public function resolveRedirectResponseById(int $id, ?string $ip = null): RedirectResponse|Response
{
$attachment = Attachment::query()->find($id);
if ($attachment === null) {
abort(SymfonyResponse::HTTP_NOT_FOUND);
}
return $this->resolveRedirectResponse($attachment, $ip);
}
public function resolveRedirectResponseByLegacyPath(string $legacyPath, ?string $ip = null): RedirectResponse|Response
{
$raw = ltrim(str_replace('\\', '/', $legacyPath), '/');
$normalized = $this->normalizeLegacyPath($legacyPath);
$prefix = $this->attachmentsUrlPrefix();
$withPrefix = ($prefix !== '' && ! Str::startsWith($raw, $prefix.'/'))
? $prefix.'/'.$raw
: $raw;
$candidates = array_values(array_unique(array_filter([$raw, $normalized, $withPrefix])));
$attachment = Attachment::query()
->where(function ($query) use ($candidates) {
$query->whereIn('legacy_filepath', $candidates)
->orWhereIn('path', $candidates);
})
->first();
if ($attachment === null) {
abort(SymfonyResponse::HTTP_NOT_FOUND);
}
return $this->resolveRedirectResponse($attachment, $ip);
}
public function resolveRedirectResponse(Attachment $attachment, ?string $ip = null): RedirectResponse
{
if ($attachment->visibility !== Attachment::VISIBILITY_PUBLIC) {
abort(SymfonyResponse::HTTP_FORBIDDEN);
}
$this->incrementDownloads($attachment, $ip);
$targetUrl = $attachment->visibility === Attachment::VISIBILITY_PUBLIC
? $this->publicUrl($attachment)
: $this->temporaryUrl($attachment);
return redirect()->away($targetUrl, SymfonyResponse::HTTP_FOUND);
}
public function incrementDownloads(Attachment $attachment, ?string $ip = null): void
{
$ip ??= request()->ip() ?? 'unknown';
$lockKey = "attachment-download:{$attachment->id}:{$ip}";
$lock = Cache::lock($lockKey, 60);
if (! $lock->get()) {
return;
}
$attachment->increment('downloads');
}
protected function normalizeLegacyPath(string $legacyPath): string
{
$path = str_replace('\\', '/', $legacyPath);
$path = ltrim($path, '/');
$prefix = $this->attachmentsUrlPrefix();
if ($prefix !== '' && Str::startsWith($path, $prefix.'/')) {
$path = Str::after($path, $prefix.'/');
}
return $path;
}
public function deleteFromDisk(Attachment $attachment): void
{
$disk = Storage::disk($attachment->disk);
if ($attachment->path !== '' && $disk->exists($attachment->path)) {
$disk->delete($attachment->path);
}
if (filled($attachment->thumb_path) && $disk->exists($attachment->thumb_path)) {
$disk->delete($attachment->thumb_path);
}
}
protected function attachmentsUrlPrefix(): string
{
try {
$fromSettings = app(GeneralSettings::class)->attachments_url_prefix;
if (is_string($fromSettings) && $fromSettings !== '') {
return trim($fromSettings, '/');
}
} catch (Throwable) {
// settings unavailable during early boot / migrate
}
return trim((string) config('larablog.attachments_url_prefix', 'attachments'), '/');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
class Hook
{
/** @var array<string, list<callable>> */
protected static array $listeners = [];
public static function listen(string $event, callable $listener): void
{
static::$listeners[$event][] = $listener;
}
public static function dispatch(string $event, mixed ...$payload): void
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$listener(...$payload);
}
}
/**
* Collect string fragments from listeners (for theme injection points).
*/
public static function gather(string $event, string $initial = '', mixed ...$payload): string
{
$buffer = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($buffer, ...$payload);
if (is_string($result)) {
$buffer = $result;
}
}
return $buffer;
}
/**
* Merge array fragments from listeners (for Filament schema/columns/actions).
*
* @param array<int|string, mixed> $initial
* @return array<int|string, mixed>
*/
public static function collect(string $event, array $initial = [], mixed ...$payload): array
{
$items = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($items, ...$payload);
if (! is_array($result)) {
continue;
}
foreach ($result as $key => $value) {
if (is_int($key)) {
$items[] = $value;
} else {
$items[$key] = $value;
}
}
}
return $items;
}
/**
* Pipe a value through listeners (each may replace it).
*/
public static function filter(string $event, mixed $value, mixed ...$payload): mixed
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$value = $listener($value, ...$payload);
}
return $value;
}
/**
* Registered listeners for an event, for callers that need to fold results
* themselves instead of letting each listener replace the value outright.
*
* @return list<callable>
*/
public static function listeners(string $event): array
{
return static::$listeners[$event] ?? [];
}
public static function flush(): void
{
static::$listeners = [];
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
use App\Models\Plugin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use RuntimeException;
class PluginManager
{
/** @var array<string, array<string, mixed>> */
protected array $discovered = [];
public function discover(): Collection
{
$pluginPath = config('larablog.plugin_path', base_path('plugins'));
if (! is_dir($pluginPath)) {
return collect();
}
$plugins = collect();
foreach (File::directories($pluginPath) as $vendorDirectory) {
foreach (File::directories($vendorDirectory) as $pluginDirectory) {
$manifestPath = $pluginDirectory.'/plugin.json';
if (! is_file($manifestPath)) {
continue;
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (! is_array($manifest)) {
continue;
}
$name = (string) ($manifest['name'] ?? basename($pluginDirectory));
$relativePath = str_replace(base_path().'/', '', $pluginDirectory);
$plugins->put($name, array_merge($manifest, [
'name' => $name,
'path' => $pluginDirectory,
'relative_path' => $relativePath,
'provider' => $manifest['provider'] ?? null,
'requires' => array_values(array_filter(
array_map('strval', (array) ($manifest['requires'] ?? [])),
fn (string $item): bool => $item !== '',
)),
'optional' => array_values(array_filter(
array_map('strval', (array) ($manifest['optional'] ?? [])),
fn (string $item): bool => $item !== '',
)),
'docs' => (string) ($manifest['docs'] ?? 'README.md'),
]));
}
}
$this->discovered = $plugins->all();
return $plugins;
}
public function syncDiscoveredPlugins(): Collection
{
$discovered = $this->discover();
foreach ($discovered as $name => $manifest) {
Plugin::query()->updateOrCreate(
['name' => $name],
[
'version' => (string) ($manifest['version'] ?? '1.0.0'),
'path' => (string) ($manifest['relative_path'] ?? $manifest['path']),
],
);
}
return $discovered;
}
public function isEnabled(string $name): bool
{
return Plugin::query()
->where('name', $name)
->where('enabled', true)
->exists();
}
/**
* @return list<string>
*/
public function missingRequires(string $name): array
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
}
$missing = [];
foreach ((array) ($manifest['requires'] ?? []) as $required) {
if (! $this->isEnabled((string) $required)) {
$missing[] = (string) $required;
}
}
return $missing;
}
public function enable(string $name): Plugin
{
$missing = $this->missingRequires($name);
if ($missing !== []) {
throw new RuntimeException(
__('admin.messages.plugin_requires', [
'plugin' => $name,
'requires' => implode(', ', $missing),
])
);
}
$plugin = $this->findPluginRecord($name);
$plugin->update(['enabled' => true]);
return $plugin->refresh();
}
public function disable(string $name): Plugin
{
$dependents = $this->enabledDependentsOf($name);
if ($dependents !== []) {
throw new RuntimeException(
__('admin.messages.plugin_required_by', [
'plugin' => $name,
'dependents' => implode(', ', $dependents),
])
);
}
$plugin = $this->findPluginRecord($name);
$plugin->update(['enabled' => false]);
return $plugin->refresh();
}
/**
* @return list<string>
*/
public function enabledDependentsOf(string $name): array
{
$dependents = [];
foreach ($this->discover() as $candidate => $manifest) {
if ($candidate === $name || ! $this->isEnabled((string) $candidate)) {
continue;
}
$requires = array_map('strval', (array) ($manifest['requires'] ?? []));
if (in_array($name, $requires, true)) {
$dependents[] = (string) $candidate;
}
}
return $dependents;
}
public function docsPath(string $name): ?string
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
return null;
}
$docs = trim((string) ($manifest['docs'] ?? 'README.md'));
if ($docs === '' || str_starts_with($docs, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $docs) === 1) {
return null;
}
$root = realpath((string) $manifest['path']);
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
if ($root === false || $path === false || ! is_file($path)) {
return null;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
return null;
}
return $path;
}
public function readDocs(string $name): ?string
{
$path = $this->docsPath($name);
return $path !== null ? (string) file_get_contents($path) : null;
}
public function registerEnabledProviders(): void
{
$discovered = $this->discover();
Plugin::query()
->where('enabled', true)
->get()
->each(function (Plugin $plugin) use ($discovered): void {
$manifest = $discovered->get($plugin->name);
if ($manifest === null) {
return;
}
$providerClass = $manifest['provider'] ?? null;
if (! is_string($providerClass) || ! class_exists($providerClass)) {
return;
}
if (! is_subclass_of($providerClass, ServiceProvider::class)) {
return;
}
app()->register($providerClass);
});
}
protected function findPluginRecord(string $name): Plugin
{
$plugin = Plugin::query()->where('name', $name)->first();
if ($plugin === null) {
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
}
return $plugin;
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Domain\Seo;
use App\Models\Article;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
class SeoPresenter
{
public function __construct(
protected GeneralSettings $generalSettings,
protected SeoSettings $seoSettings,
) {}
public function title(?string $title = null): string
{
$suffix = $this->seoSettings->meta_title_suffix;
if ($title === null || $title === '') {
return $this->generalSettings->site_name;
}
return $suffix
? "{$title} {$suffix}"
: "{$title} - {$this->generalSettings->site_name}";
}
public function description(?string $description = null): string
{
return $description
?: ($this->seoSettings->default_description
?: ($this->generalSettings->site_description ?? ''));
}
public function keywords(?string $keywords = null): ?string
{
return $keywords ?: $this->seoSettings->default_keywords;
}
/**
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forHome(): array
{
$canonical = rtrim($this->generalSettings->site_url ?: url('/'), '/') ?: url('/');
return [
'title' => $this->title(),
'description' => $this->description(),
'keywords' => $this->keywords(),
'canonical' => $canonical,
'og' => $this->openGraph(),
'twitter' => $this->twitterCards(),
'jsonld' => $this->jsonLd(),
];
}
/**
* @return array{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forArticle(Article $article): array
{
$canonical = url('/show-'.$article->id.'.shtml');
return [
'title' => $this->title($article->title),
'description' => $this->description($article->description),
'keywords' => $this->keywords($article->keywords),
'canonical' => $canonical,
'og' => $this->openGraph($article),
'twitter' => $this->twitterCards($article),
'jsonld' => $this->jsonLd($article),
];
}
/**
* @return array<string, string>
*/
public function openGraph(?Article $article = null): array
{
$title = $this->title($article?->title);
$description = $this->description($article?->description);
$url = $article
? url('/show-'.$article->id.'.shtml')
: ($this->generalSettings->site_url ?: url('/'));
return array_filter([
'og:title' => $title,
'og:description' => $description,
'og:url' => $url,
'og:type' => $article ? 'article' : 'website',
'og:site_name' => $this->generalSettings->site_name,
'og:locale' => 'zh_CN',
]);
}
/**
* @return array<string, string>
*/
public function twitterCards(?Article $article = null): array
{
return array_filter([
'twitter:card' => 'summary',
'twitter:title' => $this->title($article?->title),
'twitter:description' => $this->description($article?->description),
]);
}
/**
* @return array<string, mixed>
*/
public function jsonLd(?Article $article = null): array
{
if (! $this->seoSettings->json_ld_enabled) {
return [];
}
if ($article === null) {
return [
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => $this->generalSettings->site_name,
'url' => $this->generalSettings->site_url ?: url('/'),
'description' => $this->description(),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => url('/search.shtml').'?keywords={search_term_string}',
'query-input' => 'required name=search_term_string',
],
];
}
return [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $article->title,
'description' => $this->description($article->description),
'datePublished' => optional($article->published_at)?->toIso8601String(),
'dateModified' => optional($article->updated_at)?->toIso8601String(),
'author' => [
'@type' => 'Person',
'name' => $article->user?->name ?? $article->user?->username,
],
'mainEntityOfPage' => url('/show-'.$article->id.'.shtml'),
];
}
}
@@ -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);
}
}