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

@ -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,

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

View file

@ -0,0 +1,119 @@
<?php
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Tests\TestCase;
function makeStructuredDataRelease(): PressRelease
{
$category = Category::factory()->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('<script type="application/ld+json">', false)
->assertSee('"@type":"NewsArticle"', false)
->assertSee('"headline":"Strukturierte Daten für Pressemitteilungen"', false)
->assertSee('"datePublished"', false)
->assertSee('"publisher":{"@type":"Organization","name":"businessportal24"', false)
->assertSee('"author":{"@type":"Organization","name":"Muster GmbH"', false)
->assertSee('"@type":"BreadcrumbList"', false)
->assertSee('"articleSection":"Technologie"', false);
});
test('the release detail page emits article meta tags and og locale', function () {
/** @var TestCase $this */
makeStructuredDataRelease();
$this->get('https://businessportal24.test/de/press-release/strukturierte-daten-pm')
->assertSuccessful()
->assertSee('<meta property="article:published_time" content="', false)
->assertSee('<meta property="article:section" content="Technologie">', false)
->assertSee('<meta property="og:locale" content="de_DE">', false);
});
test('the private preview emits neither JSON-LD nor article meta tags', function () {
/** @var TestCase $this */
$owner = User::factory()->create();
$release = PressRelease::factory()->forPortal(Portal::Businessportal24)->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($release, $owner);
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertOk()
->assertDontSee('application/ld+json', false)
->assertDontSee('article:published_time', false);
});
test('a portal homepage emits WebSite JSON-LD with a SearchAction', function () {
/** @var TestCase $this */
$this->get('https://presseecho.test/de')
->assertSuccessful()
->assertSee('"@type":"WebSite"', false)
->assertSee('"@type":"SearchAction"', false)
->assertSee('https://presseecho.test/de/suche?q={search_term_string}', false);
});
test('the newsroom page emits Organization JSON-LD for the company', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$category->translations()->create(['locale' => 'de', 'name' => 'Wirtschaft', 'slug' => 'wirtschaft']);
$company = Company::factory()->businessportal24()->create([
'name' => 'Newsroom AG',
'website' => 'https://newsroom-ag.example',
]);
PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->for($category)
->for($company)
->create(['published_at' => now()->subDay()]);
$this->get('https://businessportal24.test/de/newsroom/'.$company->slug)
->assertSuccessful()
->assertSee('"@type":"Organization"', false)
->assertSee('"name":"Newsroom AG"', false)
->assertSee('"sameAs":["https://newsroom-ag.example"]', false);
});
test('the category page emits BreadcrumbList JSON-LD and localized hreflang links', function () {
/** @var TestCase $this */
$category = Category::factory()->create(['portal' => Portal::Businessportal24]);
$category->translations()->create(['locale' => 'de', 'name' => 'Technologie', 'slug' => 'technologie']);
$category->translations()->create(['locale' => 'en', 'name' => 'Technology', 'slug' => 'technology']);
$this->get('https://businessportal24.test/de/category/technologie')
->assertSuccessful()
->assertSee('"@type":"BreadcrumbList"', false)
->assertSee('"name":"Technologie"', false)
->assertSee('<link rel="alternate" hreflang="de" href="https://businessportal24.test/de/category/technologie">', false)
->assertSee('<link rel="alternate" hreflang="en" href="https://businessportal24.test/en/category/technology">', false);
});