SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test

This commit is contained in:
2026-05-08 21:51:28 +02:00
parent 0c5dde9b22
commit 6b83d76cd1
2 changed files with 484 additions and 22 deletions

View File

@@ -42,8 +42,9 @@ final class SeoFactory
/**
* @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');
$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, '…');
$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;
@@ -60,6 +64,7 @@ final class SeoFactory
->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',
@@ -73,9 +78,13 @@ final class SeoFactory
'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' => $artwork->license_url,
'license' => $licenseUrl,
'keywords' => $keywords !== [] ? $keywords : null,
'representativeOfPage' => true,
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []))
->addJsonLd(array_filter([
'@context' => 'https://schema.org',
@@ -84,14 +93,257 @@ final class SeoFactory
'description' => $description,
'url' => $canonical,
'author' => ['@type' => 'Person', 'name' => $authorName],
'publisher' => ['@type' => 'Organization', 'name' => $publisherName, 'url' => $publisherUrl],
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $artwork->license_url,
'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));
@@ -129,6 +381,58 @@ final class SeoFactory
->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()
@@ -229,48 +533,149 @@ final class SeoFactory
->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]);
}
}
$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,
'name' => $name !== '' ? $name : null,
'url' => filled($url) ? url((string) $url) : null,
'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();
if ($itemListElement === []) {
return null;
}
$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',
'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()),
'@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';
}
}

View 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');
});