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:
parent
dec524989b
commit
2ea64ad222
9 changed files with 346 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
5
resources/views/components/web/structured-data.blade.php
Normal file
5
resources/views/components/web/structured-data.blade.php
Normal 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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) }}">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
119
tests/Feature/Web/StructuredDataTest.php
Normal file
119
tests/Feature/Web/StructuredDataTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue