681 lines
29 KiB
PHP
681 lines
29 KiB
PHP
<?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
|
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
|
*/
|
|
public function artwork(Artwork $artwork, array $thumbs, string $canonical, iterable $breadcrumbs = []): 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();
|
|
$licenseUrl = $this->clean((string) ($artwork->license_url ?? ''));
|
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
|
$publisherUrl = url('/');
|
|
|
|
$imageWidth = $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null;
|
|
$imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null;
|
|
|
|
return SeoDataBuilder::make()
|
|
->title(sprintf('%s by %s — %s', $title, $authorName, config('seo.site_name', 'Skinbase')))
|
|
->description($description)
|
|
->keywords($keywords)
|
|
->canonical($canonical)
|
|
->breadcrumbs($breadcrumbs)
|
|
->og(type: 'article', image: $image, imageAlt: sprintf('%s by %s', $title, $authorName), imageWidth: is_int($imageWidth) ? $imageWidth : null, imageHeight: is_int($imageHeight) ? $imageHeight : null)
|
|
->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],
|
|
'creator' => ['@type' => 'Person', 'name' => $authorName],
|
|
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
|
|
'creditText' => $authorName,
|
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
|
'license' => $licenseUrl,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
'representativeOfPage' => true,
|
|
], 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],
|
|
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
|
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
|
'license' => $licenseUrl,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
'image' => $image,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
|
->build();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
|
* @param array<int, string> $keywords
|
|
*/
|
|
public function academyLessonPage(
|
|
string $title,
|
|
string $description,
|
|
string $canonical,
|
|
?string $image = null,
|
|
iterable $breadcrumbs = [],
|
|
array $keywords = [],
|
|
?string $publishedAt = null,
|
|
?string $modifiedAt = null,
|
|
?string $articleSection = null,
|
|
): SeoData {
|
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
|
$publisherUrl = url('/');
|
|
$licenseUrl = route('terms-of-service');
|
|
$imageUrl = $this->normalizeUrl($image);
|
|
|
|
$builder = SeoDataBuilder::make()
|
|
->title($title)
|
|
->description($description)
|
|
->keywords($keywords)
|
|
->canonical($canonical)
|
|
->breadcrumbs($breadcrumbs)
|
|
->og(type: 'article', image: $imageUrl, imageAlt: $title);
|
|
|
|
if ($imageUrl !== null) {
|
|
$builder->addJsonLd(array_filter([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'ImageObject',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'contentUrl' => $imageUrl,
|
|
'thumbnailUrl' => $imageUrl,
|
|
'license' => $licenseUrl,
|
|
'acquireLicensePage' => $licenseUrl,
|
|
'creditText' => $publisherName,
|
|
'creator' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'publisher' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'datePublished' => $publishedAt,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
|
}
|
|
|
|
$builder->addJsonLd(array_filter([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'Article',
|
|
'headline' => $title,
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'mainEntityOfPage' => $canonical,
|
|
'datePublished' => $publishedAt,
|
|
'dateModified' => $modifiedAt ?? $publishedAt,
|
|
'author' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'publisher' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'articleSection' => $articleSection,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
'image' => $imageUrl,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
|
|
|
return $builder->build();
|
|
}
|
|
|
|
/**
|
|
* @param iterable<int, array<string, mixed>> $courses
|
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
|
*/
|
|
public function academyCourseListingPage(
|
|
string $title,
|
|
string $description,
|
|
string $canonical,
|
|
iterable $courses,
|
|
iterable $breadcrumbs = [],
|
|
?string $image = null,
|
|
): SeoData {
|
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
|
$publisherUrl = url('/');
|
|
$imageUrl = $this->normalizeUrl($image);
|
|
|
|
return SeoDataBuilder::make()
|
|
->title($title)
|
|
->description($description)
|
|
->canonical($canonical)
|
|
->breadcrumbs($breadcrumbs)
|
|
->og(type: 'website', image: $imageUrl)
|
|
->addJsonLd(array_filter([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'CollectionPage',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'mainEntity' => [
|
|
'@type' => 'ItemList',
|
|
'itemListElement' => collect($courses)
|
|
->values()
|
|
->map(function (array $course, int $index) use ($publisherName, $publisherUrl): array {
|
|
$courseImage = $this->normalizeUrl((string) ($course['cover_image_url'] ?? $course['teaser_image_url'] ?? $course['cover_image'] ?? $course['teaser_image'] ?? ''));
|
|
|
|
return array_filter([
|
|
'@type' => 'ListItem',
|
|
'position' => $index + 1,
|
|
'item' => array_filter([
|
|
'@type' => 'Course',
|
|
'name' => $this->clean((string) ($course['title'] ?? '')),
|
|
'description' => $this->clean((string) ($course['excerpt'] ?? $course['description'] ?? '')),
|
|
'url' => $this->clean((string) ($course['public_url'] ?? '')),
|
|
'image' => $courseImage,
|
|
'provider' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'isAccessibleForFree' => $this->academyCourseAccessibleForFree($course['access_level'] ?? null),
|
|
'educationalLevel' => $this->academyCourseEducationalLevel($course['difficulty'] ?? null),
|
|
'timeRequired' => $this->academyDuration((int) ($course['estimated_minutes'] ?? 0)),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
|
})
|
|
->filter(fn (array $item): bool => data_get($item, 'item.name') !== null && data_get($item, 'item.url') !== null)
|
|
->values()
|
|
->all(),
|
|
],
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
|
->build();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{name: string, url: string}>|iterable<mixed> $breadcrumbs
|
|
* @param array<int, string> $keywords
|
|
* @param iterable<int, array<string, mixed>> $lessons
|
|
*/
|
|
public function academyCoursePage(
|
|
string $title,
|
|
string $description,
|
|
string $canonical,
|
|
?string $image = null,
|
|
iterable $breadcrumbs = [],
|
|
array $keywords = [],
|
|
?string $publishedAt = null,
|
|
?string $modifiedAt = null,
|
|
?string $accessLevel = null,
|
|
?string $difficulty = null,
|
|
?int $estimatedMinutes = null,
|
|
iterable $lessons = [],
|
|
): SeoData {
|
|
$publisherName = (string) config('seo.site_name', 'Skinbase');
|
|
$publisherUrl = url('/');
|
|
$licenseUrl = route('terms-of-service');
|
|
$imageUrl = $this->normalizeUrl($image);
|
|
|
|
$builder = SeoDataBuilder::make()
|
|
->title($title)
|
|
->description($description)
|
|
->keywords($keywords)
|
|
->canonical($canonical)
|
|
->breadcrumbs($breadcrumbs)
|
|
->og(type: 'website', image: $imageUrl, imageAlt: $title);
|
|
|
|
if ($imageUrl !== null) {
|
|
$builder->addJsonLd(array_filter([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'ImageObject',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'contentUrl' => $imageUrl,
|
|
'thumbnailUrl' => $imageUrl,
|
|
'license' => $licenseUrl,
|
|
'acquireLicensePage' => $licenseUrl,
|
|
'creditText' => $publisherName,
|
|
'creator' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'publisher' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'representativeOfPage' => true,
|
|
'datePublished' => $publishedAt,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
|
}
|
|
|
|
$builder->addJsonLd(array_filter([
|
|
'@context' => 'https://schema.org',
|
|
'@type' => 'Course',
|
|
'name' => $title,
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'image' => $imageUrl,
|
|
'provider' => [
|
|
'@type' => 'Organization',
|
|
'name' => $publisherName,
|
|
'url' => $publisherUrl,
|
|
],
|
|
'isAccessibleForFree' => $this->academyCourseAccessibleForFree($accessLevel),
|
|
'educationalLevel' => $this->academyCourseEducationalLevel($difficulty),
|
|
'timeRequired' => $this->academyDuration($estimatedMinutes),
|
|
'datePublished' => $publishedAt,
|
|
'dateModified' => $modifiedAt ?? $publishedAt,
|
|
'keywords' => $keywords !== [] ? $keywords : null,
|
|
'hasCourseInstance' => collect($lessons)
|
|
->values()
|
|
->map(function (array $lesson, int $index): array {
|
|
return array_filter([
|
|
'@type' => 'CourseInstance',
|
|
'name' => $this->clean((string) ($lesson['title'] ?? '')),
|
|
'url' => $this->clean((string) ($lesson['course_url'] ?? '')),
|
|
'courseMode' => 'online',
|
|
'position' => $index + 1,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
|
})
|
|
->filter(fn (array $lesson): bool => ($lesson['name'] ?? null) !== null && ($lesson['url'] ?? null) !== null)
|
|
->values()
|
|
->all(),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []));
|
|
|
|
return $builder->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();
|
|
}
|
|
|
|
private function normalizeUrl(?string $url): ?string
|
|
{
|
|
$url = $this->clean($url);
|
|
|
|
if ($url === null) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/^https?:\/\//i', $url) === 1) {
|
|
return $url;
|
|
}
|
|
|
|
return url($url);
|
|
}
|
|
|
|
private function clean(?string $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
private function academyCourseAccessibleForFree(mixed $accessLevel): ?bool
|
|
{
|
|
$access = strtolower(trim((string) $accessLevel));
|
|
|
|
if ($access === '') {
|
|
return null;
|
|
}
|
|
|
|
return $access === 'free';
|
|
}
|
|
|
|
private function academyCourseEducationalLevel(mixed $difficulty): ?string
|
|
{
|
|
$value = strtolower(trim((string) $difficulty));
|
|
|
|
return match ($value) {
|
|
'beginner' => 'Beginner',
|
|
'intermediate' => 'Intermediate',
|
|
'advanced' => 'Advanced',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function academyDuration(?int $minutes): ?string
|
|
{
|
|
$minutes = (int) ($minutes ?? 0);
|
|
|
|
return $minutes > 0 ? sprintf('PT%dM', $minutes) : null;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
public function simplePage(string $title, string $description, string $canonical, bool $indexable = true): SeoData
|
|
{
|
|
return SeoDataBuilder::make()
|
|
->title($title)
|
|
->description($description)
|
|
->canonical($canonical)
|
|
->indexable($indexable)
|
|
->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 = $this->galleryArtworkUrl($artwork);
|
|
|
|
if ($name === '' && ! filled($url)) {
|
|
return null;
|
|
}
|
|
|
|
$thumbnailUrl = $this->normalizeUrl((string) (data_get($artwork, 'thumb_url') ?? ''));
|
|
$contentUrl = $this->galleryContentUrl($artwork, $thumbnailUrl);
|
|
$creatorName = $this->clean((string) (data_get($artwork, 'publisher.name') ?? data_get($artwork, 'uname') ?? data_get($artwork, 'username') ?? ''));
|
|
$creatorUrl = $this->normalizeUrl((string) (data_get($artwork, 'publisher.profile_url') ?? data_get($artwork, 'profile_url') ?? ''));
|
|
$creatorType = data_get($artwork, 'published_as_type') === 'group' || data_get($artwork, 'publisher.type') === 'group'
|
|
? 'Organization'
|
|
: 'Person';
|
|
$publishedAt = data_get($artwork, 'published_at');
|
|
$publishedAt = $publishedAt instanceof \DateTimeInterface ? $publishedAt->format(DATE_ATOM) : $this->clean(is_string($publishedAt) ? $publishedAt : null);
|
|
$width = data_get($artwork, 'width');
|
|
$height = data_get($artwork, 'height');
|
|
$genre = $this->clean((string) (data_get($artwork, 'content_type_name') ?? data_get($artwork, 'category_name') ?? ''));
|
|
$imageFormat = $this->galleryImageFormat($thumbnailUrl ?? $contentUrl);
|
|
|
|
return array_filter([
|
|
'@type' => 'ListItem',
|
|
'position' => $index + 1,
|
|
'url' => filled($url) ? $url : null,
|
|
'item' => array_filter([
|
|
'@type' => 'VisualArtwork',
|
|
'@id' => filled($url) ? $url . '#artwork' : null,
|
|
'name' => $name !== '' ? $name : null,
|
|
'url' => filled($url) ? $url : null,
|
|
'image' => $thumbnailUrl,
|
|
'thumbnailUrl' => $thumbnailUrl,
|
|
'contentUrl' => $contentUrl,
|
|
'creator' => $creatorName !== null ? array_filter([
|
|
'@type' => $creatorType,
|
|
'name' => $creatorName,
|
|
'url' => $creatorUrl,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '') : null,
|
|
'datePublished' => $publishedAt,
|
|
'genre' => $genre,
|
|
'encodingFormat' => $imageFormat,
|
|
'width' => is_numeric($width) ? (int) $width : null,
|
|
'height' => is_numeric($height) ? (int) $height : null,
|
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []),
|
|
], fn (mixed $value): bool => $value !== null && $value !== '');
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
|
|
$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'));
|
|
$canonical = (string) ($data['page_canonical'] ?? url()->current());
|
|
$about = collect(preg_split('/\s*,\s*/', (string) ($data['page_meta_keywords'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [])
|
|
->map(fn (string $keyword): string => trim($keyword))
|
|
->filter()
|
|
->take(5)
|
|
->values()
|
|
->all();
|
|
|
|
return [
|
|
'@context' => 'https://schema.org',
|
|
'@type' => ['CollectionPage', 'ImageGallery'],
|
|
'name' => $name,
|
|
'headline' => (string) ($data['hero_title'] ?? $name),
|
|
'description' => $description,
|
|
'url' => $canonical,
|
|
'about' => $about !== [] ? $about : null,
|
|
'mainEntity' => [
|
|
'@type' => 'ItemList',
|
|
'name' => (string) ($data['hero_title'] ?? $name),
|
|
'itemListOrder' => $this->galleryItemListOrder((string) ($data['current_sort'] ?? 'trending')),
|
|
'numberOfItems' => $count,
|
|
'itemListElement' => $itemListElement,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function galleryArtworkUrl(mixed $artwork): ?string
|
|
{
|
|
$url = $this->normalizeUrl((string) (data_get($artwork, 'url') ?? ''));
|
|
|
|
if ($url !== null) {
|
|
return $url;
|
|
}
|
|
|
|
$id = data_get($artwork, 'id');
|
|
|
|
if (! filled($id)) {
|
|
return null;
|
|
}
|
|
|
|
$slug = $this->clean((string) (data_get($artwork, 'slug') ?? ''));
|
|
|
|
return route('art.show', array_filter([
|
|
'id' => $id,
|
|
'slug' => $slug,
|
|
], fn (mixed $value): bool => $value !== null && $value !== ''));
|
|
}
|
|
|
|
private function galleryImageFormat(?string $url): ?string
|
|
{
|
|
$path = parse_url((string) $url, PHP_URL_PATH);
|
|
$extension = strtolower((string) pathinfo((string) $path, PATHINFO_EXTENSION));
|
|
|
|
return $extension !== '' ? 'image/' . $extension : null;
|
|
}
|
|
|
|
private function galleryContentUrl(mixed $artwork, ?string $fallbackUrl): ?string
|
|
{
|
|
$raw = trim((string) (data_get($artwork, 'thumb_srcset') ?? ''));
|
|
|
|
if ($raw === '') {
|
|
return $fallbackUrl;
|
|
}
|
|
|
|
$candidates = collect(explode(',', $raw))
|
|
->map(static function (string $candidate): array {
|
|
$parts = preg_split('/\s+/', trim($candidate)) ?: [];
|
|
$url = $parts[0] ?? null;
|
|
$descriptor = $parts[1] ?? null;
|
|
$width = is_string($descriptor) && preg_match('/^(\d+)w$/', $descriptor, $matches) === 1
|
|
? (int) $matches[1]
|
|
: 0;
|
|
|
|
return [
|
|
'url' => $url,
|
|
'width' => $width,
|
|
];
|
|
})
|
|
->filter(fn (array $candidate): bool => filled($candidate['url']))
|
|
->sortByDesc('width')
|
|
->values();
|
|
|
|
$bestUrl = $this->normalizeUrl((string) ($candidates->first()['url'] ?? ''));
|
|
|
|
return $bestUrl ?? $fallbackUrl;
|
|
}
|
|
|
|
private function galleryItemListOrder(string $sort): string
|
|
{
|
|
return $sort === 'oldest'
|
|
? 'https://schema.org/ItemListOrderAscending'
|
|
: 'https://schema.org/ItemListOrderDescending';
|
|
}
|
|
} |