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 dec524989b
commit 2ea64ad222
9 changed files with 346 additions and 0 deletions

View file

@ -0,0 +1,5 @@
@props(['data'])
{{-- JSON_HEX_TAG escapet "<"/">" (<…), damit ein "</script>" im Inhalt
das Script-Tag nicht aufbrechen kann (XSS über Titel/Teaser). --}}
<script type="application/ld+json">{!! json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>

View file

@ -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')
<x-web.structured-data :data="$jsonLd" />
@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')

View file

@ -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')
<link rel="alternate" hreflang="de" href="{{ $portalBase }}/de/category/{{ $catSlugDe }}">
<link rel="alternate" hreflang="en" href="{{ $portalBase }}/en/category/{{ $catSlugEn }}">
<link rel="alternate" hreflang="x-default" href="{{ $portalBase }}/de/category/{{ $catSlugDe }}">
@endsection
@endif
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection
@section('content')
<x-web.site-header />

View file

@ -49,12 +49,22 @@
<meta property="og:title" content="{{ $pageTitle }}">
<meta property="og:description" content="{{ $pageDescription }}">
<meta property="og:url" content="{{ $canonicalUrl }}">
<meta property="og:locale" content="{{ app()->getLocale() === 'de' ? 'de_DE' : 'en_US' }}">
@if ($ogImage)
<meta property="og:image" content="{{ $ogImage }}">
@endif
<meta name="twitter:card" content="{{ $ogImage ? 'summary_large_image' : 'summary' }}">
<meta name="twitter:title" content="{{ $pageTitle }}">
<meta name="twitter:description" content="{{ $pageDescription }}">
@if ($ogImage)
<meta name="twitter:image" content="{{ $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')
<!-- Domain-spezifisches Favicon -->
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}">

View file

@ -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 @@
<link rel="alternate" hreflang="x-default" href="{{ url('/de/newsroom/'.$company->slug) }}">
@endsection
@section('structured_data')
<x-web.structured-data :data="$jsonLd" />
@endsection
@section('content')
<x-web.site-header />

View file

@ -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')
<x-web.structured-data :data="$jsonLd" />
@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')

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')