diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php new file mode 100644 index 00000000..7dd4174b --- /dev/null +++ b/app/Http/Controllers/Web/SitemapController.php @@ -0,0 +1,126 @@ + */ + private const HUB_PAGES = [ + 'faq', 'kontakt', 'ueber-uns', 'hilfe', 'api', 'kuendigung', + 'impressum', 'datenschutz', 'agb', 'cookies', + ]; + + /** @var list */ + private const PORTAL_PAGES = [ + 'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'kontakt', + 'ueber-uns', 'hilfe', 'api', 'impressum', 'datenschutz', 'agb', 'cookies', + ]; + + public function __invoke(Request $request): Response + { + $host = $request->getHost(); + $base = rtrim($request->getSchemeAndHttpHost(), '/'); + $isHub = str_contains($host, 'pressekonto'); + + $xml = Cache::remember('web.sitemap.'.$host, 3600, function () use ($base, $isHub): string { + $urls = $isHub ? $this->hubUrls($base) : $this->portalUrls($base); + + return view('web.sitemap', ['urls' => $urls])->render(); + }); + + return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']); + } + + /** + * @return list + */ + private function hubUrls(string $base): array + { + $urls = [['loc' => $base.'/', 'lastmod' => null]]; + + foreach (self::HUB_PAGES as $page) { + $urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null]; + } + + return $urls; + } + + /** + * @return list + */ + private function portalUrls(string $base): array + { + $context = WebPortalContext::fromRequest(); + + $urls = [['loc' => $base.'/de', 'lastmod' => null]]; + + foreach (self::PORTAL_PAGES as $page) { + $urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null]; + } + + // Kategorien über die lokalisierten Slugs (CategoryTranslation). + $categorySlugs = Category::query() + ->where('is_active', true) + ->whereIn('portal', $context->portalValues) + ->with('translations') + ->get() + ->map(fn (Category $category) => $category->translations->firstWhere('locale', 'de')?->slug) + ->filter() + ->unique(); + + foreach ($categorySlugs as $slug) { + $urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null]; + } + + // Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv + // sichtbaren PM des Portals (gleiche Regel wie NewsroomsPageData). + $companies = Company::query() + ->where('is_active', true) + ->whereHas('pressReleases', fn ($query) => $query + ->withoutGlobalScope(PortalScope::class) + ->visibleInArchive() + ->forPortalValues($context->portalValues)) + ->get(['slug', 'updated_at']); + + foreach ($companies as $company) { + $urls[] = [ + 'loc' => $base.'/de/newsroom/'.$company->slug, + 'lastmod' => $company->updated_at?->toAtomString(), + ]; + } + + // Alle im Archiv sichtbaren Pressemitteilungen des Portals. + $releases = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->visibleInArchive() + ->forPortalValues($context->portalValues) + ->orderByDesc('published_at') + ->get(['slug', 'published_at', 'updated_at']); + + foreach ($releases as $release) { + $urls[] = [ + 'loc' => $base.'/de/press-release/'.$release->slug, + 'lastmod' => ($release->updated_at ?? $release->published_at)?->toAtomString(), + ]; + } + + return $urls; + } +} diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index eb053628..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: diff --git a/resources/views/web/sitemap.blade.php b/resources/views/web/sitemap.blade.php new file mode 100644 index 00000000..18d5a69e --- /dev/null +++ b/resources/views/web/sitemap.blade.php @@ -0,0 +1,11 @@ +{!! '' !!} + +@foreach ($urls as $url) + + {{ $url['loc'] }} +@if (! empty($url['lastmod'])) + {{ $url['lastmod'] }} +@endif + +@endforeach + diff --git a/routes/web.php b/routes/web.php index bc4b13c7..ff810595 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\ReleaseDetailController; +use App\Http\Controllers\Web\SitemapController; use App\Http\Controllers\Web\SucheController; use App\Http\Controllers\Web\TerminationController; use App\Http\Controllers\Web\VeroeffentlichenController; @@ -218,6 +219,31 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func Route::view('cookies', 'web.cookies')->name('cookies'); }); +// --------------------------------------------------------------------------- +// SEO: Sitemap + robots.txt, host-aware (Hub vs. Spokes). Die frühere +// statische public/robots.txt wurde entfernt — sie hätte diese Routen +// verschattet (Webserver liefert existierende Dateien direkt aus). +// --------------------------------------------------------------------------- +Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); + +Route::get('/robots.txt', function () { + $base = rtrim(request()->getSchemeAndHttpHost(), '/'); + + $lines = [ + 'User-agent: *', + 'Disallow: /admin', + 'Disallow: /login', + 'Disallow: /register', + 'Disallow: /de/suche', + 'Disallow: /en/suche', + '', + 'Sitemap: '.$base.'/sitemap.xml', + '', + ]; + + return response(implode("\n", $lines), 200, ['Content-Type' => 'text/plain; charset=UTF-8']); +})->name('robots'); + // --------------------------------------------------------------------------- // Sprach-/Edition-unabhängige Routen (API-Doku). // --------------------------------------------------------------------------- diff --git a/tests/Feature/Web/SitemapRobotsTest.php b/tests/Feature/Web/SitemapRobotsTest.php new file mode 100644 index 00000000..abbc8895 --- /dev/null +++ b/tests/Feature/Web/SitemapRobotsTest.php @@ -0,0 +1,88 @@ +get('https://presseecho.test/robots.txt') + ->assertSuccessful() + ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') + ->assertSee('Disallow: /admin', false) + ->assertSee('Sitemap: https://presseecho.test/sitemap.xml', false); + + $this->get('https://pressekonto.test/robots.txt') + ->assertSuccessful() + ->assertSee('Sitemap: https://pressekonto.test/sitemap.xml', false); +}); + +test('the spoke sitemap lists static pages, categories, newsrooms and archive releases of its portal', function () { + /** @var TestCase $this */ + $category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]); + $category->translations()->create(['locale' => 'de', 'name' => 'Technologie', 'slug' => 'technologie']); + + $company = Company::factory()->presseecho()->create(['is_active' => true]); + + PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create(['slug' => 'ki-report', 'published_at' => now()->subDay()]); + + // Fremd-Portal-PM darf nicht in der presseecho-Sitemap auftauchen. + $bpCategory = Category::factory()->create(['portal' => Portal::Businessportal24]); + PressRelease::factory() + ->published() + ->forPortal(Portal::Businessportal24) + ->for($bpCategory) + ->for(Company::factory()->businessportal24()->create()) + ->create(['slug' => 'bp24-only', 'published_at' => now()->subDay()]); + + $response = $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful(); + + expect($response->headers->get('Content-Type'))->toContain('application/xml'); + + expect($response->getContent()) + ->toContain('https://presseecho.test/de') + ->toContain('https://presseecho.test/de/preise') + ->toContain('https://presseecho.test/de/impressum') + ->toContain('https://presseecho.test/de/category/technologie') + ->toContain('https://presseecho.test/de/newsroom/'.$company->slug.'') + ->toContain('https://presseecho.test/de/press-release/ki-report') + ->not->toContain('bp24-only') + // Suche und Marketing-Platzhalterseiten bleiben draußen. + ->not->toContain('/de/suche') + ->not->toContain('/de/team'); +}); + +test('the hub sitemap lists the landing and legal pages but no archive content', function () { + /** @var TestCase $this */ + PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for(Category::factory()->create(['portal' => Portal::Presseecho])) + ->for(Company::factory()->presseecho()->create()) + ->create(['slug' => 'nicht-im-hub', 'published_at' => now()->subDay()]); + + $response = $this->get('https://pressekonto.test/sitemap.xml')->assertSuccessful(); + + expect($response->getContent()) + ->toContain('https://pressekonto.test/') + ->toContain('https://pressekonto.test/de/impressum') + ->toContain('https://pressekonto.test/de/kuendigung') + ->not->toContain('press-release') + ->not->toContain('newsroom'); +}); + +test('marketing pages carry brand-aware titles instead of the hardcoded legacy brand', function () { + /** @var TestCase $this */ + $response = $this->get('https://presseecho.test/de/team')->assertSuccessful(); + + expect($response->getContent()) + ->toContain('Unser Team – presseecho') + ->not->toContain('Business Portal 24'); +});