M4: 插件框架(钩子系统/ZIP安装/市场客户端)+ blog-seo 插件(JSON-LD/OG/llms.txt/Markdown导出)+ 后台插件/主题管理页

This commit is contained in:
ak
2026-08-11 18:15:56 +08:00
parent dee0208f1d
commit f77c96d3aa
18 changed files with 994 additions and 10 deletions
@@ -0,0 +1,114 @@
<?php
namespace Plugins\Neatstudio\BlogSeo\Http\Middleware;
use App\Models\Post;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* SEO 增强中间件:注入 JSON-LD 结构化数据与 OG 标签。
* 主题无关:直接改写响应 HTML 的 </head>。
*/
class SeoMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $this->shouldEnhance($response)) {
return $response;
}
$content = $response->getContent();
$data = $this->structuredData($request);
if (empty($data)) {
return $response;
}
$jsonLd = '<script type="application/ld+json">'.json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).'</script>';
$og = $this->ogTags($request, $data);
$content = preg_replace('/<\/head>/', $jsonLd."\n".$og."\n</head>", $content, 1);
$response->setContent($content);
return $response;
}
private function shouldEnhance(Response $response): bool
{
$contentType = $response->headers->get('Content-Type', '');
// 跳过后台与静态资源
if (str_starts_with(request()->path(), 'admin')) {
return false;
}
return $response->getStatusCode() === 200
&& str_contains($contentType, 'text/html');
}
private function structuredData(Request $request): array
{
$siteName = blog_setting('site_name', config('blog.name'));
$route = $request->route();
if ($route && $route->getName() === 'posts.show') {
$slug = $route->parameter('slug');
$post = Cache::remember('seo.post.'.$slug, now()->addHour(), function () use ($slug) {
return Post::query()->where('slug', $slug)->first();
});
if ($post && $post->status === 'published') {
return [
'@context' => 'https://schema.org',
'@type' => 'BlogPosting',
'headline' => $post->title,
'url' => route('posts.show', $post->slug ?: $post->id),
'datePublished' => $post->published_at?->toIso8601String(),
'dateModified' => $post->updated_at?->toIso8601String(),
'author' => ['@type' => 'Person', 'name' => $post->author?->name ?? $siteName],
'publisher' => ['@type' => 'Organization', 'name' => $siteName],
'description' => Str::limit(strip_tags($post->excerpt_or_fallback), 200),
'mainEntityOfPage' => route('posts.show', $post->slug ?: $post->id),
];
}
}
return [
'@context' => 'https://schema.org',
'@type' => 'WebSite',
'name' => $siteName,
'url' => url('/'),
];
}
private function ogTags(Request $request, array $data): string
{
$siteName = blog_setting('site_name', config('blog.name'));
$isArticle = ($data['@type'] ?? '') === 'BlogPosting';
$tags = [
'<meta property="og:site_name" content="'.e($siteName).'">',
'<meta property="og:type" content="'.($isArticle ? 'article' : 'website').'">',
'<meta property="og:title" content="'.e($data['headline'] ?? $siteName).'">',
'<meta property="og:url" content="'.e(url()->current()).'">',
'<meta property="og:description" content="'.e($data['description'] ?? blog_setting('site_description', '')).'">',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:title" content="'.e($data['headline'] ?? $siteName).'">',
];
if ($isArticle) {
$tags[] = '<meta property="article:published_time" content="'.e($data['datePublished'] ?? '').'">';
}
return implode("\n", $tags);
}
}