Files
gouki 3cec4c5e18
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped
wip: article AI polish, category SEO fields, cover generator, membership plan seeder
2026-09-07 18:48:37 +00:00

210 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Domain\Seo;
use App\Models\Article;
use App\Models\Category;
use App\Settings\GeneralSettings;
use App\Settings\SeoSettings;
use Illuminate\Support\Str;
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{title: string, description: string, keywords: ?string, canonical: string, og: array<string, string>, twitter: array<string, string>, jsonld: array<string, mixed>}
*/
public function forCategory(Category $category): array
{
$canonical = $category->publicUrl();
$rawDescription = $category->description ?: Str::limit(trim(preg_replace('/\s+/', ' ', (string) $category->intro) ?: ''), 160);
$description = $this->description($rawDescription !== '' ? $rawDescription : null);
return [
'title' => $this->title($category->name),
'description' => $description,
'keywords' => $this->keywords($category->keywords),
'canonical' => $canonical,
'og' => array_filter([
'og:title' => $this->title($category->name),
'og:description' => $description,
'og:url' => $canonical,
'og:type' => 'website',
'og:site_name' => $this->generalSettings->site_name,
'og:locale' => 'zh_CN',
'og:image' => $category->coverUrl(),
]),
'twitter' => array_filter([
'twitter:card' => $category->coverUrl() ? 'summary_large_image' : 'summary',
'twitter:title' => $this->title($category->name),
'twitter:description' => $description,
'twitter:image' => $category->coverUrl(),
]),
'jsonld' => $this->jsonLdForCategory($category, $canonical, $description),
];
}
/**
* @return array<string, mixed>
*/
protected function jsonLdForCategory(Category $category, string $canonical, string $description): array
{
if (! $this->seoSettings->json_ld_enabled) {
return [];
}
return [
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => $category->name,
'description' => $description,
'url' => $canonical,
'isPartOf' => [
'@type' => 'WebSite',
'name' => $this->generalSettings->site_name,
'url' => $this->generalSettings->site_url ?: url('/'),
],
];
}
/**
* @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',
'og:image' => $article?->coverUrl(),
]);
}
/**
* @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'),
];
}
}