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,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:  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 '';
|
||||
}
|
||||
|
||||
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($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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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'), '/');
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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