Implement creator studio and upload updates
This commit is contained in:
103
app/Support/Seo/BreadcrumbTrail.php
Normal file
103
app/Support/Seo/BreadcrumbTrail.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Seo;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class BreadcrumbTrail
|
||||
{
|
||||
/**
|
||||
* @param iterable<mixed>|Collection<int, mixed>|null $breadcrumbs
|
||||
* @return list<array{name: string, url: string}>
|
||||
*/
|
||||
public static function normalize(iterable|Collection|null $breadcrumbs): array
|
||||
{
|
||||
$items = collect($breadcrumbs instanceof Collection ? $breadcrumbs->all() : ($breadcrumbs ?? []))
|
||||
->map(fn (mixed $crumb): ?array => self::mapCrumb($crumb))
|
||||
->filter(fn (?array $crumb): bool => is_array($crumb) && $crumb['name'] !== '' && $crumb['url'] !== '')
|
||||
->values();
|
||||
|
||||
$home = [
|
||||
'name' => 'Home',
|
||||
'url' => self::absoluteUrl('/'),
|
||||
];
|
||||
|
||||
$normalized = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($items as $crumb) {
|
||||
$name = trim((string) ($crumb['name'] ?? ''));
|
||||
$url = self::absoluteUrl((string) ($crumb['url'] ?? ''));
|
||||
|
||||
if ($name === '' || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isHome($name, $url)) {
|
||||
$name = $home['name'];
|
||||
$url = $home['url'];
|
||||
}
|
||||
|
||||
$key = strtolower($name) . '|' . rtrim(strtolower($url), '/');
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$normalized[] = ['name' => $name, 'url' => $url];
|
||||
}
|
||||
|
||||
if ($normalized === [] || ! self::isHome($normalized[0]['name'], $normalized[0]['url'])) {
|
||||
array_unshift($normalized, $home);
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $crumb
|
||||
* @return array{name: string, url: string}|null
|
||||
*/
|
||||
private static function mapCrumb(mixed $crumb): ?array
|
||||
{
|
||||
if (is_array($crumb)) {
|
||||
return [
|
||||
'name' => trim((string) ($crumb['name'] ?? '')),
|
||||
'url' => trim((string) ($crumb['url'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_object($crumb)) {
|
||||
return [
|
||||
'name' => trim((string) ($crumb->name ?? '')),
|
||||
'url' => trim((string) ($crumb->url ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isHome(string $name, string $url): bool
|
||||
{
|
||||
$normalizedUrl = rtrim(strtolower($url), '/');
|
||||
$homeUrl = rtrim(strtolower(self::absoluteUrl('/')), '/');
|
||||
|
||||
return strtolower($name) === 'home' || $normalizedUrl === $homeUrl;
|
||||
}
|
||||
|
||||
private static function absoluteUrl(string $url): string
|
||||
{
|
||||
$trimmed = trim($url);
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('/^https?:\/\//i', $trimmed) === 1) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
return url($trimmed);
|
||||
}
|
||||
}
|
||||
23
app/Support/Seo/SeoData.php
Normal file
23
app/Support/Seo/SeoData.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Seo;
|
||||
|
||||
final class SeoData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function __construct(private readonly array $attributes)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
}
|
||||
293
app/Support/Seo/SeoDataBuilder.php
Normal file
293
app/Support/Seo/SeoDataBuilder.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
262
app/Support/Seo/SeoFactory.php
Normal file
262
app/Support/Seo/SeoFactory.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Seo;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SeoFactory
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function homepage(array $meta): SeoData
|
||||
{
|
||||
$description = trim((string) ($meta['description'] ?? config('seo.default_description')));
|
||||
|
||||
return SeoDataBuilder::make()
|
||||
->title((string) ($meta['title'] ?? config('seo.default_title')))
|
||||
->description($description)
|
||||
->keywords($meta['keywords'] ?? null)
|
||||
->canonical((string) ($meta['canonical'] ?? url('/')))
|
||||
->og(type: 'website', image: $meta['og_image'] ?? null)
|
||||
->addJsonLd([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'WebSite',
|
||||
'name' => (string) config('seo.site_name', 'Skinbase'),
|
||||
'url' => url('/'),
|
||||
'description' => $description,
|
||||
'potentialAction' => [
|
||||
'@type' => 'SearchAction',
|
||||
'target' => url('/search') . '?q={search_term_string}',
|
||||
'query-input' => 'required name=search_term_string',
|
||||
],
|
||||
])
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>|null> $thumbs
|
||||
*/
|
||||
public function artwork(Artwork $artwork, array $thumbs, string $canonical): SeoData
|
||||
{
|
||||
$authorName = html_entity_decode((string) ($artwork->user?->name ?: $artwork->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$title = html_entity_decode((string) ($artwork->title ?: 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$description = trim(strip_tags(html_entity_decode((string) ($artwork->description ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||||
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
|
||||
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
|
||||
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
|
||||
|
||||
return SeoDataBuilder::make()
|
||||
->title(sprintf('%s by %s — %s', $title, $authorName, config('seo.site_name', 'Skinbase')))
|
||||
->description($description)
|
||||
->keywords($keywords)
|
||||
->canonical($canonical)
|
||||
->og(type: 'article', image: $image)
|
||||
->addJsonLd(array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ImageObject',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'contentUrl' => $image,
|
||||
'thumbnailUrl' => $thumbs['md']['url'] ?? $image,
|
||||
'encodingFormat' => 'image/webp',
|
||||
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
|
||||
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
|
||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
'license' => $artwork->license_url,
|
||||
'keywords' => $keywords !== [] ? $keywords : null,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||
->addJsonLd(array_filter([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CreativeWork',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||
'license' => $artwork->license_url,
|
||||
'keywords' => $keywords !== [] ? $keywords : null,
|
||||
'image' => $image,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||
->build();
|
||||
}
|
||||
|
||||
public function profilePage(string $title, string $canonical, string $description, ?string $image = null, iterable $breadcrumbs = []): SeoData
|
||||
{
|
||||
$profileName = trim(str_replace([' Gallery on Skinbase', ' on Skinbase'], '', $title));
|
||||
|
||||
return SeoDataBuilder::make()
|
||||
->title($title)
|
||||
->description($description)
|
||||
->canonical($canonical)
|
||||
->breadcrumbs($breadcrumbs)
|
||||
->og(type: 'profile', image: $image)
|
||||
->addJsonLd([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ProfilePage',
|
||||
'name' => $title,
|
||||
'description' => $description,
|
||||
'url' => $canonical,
|
||||
'mainEntity' => array_filter([
|
||||
'@type' => 'Person',
|
||||
'name' => $profileName,
|
||||
'url' => $canonical,
|
||||
'image' => $image,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
])
|
||||
->build();
|
||||
}
|
||||
|
||||
public function collectionListing(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
|
||||
{
|
||||
return SeoDataBuilder::make()
|
||||
->title($title)
|
||||
->description($description)
|
||||
->canonical($canonical)
|
||||
->indexable($indexable)
|
||||
->og(type: 'website', image: $image)
|
||||
->build();
|
||||
}
|
||||
|
||||
public function collectionPage(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
|
||||
{
|
||||
return SeoDataBuilder::make()
|
||||
->title($title)
|
||||
->description($description)
|
||||
->canonical($canonical)
|
||||
->indexable($indexable)
|
||||
->og(type: 'website', image: $image)
|
||||
->build();
|
||||
}
|
||||
|
||||
public function leaderboardPage(string $title, string $description, string $canonical): SeoData
|
||||
{
|
||||
return SeoDataBuilder::make()
|
||||
->title($title)
|
||||
->description($description)
|
||||
->canonical($canonical)
|
||||
->og(type: 'website')
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fromViewData(array $data): array
|
||||
{
|
||||
if (($data['seo'] ?? null) instanceof SeoData) {
|
||||
return $data['seo']->toArray();
|
||||
}
|
||||
|
||||
if (is_array($data['seo'] ?? null) && ($data['seo'] ?? []) !== []) {
|
||||
return SeoDataBuilder::fromArray($data['seo'])->build()->toArray();
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'title' => $data['page_title'] ?? data_get($data, 'meta.title') ?? data_get($data, 'metaTitle'),
|
||||
'description' => $data['page_meta_description'] ?? data_get($data, 'meta.description') ?? data_get($data, 'metaDescription'),
|
||||
'keywords' => $data['page_meta_keywords'] ?? data_get($data, 'meta.keywords'),
|
||||
'canonical' => $data['page_canonical'] ?? data_get($data, 'meta.canonical') ?? url()->current(),
|
||||
'robots' => $data['page_robots'] ?? data_get($data, 'meta.robots'),
|
||||
'prev' => $data['page_rel_prev'] ?? null,
|
||||
'next' => $data['page_rel_next'] ?? null,
|
||||
'breadcrumbs' => $data['breadcrumbs'] ?? [],
|
||||
'structured_data' => Arr::wrap($data['structured_data'] ?? []),
|
||||
'faq_schema' => Arr::wrap($data['faq_schema'] ?? []),
|
||||
'og_type' => $data['seo_og_type'] ?? data_get($data, 'meta.og_type'),
|
||||
'og_title' => $data['og_title'] ?? data_get($data, 'meta.og_title'),
|
||||
'og_description' => $data['og_description'] ?? data_get($data, 'meta.og_description'),
|
||||
'og_url' => $data['og_url'] ?? data_get($data, 'meta.og_url'),
|
||||
'og_image' => $data['og_image']
|
||||
?? $data['ogImage']
|
||||
?? data_get($data, 'meta.og_image')
|
||||
?? data_get($data, 'meta.ogImage')
|
||||
?? data_get($data, 'props.hero.thumb_lg')
|
||||
?? data_get($data, 'props.hero.thumb')
|
||||
?? null,
|
||||
'og_image_alt' => $data['og_image_alt'] ?? null,
|
||||
];
|
||||
|
||||
$builder = SeoDataBuilder::fromArray($attributes);
|
||||
|
||||
if (($data['gallery_type'] ?? null) !== null) {
|
||||
$builder->addJsonLd($this->gallerySchema($data));
|
||||
}
|
||||
|
||||
return $builder->build()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function gallerySchema(array $data): ?array
|
||||
{
|
||||
$artworks = $data['artworks'] ?? null;
|
||||
if (! $artworks instanceof AbstractPaginator && ! $artworks instanceof Collection && ! is_array($artworks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$items = $artworks instanceof AbstractPaginator
|
||||
? collect($artworks->items())
|
||||
: collect($artworks);
|
||||
|
||||
$itemListElement = $items
|
||||
->take(12)
|
||||
->values()
|
||||
->map(function (mixed $artwork, int $index): ?array {
|
||||
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
|
||||
$url = data_get($artwork, 'url');
|
||||
|
||||
if (! filled($url)) {
|
||||
$slug = data_get($artwork, 'slug');
|
||||
$id = data_get($artwork, 'id');
|
||||
if (filled($slug) && filled($id)) {
|
||||
$url = route('art.show', ['id' => $id, 'slug' => $slug]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($name === '' && ! filled($url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'@type' => 'ListItem',
|
||||
'position' => $index + 1,
|
||||
'name' => $name !== '' ? $name : null,
|
||||
'url' => filled($url) ? url((string) $url) : null,
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($itemListElement === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
|
||||
|
||||
return [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => (string) ($data['page_title'] ?? $data['hero_title'] ?? config('seo.default_title', 'Skinbase')),
|
||||
'description' => (string) ($data['page_meta_description'] ?? $data['hero_description'] ?? config('seo.default_description')),
|
||||
'url' => (string) ($data['page_canonical'] ?? url()->current()),
|
||||
'mainEntity' => [
|
||||
'@type' => 'ItemList',
|
||||
'numberOfItems' => $count,
|
||||
'itemListElement' => $itemListElement,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user