From 6b83d76cd1b6587fc4a40ba8a315ff380ec90d0b Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Fri, 8 May 2026 21:51:28 +0200 Subject: [PATCH] SEO: gallery VisualArtwork contentUrl -> single best URL; update gallery unit test --- app/Support/Seo/SeoFactory.php | 449 +++++++++++++++++- .../Unit/Seo/SeoFactoryGallerySchemaTest.php | 57 +++ 2 files changed, 484 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/Seo/SeoFactoryGallerySchemaTest.php diff --git a/app/Support/Seo/SeoFactory.php b/app/Support/Seo/SeoFactory.php index e8607e47..8e0f2ffa 100644 --- a/app/Support/Seo/SeoFactory.php +++ b/app/Support/Seo/SeoFactory.php @@ -42,8 +42,9 @@ final class SeoFactory /** * @param array|null> $thumbs + * @param array|iterable $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|iterable $breadcrumbs + * @param array $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> $courses + * @param array|iterable $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|iterable $breadcrumbs + * @param array $keywords + * @param iterable> $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'; + } } \ No newline at end of file diff --git a/tests/Unit/Seo/SeoFactoryGallerySchemaTest.php b/tests/Unit/Seo/SeoFactoryGallerySchemaTest.php new file mode 100644 index 00000000..13e7722c --- /dev/null +++ b/tests/Unit/Seo/SeoFactoryGallerySchemaTest.php @@ -0,0 +1,57 @@ +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'); +}); \ No newline at end of file