*/ private array $breadcrumbs = []; /** @var list> */ private array $jsonLd = []; /** @var array */ private array $og = []; /** @var array */ private array $twitter = []; public static function make(): self { return new self(); } /** * @param array $attributes */ public static function fromArray(array $attributes): self { $builder = new self(); if (filled($attributes['title'] ?? null)) { $builder->title((string) $attributes['title']); } if (filled($attributes['description'] ?? null)) { $builder->description((string) $attributes['description']); } if (filled($attributes['keywords'] ?? null)) { $builder->keywords($attributes['keywords']); } if (filled($attributes['canonical'] ?? null)) { $builder->canonical((string) $attributes['canonical']); } if (filled($attributes['robots'] ?? null)) { $builder->robots((string) $attributes['robots']); } if (filled($attributes['prev'] ?? null)) { $builder->prev((string) $attributes['prev']); } if (filled($attributes['next'] ?? null)) { $builder->next((string) $attributes['next']); } if (! empty($attributes['breadcrumbs'] ?? null)) { $builder->breadcrumbs($attributes['breadcrumbs']); } foreach (Arr::wrap($attributes['json_ld'] ?? []) as $schema) { $builder->addJsonLd($schema); } foreach (Arr::wrap($attributes['structured_data'] ?? []) as $schema) { $builder->addJsonLd($schema); } foreach (Arr::wrap($attributes['faq_schema'] ?? []) as $schema) { $builder->addJsonLd($schema); } $builder->og( type: $attributes['og_type'] ?? null, title: $attributes['og_title'] ?? null, description: $attributes['og_description'] ?? null, url: $attributes['og_url'] ?? null, image: $attributes['og_image'] ?? null, imageAlt: $attributes['og_image_alt'] ?? null, ); $builder->twitter( card: $attributes['twitter_card'] ?? null, title: $attributes['twitter_title'] ?? null, description: $attributes['twitter_description'] ?? null, image: $attributes['twitter_image'] ?? null, ); return $builder; } public function title(?string $title): self { $this->title = $this->clean($title); return $this; } public function description(?string $description): self { $this->description = $this->clean($description); return $this; } public function keywords(array|string|null $keywords): self { if (is_array($keywords)) { $keywords = collect($keywords) ->map(fn (mixed $keyword): string => trim((string) $keyword)) ->filter() ->unique() ->implode(', '); } $this->keywords = $this->clean($keywords); return $this; } public function canonical(?string $canonical): self { $this->canonical = $this->absoluteUrl($canonical); return $this; } public function robots(?string $robots): self { $this->robots = $this->clean($robots); return $this; } public function indexable(bool $indexable, bool $follow = true): self { $this->robots = $indexable ? 'index,' . ($follow ? 'follow' : 'nofollow') : 'noindex,' . ($follow ? 'follow' : 'nofollow'); return $this; } public function prev(?string $prev): self { $this->prev = $this->absoluteUrl($prev); return $this; } public function next(?string $next): self { $this->next = $this->absoluteUrl($next); return $this; } public function breadcrumbs(iterable|Collection|null $breadcrumbs): self { $this->breadcrumbs = BreadcrumbTrail::normalize($breadcrumbs); return $this; } /** * @param array|null $schema */ public function addJsonLd(?array $schema): self { if (is_array($schema) && $schema !== []) { $this->jsonLd[] = $schema; } return $this; } public function og(?string $type = null, ?string $title = null, ?string $description = null, ?string $url = null, ?string $image = null, ?string $imageAlt = null): self { $this->og = array_filter([ 'type' => $this->clean($type), 'title' => $this->clean($title), 'description' => $this->clean($description), 'url' => $this->absoluteUrl($url), 'image' => $this->absoluteUrl($image), 'image_alt' => $this->clean($imageAlt), ], fn (mixed $value): bool => $value !== null && $value !== ''); return $this; } public function twitter(?string $card = null, ?string $title = null, ?string $description = null, ?string $image = null): self { $this->twitter = array_filter([ 'card' => $this->clean($card), 'title' => $this->clean($title), 'description' => $this->clean($description), 'image' => $this->absoluteUrl($image), ], fn (mixed $value): bool => $value !== null && $value !== ''); return $this; } public function build(): SeoData { $title = $this->title ?: (string) config('seo.default_title', 'Skinbase'); $description = $this->description ?: (string) config('seo.default_description', 'Skinbase'); $robots = $this->robots ?: (string) config('seo.default_robots', 'index,follow'); $canonical = $this->canonical ?: url()->current(); $fallbackImage = $this->absoluteUrl((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp')); $image = $this->og['image'] ?? $this->twitter['image'] ?? $fallbackImage; $jsonLd = collect($this->jsonLd) ->filter(fn (mixed $schema): bool => is_array($schema) && $schema !== []) ->values() ->all(); if ($this->breadcrumbs !== [] && ! $this->hasSchemaType($jsonLd, 'BreadcrumbList')) { $jsonLd[] = [ '@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => collect($this->breadcrumbs) ->values() ->map(fn (array $crumb, int $index): array => [ '@type' => 'ListItem', 'position' => $index + 1, 'name' => $crumb['name'], 'item' => $crumb['url'], ]) ->all(), ]; } $keywords = config('seo.keywords_enabled', true) ? $this->keywords : null; $ogTitle = $this->og['title'] ?? $title; $ogDescription = $this->og['description'] ?? $description; $ogType = $this->og['type'] ?? 'website'; $ogUrl = $this->og['url'] ?? $canonical; $twitterCard = $this->twitter['card'] ?? ($image !== '' ? (string) config('seo.twitter_card', 'summary_large_image') : 'summary'); return new SeoData(array_filter([ 'title' => $title, 'description' => $description, 'keywords' => $keywords, 'canonical' => $canonical, 'robots' => $robots, 'prev' => $this->prev, 'next' => $this->next, 'breadcrumbs' => $this->breadcrumbs, 'og_type' => $ogType, 'og_title' => $ogTitle, 'og_description' => $ogDescription, 'og_url' => $ogUrl, 'og_image' => $image, 'og_image_alt' => $this->og['image_alt'] ?? null, 'og_site_name' => (string) config('seo.site_name', 'Skinbase'), 'twitter_card' => $twitterCard, 'twitter_title' => $this->twitter['title'] ?? $ogTitle, 'twitter_description' => $this->twitter['description'] ?? $ogDescription, 'twitter_image' => $this->twitter['image'] ?? $image, 'json_ld' => $jsonLd, 'is_indexable' => ! str_contains(strtolower($robots), 'noindex'), ], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== [])); } /** * @param list> $schemas */ private function hasSchemaType(array $schemas, string $type): bool { foreach ($schemas as $schema) { if (($schema['@type'] ?? null) === $type) { return true; } } return false; } private function clean(?string $value): ?string { $value = trim((string) $value); return $value === '' ? null : $value; } private function absoluteUrl(?string $url): ?string { $url = $this->clean($url); if ($url === null) { return null; } if (preg_match('/^https?:\/\//i', $url) === 1) { return $url; } return url($url); } }