diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index b1b93bfd..0c641f38 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -61,6 +61,13 @@ class CategoryPageData abort(404); } + // SEO/hreflang: lokalisierte Slugs beider Sprachen — die geladene + // translations-Relation ist auf die aktuelle Sprache gefiltert. + $alternateSlugs = $category->translations() + ->whereIn('locale', ['de', 'en']) + ->pluck('slug', 'locale') + ->all(); + $childCategories = Category::query() ->where('parent_id', $category->id) ->where('is_active', true) @@ -278,6 +285,7 @@ class CategoryPageData return [ 'category' => $category, + 'categoryAlternateSlugs' => $alternateSlugs, 'referenceNow' => $referenceNow, 'leadRelease' => $leadRelease, 'topReleases' => $topReleases, diff --git a/resources/views/components/web/structured-data.blade.php b/resources/views/components/web/structured-data.blade.php new file mode 100644 index 00000000..92dbaebf --- /dev/null +++ b/resources/views/components/web/structured-data.blade.php @@ -0,0 +1,5 @@ +@props(['data']) + +{{-- JSON_HEX_TAG escapet "<"/">" (<…), damit ein "" im Inhalt + das Script-Tag nicht aufbrechen kann (XSS über Titel/Teaser). --}} + diff --git a/resources/views/web/businessportal24.blade.php b/resources/views/web/businessportal24.blade.php index 0e211591..14e8313c 100644 --- a/resources/views/web/businessportal24.blade.php +++ b/resources/views/web/businessportal24.blade.php @@ -2,8 +2,41 @@ @php $brand = config('domains.domains.businessportal24.brand', []); + + // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). + $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $brandFullName = ($brand['name'] ?? 'businessportal').($brand['accent'] ?? '24'); + + $jsonLd = [ + '@context' => 'https://schema.org', + '@graph' => [ + [ + '@type' => 'WebSite', + 'name' => $brandFullName, + 'url' => $portalBase.'/', + 'inLanguage' => app()->getLocale(), + 'potentialAction' => [ + '@type' => 'SearchAction', + 'target' => [ + '@type' => 'EntryPoint', + 'urlTemplate' => $portalBase.'/'.app()->getLocale().'/suche?q={search_term_string}', + ], + 'query-input' => 'required name=search_term_string', + ], + ], + [ + '@type' => 'Organization', + 'name' => $brandFullName, + 'url' => $portalBase.'/', + ], + ], + ]; @endphp +@section('structured_data') + +@endsection + {{-- DE-Meta kommt brand-spezifisch aus config/domains.php, EN aus den Übersetzungsdateien. --}} @section('title', app()->getLocale() === 'de' ? ($brand['meta_title'] ?? 'Aktuelle Pressemitteilungen aus der deutschen Wirtschaft – businessportal24') diff --git a/resources/views/web/kategorie.blade.php b/resources/views/web/kategorie.blade.php index e80ab19c..20ae1554 100644 --- a/resources/views/web/kategorie.blade.php +++ b/resources/views/web/kategorie.blade.php @@ -30,11 +30,52 @@ ? (filled($leadRelease->subtitle) ? $leadRelease->subtitle : $leadRelease->teaserText(220)) : null; $leadReadTime = $leadRelease?->readTimeMinutes() ?? 1; + + // SEO: hreflang über die lokalisierten Kategorie-Slugs (nur wenn beide + // Übersetzungen existieren — sonst würden wir auf 404s verweisen). + // Fallback [] deckt gecachte Page-Data von vor diesem Feld ab. + $alternateSlugs = $categoryAlternateSlugs ?? []; + $catSlugDe = $alternateSlugs['de'] ?? null; + $catSlugEn = $alternateSlugs['en'] ?? null; + + // URLs aus der Portal-Basis (wie der Canonical im Layout) statt route(), + // damit Host und Canonical auch bei forceRootUrl-Abweichungen übereinstimmen. + $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24'; + + $breadcrumbs = collect([ + ['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()], + $parentName && $parentTranslation?->slug ? ['name' => $parentName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$parentTranslation->slug] : null, + $catSlug ? ['name' => $catName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$catSlug] : null, + ])->filter()->values(); + + $jsonLd = [ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => $breadcrumbs->map(fn (array $crumb, int $index) => [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => $crumb['name'], + 'item' => $crumb['item'], + ])->all(), + ]; @endphp @section('title', __('web.category.meta_title', ['category' => $catName, 'brand' => config('app.name')])) @section('meta_description', Str::limit(strip_tags($catDescription), 155)) +@if ($catSlugDe && $catSlugEn) + @section('hreflang') + + + + @endsection +@endif + +@section('structured_data') + +@endsection + @section('content') diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php index 9873dd22..bd0a9e16 100644 --- a/resources/views/web/layouts/web-master.blade.php +++ b/resources/views/web/layouts/web-master.blade.php @@ -49,12 +49,22 @@ + @if ($ogImage) @endif + @if ($ogImage) + + @endif + + {{-- Seitenspezifische Zusatz-Metas (z. B. article:* auf PM-Detailseiten) --}} + @yield('meta_extra') + + {{-- Strukturierte Daten (JSON-LD) – pro Seite optional gesetzt --}} + @yield('structured_data') diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php index 96226785..e66756df 100644 --- a/resources/views/web/newsroom.blade.php +++ b/resources/views/web/newsroom.blade.php @@ -30,6 +30,20 @@ ? (filled($leadRelease->subtitle) ? $leadRelease->subtitle : $leadRelease->teaserText(220)) : null; $leadReadTime = $leadRelease?->readTimeMinutes() ?? 1; + + // SEO: Der Newsroom ist die kanonische Firmenseite im Portal — + // Organization-Markup verknüpft PMs (author) mit diesem Profil. + // Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl). + $newsroomUrl = rtrim($domainUrl ?? config('app.url'), '/').'/'.app()->getLocale().'/newsroom/'.$company->slug; + $jsonLd = [ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'name' => $company->name, + 'url' => $newsroomUrl, + ...(filled($company->website) ? ['sameAs' => [$company->website]] : []), + ...($logoUrl ? ['logo' => $logoUrl] : []), + ...($boilerplate ? ['description' => Str::limit($boilerplate, 300)] : []), + ]; @endphp @section('title', $company->name.' – Newsroom · '.$brandName.$brandAccent) @@ -42,6 +56,10 @@ @endsection +@section('structured_data') + +@endsection + @section('content') diff --git a/resources/views/web/presseecho.blade.php b/resources/views/web/presseecho.blade.php index d617edc1..144ee8c8 100644 --- a/resources/views/web/presseecho.blade.php +++ b/resources/views/web/presseecho.blade.php @@ -2,8 +2,41 @@ @php $brand = config('domains.domains.presseecho.brand', []); + + // SEO: WebSite-Markup mit Sitelinks-Searchbox (SearchAction auf /suche). + $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + $brandFullName = ($brand['name'] ?? 'presse').($brand['accent'] ?? 'echo'); + + $jsonLd = [ + '@context' => 'https://schema.org', + '@graph' => [ + [ + '@type' => 'WebSite', + 'name' => $brandFullName, + 'url' => $portalBase.'/', + 'inLanguage' => app()->getLocale(), + 'potentialAction' => [ + '@type' => 'SearchAction', + 'target' => [ + '@type' => 'EntryPoint', + 'urlTemplate' => $portalBase.'/'.app()->getLocale().'/suche?q={search_term_string}', + ], + 'query-input' => 'required name=search_term_string', + ], + ], + [ + '@type' => 'Organization', + 'name' => $brandFullName, + 'url' => $portalBase.'/', + ], + ], + ]; @endphp +@section('structured_data') + +@endsection + {{-- DE-Meta kommt brand-spezifisch aus config/domains.php, EN aus den Übersetzungsdateien. --}} @section('title', app()->getLocale() === 'de' ? ($brand['meta_title'] ?? 'Aktuelle Pressemitteilungen aus Wirtschaft & Industrie – presseecho') diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index c39128b2..394a1da9 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -83,6 +83,63 @@ // URL existiert vor der Veröffentlichung noch nicht. $shareUrl = $isPreview ? request()->fullUrl() : route('release.detail', ['slug' => $release->slug]); $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24'; + + // SEO: NewsArticle + BreadcrumbList als JSON-LD — nur für die öffentliche + // Seite (die Vorschau ist noindex und hat keine stabile URL). + $jsonLd = null; + + if (! $isPreview) { + $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); + + // dateModified darf laut Google nicht vor datePublished liegen + // (updated_at kann bei terminierten PMs älter sein als published_at). + $modifiedAt = $release->updated_at?->greaterThan($publishedAt ?? $release->updated_at) + ? $release->updated_at + : $publishedAt; + + // URLs aus der Portal-Basis (wie der Canonical) statt route(), damit + // Host und Canonical auch bei forceRootUrl-Abweichungen übereinstimmen. + $breadcrumbs = collect([ + ['name' => $brandLabel, 'item' => $portalBase.'/'.app()->getLocale()], + $categoryName && $categorySlug ? ['name' => $categoryName, 'item' => $portalBase.'/'.app()->getLocale().'/category/'.$categorySlug] : null, + ['name' => $release->title, 'item' => $portalBase.'/'.app()->getLocale().'/press-release/'.$release->slug], + ])->filter()->values(); + + $jsonLd = [ + '@context' => 'https://schema.org', + '@graph' => [ + array_filter([ + '@type' => 'NewsArticle', + 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $shareUrl], + 'headline' => Str::limit($release->title, 110), + 'description' => filled($lead) ? $lead : $teaser, + 'image' => $imageUrl ? [$imageUrl] : null, + 'datePublished' => $publishedAt?->toIso8601String(), + 'dateModified' => $modifiedAt?->toIso8601String(), + 'articleSection' => $categoryName, + 'keywords' => $keywords->isNotEmpty() ? $keywords->implode(', ') : null, + 'wordCount' => $wordCount ?: null, + 'inLanguage' => app()->getLocale(), + 'isAccessibleForFree' => true, + 'author' => $companyName ? array_filter([ + '@type' => 'Organization', + 'name' => $companyName, + 'url' => $company?->slug ? $portalBase.'/'.app()->getLocale().'/newsroom/'.$company->slug : null, + ]) : null, + 'publisher' => ['@type' => 'Organization', 'name' => $brandLabel, 'url' => $portalBase], + ], fn ($value) => $value !== null), + [ + '@type' => 'BreadcrumbList', + 'itemListElement' => $breadcrumbs->map(fn (array $crumb, int $index) => [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => $crumb['name'], + 'item' => $crumb['item'], + ])->all(), + ], + ], + ]; + } @endphp @section('title', $release->title.' – '.$brandLabel) @@ -95,6 +152,28 @@ @section('robots', 'noindex,nofollow') @else @section('canonical', $shareUrl) + + @section('meta_extra') + @if ($publishedAt) + + @endif + @if ($release->updated_at?->greaterThan($publishedAt ?? $release->updated_at)) + + @endif + @if ($categoryName) + + @endif + @foreach ($keywords as $keyword) + + @endforeach + @if ($imageUrl && ($imageCaption || $release->title)) + + @endif + @endsection + + @section('structured_data') + + @endsection @endif @section('content') diff --git a/tests/Feature/Web/StructuredDataTest.php b/tests/Feature/Web/StructuredDataTest.php new file mode 100644 index 00000000..4c24daf3 --- /dev/null +++ b/tests/Feature/Web/StructuredDataTest.php @@ -0,0 +1,119 @@ +create(['portal' => Portal::Businessportal24]); + $category->translations()->create(['locale' => 'de', 'name' => 'Technologie', 'slug' => 'technologie']); + + $company = Company::factory()->businessportal24()->create(['name' => 'Muster GmbH']); + + return PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->for($category) + ->for($company) + ->create([ + 'slug' => 'strukturierte-daten-pm', + 'title' => 'Strukturierte Daten für Pressemitteilungen', + 'published_at' => now()->subDay(), + ]); +} + +test('the release detail page emits NewsArticle and BreadcrumbList JSON-LD', function () { + /** @var TestCase $this */ + makeStructuredDataRelease(); + + $this->get('https://businessportal24.test/de/press-release/strukturierte-daten-pm') + ->assertSuccessful() + ->assertSee('