293 lines
9.6 KiB
PHP
293 lines
9.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Seo;
|
|
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class SeoDataBuilder
|
|
{
|
|
private ?string $title = null;
|
|
private ?string $description = null;
|
|
private ?string $keywords = null;
|
|
private ?string $canonical = null;
|
|
private ?string $robots = null;
|
|
private ?string $prev = null;
|
|
private ?string $next = null;
|
|
|
|
/** @var list<array{name: string, url: string}> */
|
|
private array $breadcrumbs = [];
|
|
|
|
/** @var list<array<string, mixed>> */
|
|
private array $jsonLd = [];
|
|
|
|
/** @var array<string, mixed> */
|
|
private array $og = [];
|
|
|
|
/** @var array<string, mixed> */
|
|
private array $twitter = [];
|
|
|
|
public static function make(): self
|
|
{
|
|
return new self();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $attributes
|
|
*/
|
|
public static function fromArray(array $attributes): self
|
|
{
|
|
$builder = new self();
|
|
|
|
if (filled($attributes['title'] ?? null)) {
|
|
$builder->title((string) $attributes['title']);
|
|
}
|
|
if (filled($attributes['description'] ?? null)) {
|
|
$builder->description((string) $attributes['description']);
|
|
}
|
|
if (filled($attributes['keywords'] ?? null)) {
|
|
$builder->keywords($attributes['keywords']);
|
|
}
|
|
if (filled($attributes['canonical'] ?? null)) {
|
|
$builder->canonical((string) $attributes['canonical']);
|
|
}
|
|
if (filled($attributes['robots'] ?? null)) {
|
|
$builder->robots((string) $attributes['robots']);
|
|
}
|
|
if (filled($attributes['prev'] ?? null)) {
|
|
$builder->prev((string) $attributes['prev']);
|
|
}
|
|
if (filled($attributes['next'] ?? null)) {
|
|
$builder->next((string) $attributes['next']);
|
|
}
|
|
if (! empty($attributes['breadcrumbs'] ?? null)) {
|
|
$builder->breadcrumbs($attributes['breadcrumbs']);
|
|
}
|
|
|
|
foreach (Arr::wrap($attributes['json_ld'] ?? []) as $schema) {
|
|
$builder->addJsonLd($schema);
|
|
}
|
|
foreach (Arr::wrap($attributes['structured_data'] ?? []) as $schema) {
|
|
$builder->addJsonLd($schema);
|
|
}
|
|
foreach (Arr::wrap($attributes['faq_schema'] ?? []) as $schema) {
|
|
$builder->addJsonLd($schema);
|
|
}
|
|
|
|
$builder->og(
|
|
type: $attributes['og_type'] ?? null,
|
|
title: $attributes['og_title'] ?? null,
|
|
description: $attributes['og_description'] ?? null,
|
|
url: $attributes['og_url'] ?? null,
|
|
image: $attributes['og_image'] ?? null,
|
|
imageAlt: $attributes['og_image_alt'] ?? null,
|
|
);
|
|
$builder->twitter(
|
|
card: $attributes['twitter_card'] ?? null,
|
|
title: $attributes['twitter_title'] ?? null,
|
|
description: $attributes['twitter_description'] ?? null,
|
|
image: $attributes['twitter_image'] ?? null,
|
|
);
|
|
|
|
return $builder;
|
|
}
|
|
|
|
public function title(?string $title): self
|
|
{
|
|
$this->title = $this->clean($title);
|
|
return $this;
|
|
}
|
|
|
|
public function description(?string $description): self
|
|
{
|
|
$this->description = $this->clean($description);
|
|
return $this;
|
|
}
|
|
|
|
public function keywords(array|string|null $keywords): self
|
|
{
|
|
if (is_array($keywords)) {
|
|
$keywords = collect($keywords)
|
|
->map(fn (mixed $keyword): string => trim((string) $keyword))
|
|
->filter()
|
|
->unique()
|
|
->implode(', ');
|
|
}
|
|
|
|
$this->keywords = $this->clean($keywords);
|
|
return $this;
|
|
}
|
|
|
|
public function canonical(?string $canonical): self
|
|
{
|
|
$this->canonical = $this->absoluteUrl($canonical);
|
|
return $this;
|
|
}
|
|
|
|
public function robots(?string $robots): self
|
|
{
|
|
$this->robots = $this->clean($robots);
|
|
return $this;
|
|
}
|
|
|
|
public function indexable(bool $indexable, bool $follow = true): self
|
|
{
|
|
$this->robots = $indexable
|
|
? 'index,' . ($follow ? 'follow' : 'nofollow')
|
|
: 'noindex,' . ($follow ? 'follow' : 'nofollow');
|
|
return $this;
|
|
}
|
|
|
|
public function prev(?string $prev): self
|
|
{
|
|
$this->prev = $this->absoluteUrl($prev);
|
|
return $this;
|
|
}
|
|
|
|
public function next(?string $next): self
|
|
{
|
|
$this->next = $this->absoluteUrl($next);
|
|
return $this;
|
|
}
|
|
|
|
public function breadcrumbs(iterable|Collection|null $breadcrumbs): self
|
|
{
|
|
$this->breadcrumbs = BreadcrumbTrail::normalize($breadcrumbs);
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $schema
|
|
*/
|
|
public function addJsonLd(?array $schema): self
|
|
{
|
|
if (is_array($schema) && $schema !== []) {
|
|
$this->jsonLd[] = $schema;
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
public function og(?string $type = null, ?string $title = null, ?string $description = null, ?string $url = null, ?string $image = null, ?string $imageAlt = null): self
|
|
{
|
|
$this->og = array_filter([
|
|
'type' => $this->clean($type),
|
|
'title' => $this->clean($title),
|
|
'description' => $this->clean($description),
|
|
'url' => $this->absoluteUrl($url),
|
|
'image' => $this->absoluteUrl($image),
|
|
'image_alt' => $this->clean($imageAlt),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '');
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function twitter(?string $card = null, ?string $title = null, ?string $description = null, ?string $image = null): self
|
|
{
|
|
$this->twitter = array_filter([
|
|
'card' => $this->clean($card),
|
|
'title' => $this->clean($title),
|
|
'description' => $this->clean($description),
|
|
'image' => $this->absoluteUrl($image),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '');
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function build(): SeoData
|
|
{
|
|
$title = $this->title ?: (string) config('seo.default_title', 'Skinbase');
|
|
$description = $this->description ?: (string) config('seo.default_description', 'Skinbase');
|
|
$robots = $this->robots ?: (string) config('seo.default_robots', 'index,follow');
|
|
$canonical = $this->canonical ?: url()->current();
|
|
$fallbackImage = $this->absoluteUrl((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
|
|
$image = $this->og['image'] ?? $this->twitter['image'] ?? $fallbackImage;
|
|
|
|
$jsonLd = collect($this->jsonLd)
|
|
->filter(fn (mixed $schema): bool => is_array($schema) && $schema !== [])
|
|
->values()
|
|
->all();
|
|
|
|
if ($this->breadcrumbs !== [] && ! $this->hasSchemaType($jsonLd, 'BreadcrumbList')) {
|
|
$jsonLd[] = [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'BreadcrumbList',
|
|
'itemListElement' => collect($this->breadcrumbs)
|
|
->values()
|
|
->map(fn (array $crumb, int $index): array => [
|
|
'@type' => 'ListItem',
|
|
'position' => $index + 1,
|
|
'name' => $crumb['name'],
|
|
'item' => $crumb['url'],
|
|
])
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
$keywords = config('seo.keywords_enabled', true) ? $this->keywords : null;
|
|
$ogTitle = $this->og['title'] ?? $title;
|
|
$ogDescription = $this->og['description'] ?? $description;
|
|
$ogType = $this->og['type'] ?? 'website';
|
|
$ogUrl = $this->og['url'] ?? $canonical;
|
|
$twitterCard = $this->twitter['card'] ?? ($image !== '' ? (string) config('seo.twitter_card', 'summary_large_image') : 'summary');
|
|
|
|
return new SeoData(array_filter([
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'keywords' => $keywords,
|
|
'canonical' => $canonical,
|
|
'robots' => $robots,
|
|
'prev' => $this->prev,
|
|
'next' => $this->next,
|
|
'breadcrumbs' => $this->breadcrumbs,
|
|
'og_type' => $ogType,
|
|
'og_title' => $ogTitle,
|
|
'og_description' => $ogDescription,
|
|
'og_url' => $ogUrl,
|
|
'og_image' => $image,
|
|
'og_image_alt' => $this->og['image_alt'] ?? null,
|
|
'og_site_name' => (string) config('seo.site_name', 'Skinbase'),
|
|
'twitter_card' => $twitterCard,
|
|
'twitter_title' => $this->twitter['title'] ?? $ogTitle,
|
|
'twitter_description' => $this->twitter['description'] ?? $ogDescription,
|
|
'twitter_image' => $this->twitter['image'] ?? $image,
|
|
'json_ld' => $jsonLd,
|
|
'is_indexable' => ! str_contains(strtolower($robots), 'noindex'),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $schemas
|
|
*/
|
|
private function hasSchemaType(array $schemas, string $type): bool
|
|
{
|
|
foreach ($schemas as $schema) {
|
|
if (($schema['@type'] ?? null) === $type) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function clean(?string $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
private function absoluteUrl(?string $url): ?string
|
|
{
|
|
$url = $this->clean($url);
|
|
if ($url === null) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/^https?:\/\//i', $url) === 1) {
|
|
return $url;
|
|
}
|
|
|
|
return url($url);
|
|
}
|
|
} |