Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Support\Seo;
final class SeoData
use Illuminate\Contracts\Support\Arrayable;
/** @implements Arrayable<string, mixed> */
final class SeoData implements Arrayable
{
/**
* @param array<string, mixed> $attributes

View File

@@ -82,6 +82,8 @@ final class SeoDataBuilder
url: $attributes['og_url'] ?? null,
image: $attributes['og_image'] ?? null,
imageAlt: $attributes['og_image_alt'] ?? null,
imageWidth: isset($attributes['og_image_width']) ? (int) $attributes['og_image_width'] : null,
imageHeight: isset($attributes['og_image_height']) ? (int) $attributes['og_image_height'] : null,
);
$builder->twitter(
card: $attributes['twitter_card'] ?? null,
@@ -121,7 +123,7 @@ final class SeoDataBuilder
public function canonical(?string $canonical): self
{
$this->canonical = $this->absoluteUrl($canonical);
$this->canonical = $this->absolutePageUrl($canonical);
return $this;
}
@@ -141,13 +143,13 @@ final class SeoDataBuilder
public function prev(?string $prev): self
{
$this->prev = $this->absoluteUrl($prev);
$this->prev = $this->absolutePageUrl($prev);
return $this;
}
public function next(?string $next): self
{
$this->next = $this->absoluteUrl($next);
$this->next = $this->absolutePageUrl($next);
return $this;
}
@@ -168,7 +170,7 @@ final class SeoDataBuilder
return $this;
}
public function og(?string $type = null, ?string $title = null, ?string $description = null, ?string $url = null, ?string $image = null, ?string $imageAlt = null): self
public function og(?string $type = null, ?string $title = null, ?string $description = null, ?string $url = null, ?string $image = null, ?string $imageAlt = null, ?int $imageWidth = null, ?int $imageHeight = null): self
{
$this->og = array_filter([
'type' => $this->clean($type),
@@ -177,6 +179,8 @@ final class SeoDataBuilder
'url' => $this->absoluteUrl($url),
'image' => $this->absoluteUrl($image),
'image_alt' => $this->clean($imageAlt),
'image_width' => $imageWidth,
'image_height' => $imageHeight,
], fn (mixed $value): bool => $value !== null && $value !== '');
return $this;
@@ -199,7 +203,7 @@ final class SeoDataBuilder
$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();
$canonical = $this->canonical ?: $this->defaultCanonicalUrl();
$fallbackImage = $this->absoluteUrl((string) config('seo.fallback_image_path', '/gfx/skinbase_back_001.webp'));
$image = $this->og['image'] ?? $this->twitter['image'] ?? $fallbackImage;
@@ -246,6 +250,8 @@ final class SeoDataBuilder
'og_url' => $ogUrl,
'og_image' => $image,
'og_image_alt' => $this->og['image_alt'] ?? null,
'og_image_width' => $this->og['image_width'] ?? null,
'og_image_height' => $this->og['image_height'] ?? null,
'og_site_name' => (string) config('seo.site_name', 'Skinbase'),
'twitter_card' => $twitterCard,
'twitter_title' => $this->twitter['title'] ?? $ogTitle,
@@ -325,4 +331,45 @@ final class SeoDataBuilder
return url($url);
}
private function absolutePageUrl(?string $url): ?string
{
$url = $this->clean($url);
if ($url === null) {
return null;
}
$base = rtrim((string) config('app.url', url('/')), '/');
if (preg_match('/^https?:\/\//i', $url) === 1) {
$parts = parse_url($url);
if ($parts === false) {
return $base;
}
$path = $parts['path'] ?? '/';
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
return $base . ($path !== '' ? $path : '/') . $query . $fragment;
}
if ($url[0] === '?') {
return $this->defaultCanonicalUrl() . $url;
}
if ($url[0] !== '/') {
$url = '/' . $url;
}
return $base . $url;
}
private function defaultCanonicalUrl(): string
{
$request = app()->bound('request') ? app('request') : null;
$path = $request?->getPathInfo() ?: '/';
return $this->absolutePageUrl($path) ?? rtrim((string) config('app.url', url('/')), '/');
}
}

View File

@@ -52,12 +52,15 @@ final class SeoFactory
$image = $thumbs['xl']['url'] ?? $thumbs['lg']['url'] ?? $thumbs['md']['url'] ?? null;
$keywords = $artwork->tags->pluck('name')->filter()->unique()->values()->all();
$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)
->og(type: 'article', image: $image)
->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',
@@ -147,6 +150,17 @@ final class SeoFactory
->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<string, mixed> $data
* @return array<string, mixed>