web: Strukturierte Daten (JSON-LD) und SEO-Metas fürs öffentliche Frontend

Ergänzt die bislang fehlenden strukturierten Daten auf den Presse-Seiten:

- PM-Detailseite: NewsArticle + BreadcrumbList als JSON-LD sowie
  article:published_time/-section/-tag-Metas und og:image:alt — nur auf
  der öffentlichen Seite, die noindex-Vorschau bleibt ohne Markup.
- Kategorie-Seiten: BreadcrumbList plus hreflang de/en über die
  lokalisierten Slugs (CategoryPageData liefert dafür neu
  categoryAlternateSlugs, mit View-Fallback für gecachte Alt-Daten).
- Newsroom: Organization-Markup (Logo, Website als sameAs, Boilerplate).
- Portal-Homepages: WebSite mit SearchAction (Sitelinks-Searchbox) und
  Organization.
- Layout: og:locale und twitter:image ergänzt; neue Sections meta_extra
  und structured_data.

Neue Komponente x-web.structured-data rendert JSON-LD mit JSON_HEX_TAG,
damit "</script>" in Titeln/Teasern das Script-Tag nicht aufbrechen kann.
JSON-LD-URLs werden wie der Canonical aus der Portal-Basis gebaut
(unabhängig von forceRootUrl), damit Host und Canonical übereinstimmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-16 10:27:45 +00:00
parent c70e1754e0
commit 379688f4ff
9 changed files with 346 additions and 0 deletions

View file

@ -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)
<meta property="article:published_time" content="{{ $publishedAt->toIso8601String() }}">
@endif
@if ($release->updated_at?->greaterThan($publishedAt ?? $release->updated_at))
<meta property="article:modified_time" content="{{ $release->updated_at->toIso8601String() }}">
@endif
@if ($categoryName)
<meta property="article:section" content="{{ $categoryName }}">
@endif
@foreach ($keywords as $keyword)
<meta property="article:tag" content="{{ $keyword }}">
@endforeach
@if ($imageUrl && ($imageCaption || $release->title))
<meta property="og:image:alt" content="{{ $imageCaption ?: $release->title }}">
@endif
@endsection
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection
@endif
@section('content')