SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test
This commit is contained in:
@@ -42,8 +42,9 @@ final class SeoFactory
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<string, mixed>|null> $thumbs
|
* @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): SeoData
|
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');
|
$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');
|
$title = html_entity_decode((string) ($artwork->title ?: 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
@@ -51,6 +52,9 @@ final class SeoFactory
|
|||||||
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
|
$description = Str::limit($description !== '' ? $description : $title, 160, '…');
|
||||||
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
|
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
|
||||||
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
|
$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;
|
$imageWidth = $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null;
|
||||||
$imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null;
|
$imageHeight = $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null;
|
||||||
@@ -60,6 +64,7 @@ final class SeoFactory
|
|||||||
->description($description)
|
->description($description)
|
||||||
->keywords($keywords)
|
->keywords($keywords)
|
||||||
->canonical($canonical)
|
->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)
|
->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([
|
->addJsonLd(array_filter([
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
@@ -73,9 +78,13 @@ final class SeoFactory
|
|||||||
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
|
'width' => $thumbs['xl']['width'] ?? $thumbs['lg']['width'] ?? null,
|
||||||
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
|
'height' => $thumbs['xl']['height'] ?? $thumbs['lg']['height'] ?? null,
|
||||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
'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(),
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||||
'license' => $artwork->license_url,
|
'license' => $licenseUrl,
|
||||||
'keywords' => $keywords !== [] ? $keywords : null,
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
|
'representativeOfPage' => true,
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||||
->addJsonLd(array_filter([
|
->addJsonLd(array_filter([
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
@@ -84,14 +93,257 @@ final class SeoFactory
|
|||||||
'description' => $description,
|
'description' => $description,
|
||||||
'url' => $canonical,
|
'url' => $canonical,
|
||||||
'author' => ['@type' => 'Person', 'name' => $authorName],
|
'author' => ['@type' => 'Person', 'name' => $authorName],
|
||||||
|
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
|
||||||
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
'datePublished' => optional($artwork->published_at)->toAtomString(),
|
||||||
'license' => $artwork->license_url,
|
'license' => $licenseUrl,
|
||||||
'keywords' => $keywords !== [] ? $keywords : null,
|
'keywords' => $keywords !== [] ? $keywords : null,
|
||||||
'image' => $image,
|
'image' => $image,
|
||||||
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
|
||||||
->build();
|
->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
|
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));
|
$profileName = trim(str_replace([' Gallery on Skinbase', ' on Skinbase'], '', $title));
|
||||||
@@ -129,6 +381,58 @@ final class SeoFactory
|
|||||||
->build();
|
->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
|
public function collectionPage(string $title, string $description, string $canonical, ?string $image = null, bool $indexable = true): SeoData
|
||||||
{
|
{
|
||||||
return SeoDataBuilder::make()
|
return SeoDataBuilder::make()
|
||||||
@@ -229,48 +533,149 @@ final class SeoFactory
|
|||||||
->values()
|
->values()
|
||||||
->map(function (mixed $artwork, int $index): ?array {
|
->map(function (mixed $artwork, int $index): ?array {
|
||||||
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
|
$name = trim((string) (data_get($artwork, 'name') ?? data_get($artwork, 'title') ?? ''));
|
||||||
$url = data_get($artwork, 'url');
|
$url = $this->galleryArtworkUrl($artwork);
|
||||||
|
|
||||||
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)) {
|
if ($name === '' && ! filled($url)) {
|
||||||
return null;
|
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([
|
return array_filter([
|
||||||
'@type' => 'ListItem',
|
'@type' => 'ListItem',
|
||||||
'position' => $index + 1,
|
'position' => $index + 1,
|
||||||
'name' => $name !== '' ? $name : null,
|
'url' => filled($url) ? $url : null,
|
||||||
'url' => filled($url) ? url((string) $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 !== '');
|
], fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
})
|
})
|
||||||
->filter()
|
->filter()
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($itemListElement === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = $artworks instanceof AbstractPaginator ? $artworks->total() : count($itemListElement);
|
$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 [
|
return [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => 'CollectionPage',
|
'@type' => ['CollectionPage', 'ImageGallery'],
|
||||||
'name' => (string) ($data['page_title'] ?? $data['hero_title'] ?? config('seo.default_title', 'Skinbase')),
|
'name' => $name,
|
||||||
'description' => (string) ($data['page_meta_description'] ?? $data['hero_description'] ?? config('seo.default_description')),
|
'headline' => (string) ($data['hero_title'] ?? $name),
|
||||||
'url' => (string) ($data['page_canonical'] ?? url()->current()),
|
'description' => $description,
|
||||||
|
'url' => $canonical,
|
||||||
|
'about' => $about !== [] ? $about : null,
|
||||||
'mainEntity' => [
|
'mainEntity' => [
|
||||||
'@type' => 'ItemList',
|
'@type' => 'ItemList',
|
||||||
|
'name' => (string) ($data['hero_title'] ?? $name),
|
||||||
|
'itemListOrder' => $this->galleryItemListOrder((string) ($data['current_sort'] ?? 'trending')),
|
||||||
'numberOfItems' => $count,
|
'numberOfItems' => $count,
|
||||||
'itemListElement' => $itemListElement,
|
'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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
57
tests/Unit/Seo/SeoFactoryGallerySchemaTest.php
Normal file
57
tests/Unit/Seo/SeoFactoryGallerySchemaTest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Seo\SeoFactory;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
|
||||||
|
it('builds visual artwork item data for gallery collection schemas', function (): void {
|
||||||
|
$seo = app(SeoFactory::class)->fromViewData([
|
||||||
|
'gallery_type' => 'content-type',
|
||||||
|
'page_title' => 'Wallpapers - Skinbase',
|
||||||
|
'hero_title' => 'Wallpapers',
|
||||||
|
'page_meta_description' => 'Discover desktop and mobile wallpapers from the Skinbase creative community.',
|
||||||
|
'page_meta_keywords' => 'wallpapers, skinbase, digital art',
|
||||||
|
'page_canonical' => 'https://skinbase.org/wallpapers',
|
||||||
|
'current_sort' => 'trending',
|
||||||
|
'breadcrumbs' => [
|
||||||
|
['name' => 'Explore', 'url' => '/explore'],
|
||||||
|
['name' => 'Wallpapers', 'url' => '/wallpapers'],
|
||||||
|
],
|
||||||
|
'artworks' => collect([
|
||||||
|
(object) [
|
||||||
|
'id' => 12899,
|
||||||
|
'name' => 'Example Wallpaper',
|
||||||
|
'slug' => 'example-wallpaper',
|
||||||
|
'url' => 'https://skinbase.org/art/12899/example-wallpaper',
|
||||||
|
'thumb_url' => 'https://files.skinbase.org/thumbs/example_md.webp',
|
||||||
|
'thumb_srcset' => 'https://files.skinbase.org/thumbs/example_lg.webp',
|
||||||
|
'uname' => 'CreatorName',
|
||||||
|
'username' => 'creatorname',
|
||||||
|
'profile_url' => 'https://skinbase.org/@creatorname',
|
||||||
|
'published_at' => now()->toAtomString(),
|
||||||
|
'width' => 3840,
|
||||||
|
'height' => 2160,
|
||||||
|
'content_type_name' => 'Wallpapers',
|
||||||
|
'published_as_type' => 'user',
|
||||||
|
'publisher' => [
|
||||||
|
'type' => 'user',
|
||||||
|
'name' => 'CreatorName',
|
||||||
|
'profile_url' => 'https://skinbase.org/@creatorname',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($seo['json_ld'][0]['@type'] ?? null)
|
||||||
|
->toBe(['CollectionPage', 'ImageGallery'])
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['@type'] ?? null)->toBe('ItemList')
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['itemListElement'][0]['item']['@type'] ?? null)->toBe('VisualArtwork')
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['itemListElement'][0]['item']['url'] ?? null)->toBe('https://skinbase.org/art/12899/example-wallpaper')
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['itemListElement'][0]['item']['creator']['name'] ?? null)->toBe('CreatorName')
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['itemListElement'][0]['item']['contentUrl'] ?? null)->toBe('https://files.skinbase.org/thumbs/example_lg.webp')
|
||||||
|
->and($seo['json_ld'][0]['mainEntity']['itemListElement'][0]['item']['encodingFormat'] ?? null)->toBe('image/webp')
|
||||||
|
->and($seo['json_ld'][1]['@type'] ?? null)->toBe('BreadcrumbList');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user