$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|null> $thumbs * @param array|iterable $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|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)); 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 $data * @return array */ 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 $data * @return array|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'; } }