From ff75159b8f95e069f717b9dfd4ee1216800cbc70 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 21 Sep 2026 08:27:14 +0000 Subject: [PATCH] =?UTF-8?q?web:=20RSS-Feed=20je=20Newsroom,=20RSS-=C3=9Cbe?= =?UTF-8?q?rsichtsseite,=20=E2=80=9ERSS"=20in=20der=20Topleiste=20(Roadmap?= =?UTF-8?q?=203.1,=20Nachtrag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /{edition}/newsroom/{slug}/feed: Sichtbarkeit wie die Newsroom-Seite (aktiv, nicht entfernt, mindestens eine Meldung), Prüfung vor dem Cache, 301 bei zusammengeführter Firma; Autodiscovery und Link „RSS-Feed" auf der Newsroom-Seite; Hub leitet auf das Portal der Firma. - /{edition}/rss (RssPageController, static.rss DE/EN): Was RSS ist, wie ein Reader funktioniert, Hauptfeed, alle Branchen-Feeds mit Adresse, Muster des Newsroom-Feeds, Hinweise. Portalseite mit Portal-Kontext, Hub → presseecho, in Sitemap und llms.txt. - Topleiste, Burger-Menü und Footer verlinken RSS neben FAQ/Hilfe/API. - FeedController in portal/category/newsroom aufgeteilt; Panel „Auslieferung" nennt zusätzlich den Newsroom-Feed. - Tests: FeedTest (+3), RssPageTest (4), StaticPagesTest, VisibilityStatsTest. - dev/2026-09-01: Nachtrag in Protokoll Teil 11 und Roadmap 3.1. Co-Authored-By: Claude Fable 5.1 --- app/Http/Controllers/Web/FeedController.php | 181 ++++++++++++++---- .../Controllers/Web/LlmsTxtController.php | 2 +- .../Controllers/Web/RssPageController.php | 28 +++ .../Controllers/Web/SitemapController.php | 2 +- .../RedirectPortalOnlyPagesFromHub.php | 2 +- app/Services/PressRelease/VisibilityStats.php | 10 +- config/canonical.php | 4 +- dev/2026-09-01/roadmap-2026.md | 2 +- dev/2026-09-01/umsetzung-protokoll.md | 35 ++++ lang/de/static.php | 42 ++++ lang/de/web.php | 5 + lang/en/static.php | 42 ++++ lang/en/web.php | 5 + .../components/web/site-footer.blade.php | 1 + .../components/web/site-header.blade.php | 3 +- resources/views/web/newsroom.blade.php | 13 ++ resources/views/web/rss.blade.php | 104 ++++++++++ routes/web.php | 11 +- tests/Feature/VisibilityStatsTest.php | 1 + tests/Feature/Web/FeedTest.php | 65 ++++++- tests/Feature/Web/RssPageTest.php | 73 +++++++ tests/Feature/Web/StaticPagesTest.php | 1 + 22 files changed, 577 insertions(+), 55 deletions(-) create mode 100644 app/Http/Controllers/Web/RssPageController.php create mode 100644 resources/views/web/rss.blade.php create mode 100644 tests/Feature/Web/RssPageTest.php diff --git a/app/Http/Controllers/Web/FeedController.php b/app/Http/Controllers/Web/FeedController.php index 2a47bcca..b99a7852 100644 --- a/app/Http/Controllers/Web/FeedController.php +++ b/app/Http/Controllers/Web/FeedController.php @@ -4,23 +4,29 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Category; +use App\Models\Company; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Services\Company\MergedNewsroomLookup; use App\Support\StructuredData; use App\Support\WebPageCache; use App\Support\WebPortalContext; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; /** - * RSS-Feeds je Branche und je Portal (Roadmap 3.1): `/{edition}/feed` und - * `/{edition}/category/{slug}/feed`. Die jüngsten Meldungen der Ausgabe - * (Portal × Sprache), bei einer Branche inklusive Unterkategorien — wie die - * Branchenseite selbst. Bei Doppelveröffentlichung erscheint je Portal die - * eigene Fassung; Leser und Feeds sehen beide (Anhang 2), nur die - * Suchmaschine bekommt die Erstquelle. + * RSS-Feeds (Roadmap 3.1): je Portal (`/{edition}/feed`), je Branche + * (`/{edition}/category/{slug}/feed`, inklusive Unterkategorien wie die + * Branchenseite) und je Newsroom (`/{edition}/newsroom/{slug}/feed`). Immer + * die jüngsten Meldungen der Ausgabe (Portal × Sprache). Bei + * Doppelveröffentlichung erscheint je Portal die eigene Fassung; Leser und + * Feeds sehen beide (Anhang 2), nur die Suchmaschine bekommt die Erstquelle. * * Gecacht wie die Seitendaten (WebPageCache: 5 min frisch, danach stale und * Neuaufbau nach der Antwort); Entfernungen leeren ihn mit den übrigen @@ -33,29 +39,33 @@ class FeedController extends Controller public const TEASER_LENGTH = 300; - public function __invoke(Request $request, string $edition, ?string $slug = null): Response + public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {} + + public function portal(Request $request, string $edition): Response { $context = WebPortalContext::fromRequest(); $base = rtrim($request->getSchemeAndHttpHost(), '/'); - $xml = WebPageCache::remember( - WebPageCache::key('feed', [$context->portal->value, $context->language, $slug ?? 'all']), - fn (): string => $this->render($context, $base, $slug), - ); + return $this->respond($context, ['all'], function () use ($context, $base): string { + $releases = $this->releases($context)->get($this->columns()); + $brand = (array) ($context->domainConfig['brand'] ?? []); - return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + return $this->render($context, $base, $releases, [ + 'title' => __('web.rss.portal_title', ['brand' => $this->publisher($base)]), + 'link' => $base.'/'.$context->language, + 'self' => $base.'/'.$context->language.'/feed', + 'description' => (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])), + ]); + }); } - private function render(WebPortalContext $context, string $base, ?string $slug): string + public function category(Request $request, string $edition, string $slug): Response { + $context = WebPortalContext::fromRequest(); + $base = rtrim($request->getSchemeAndHttpHost(), '/'); $language = $context->language; - $publisher = (string) StructuredData::publisher($base)['name']; - $brand = (array) ($context->domainConfig['brand'] ?? []); - $categoryIds = null; - $category = null; - - if ($slug !== null) { + return $this->respond($context, ['category', $slug], function () use ($context, $base, $slug, $language): string { $category = Category::query() ->whereIn('portal', $context->portalValues) ->where('is_active', true) @@ -75,21 +85,125 @@ class FeedController extends Controller ->unique() ->values() ->all(); + + $releases = $this->releases($context)->whereIn('category_id', $categoryIds)->get($this->columns()); + $translation = $category->translations->first(); + $brand = (array) ($context->domainConfig['brand'] ?? []); + + return $this->render($context, $base, $releases, [ + 'title' => __('web.rss.category_title', ['category' => (string) $translation?->name, 'brand' => $this->publisher($base)]), + 'link' => $base.'/'.$language.'/category/'.$slug, + 'self' => $base.'/'.$language.'/category/'.$slug.'/feed', + 'description' => filled($translation?->description) + ? (string) $translation->description + : (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])), + ]); + }); + } + + /** + * Feed eines Newsrooms: dieselbe Sichtbarkeitsregel wie die Newsroom-Seite + * (aktive Firma, nicht entfernt, mindestens eine veröffentlichte Meldung). + * Die Prüfung läuft bewusst vor dem Cache, damit eine Entfernung sofort + * greift; eine in einer Zusammenführung aufgegangene Firma führt per 301 + * zum bleibenden Newsroom (Roadmap 1a.3). + */ + public function newsroom(Request $request, string $edition, string $slug): Response|RedirectResponse + { + $context = WebPortalContext::fromRequest(); + $base = rtrim($request->getSchemeAndHttpHost(), '/'); + $language = $context->language; + + $company = Company::query() + ->withoutGlobalScope(PortalScope::class) + ->whereIn('portal', $context->portalValues) + ->where('slug', $slug) + ->where('is_active', true) + ->whereNull('gone_at') + ->first(['id', 'name', 'slug', 'boilerplate']); + + if (! $company) { + $survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug); + + if ($survivor !== null && $survivor->is_active && $survivor->slug !== $slug) { + return redirect($base.'/'.$language.'/newsroom/'.$survivor->slug.'/feed', 301); + } + + abort(404); } - $releases = PressRelease::query() + return $this->respond($context, ['newsroom', $slug], function () use ($context, $base, $company, $language): string { + $releases = $this->releases($context)->where('company_id', $company->id)->get($this->columns()); + + if ($releases->isEmpty()) { + abort(404); + } + + $boilerplate = trim((string) Str::of(strip_tags((string) $company->boilerplate))->squish()); + + return $this->render($context, $base, $releases, [ + 'title' => __('web.rss.newsroom_title', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]), + 'link' => $base.'/'.$language.'/newsroom/'.$company->slug, + 'self' => $base.'/'.$language.'/newsroom/'.$company->slug.'/feed', + 'description' => $boilerplate !== '' + ? Str::limit($boilerplate, 300) + : __('web.rss.newsroom_description', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]), + ]); + }); + } + + /** + * @param list $keySegments + * @param Closure(): string $build + */ + private function respond(WebPortalContext $context, array $keySegments, Closure $build): Response + { + $xml = WebPageCache::remember( + WebPageCache::key('feed', [$context->portal->value, $context->language, ...$keySegments]), + $build, + ); + + return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + } + + /** + * @return Builder + */ + private function releases(WebPortalContext $context): Builder + { + return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->forPortalValues($context->portalValues) - ->forLanguage($language) + ->forLanguage($context->language) ->visibleInArchive() - ->when($categoryIds !== null, fn (Builder $query) => $query->whereIn('category_id', $categoryIds)) ->with([ 'company:id,name', - 'category.translations' => fn ($query) => $query->where('locale', $language), + 'category.translations' => fn ($query) => $query->where('locale', $context->language), ]) ->orderByDesc('published_at') - ->limit(self::MAX_ITEMS) - ->get(['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']); + ->limit(self::MAX_ITEMS); + } + + /** + * @return list + */ + private function columns(): array + { + return ['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']; + } + + private function publisher(string $base): string + { + return (string) StructuredData::publisher($base)['name']; + } + + /** + * @param Collection $releases + * @param array{title: string, link: string, self: string, description: string} $channel + */ + private function render(WebPortalContext $context, string $base, Collection $releases, array $channel): string + { + $language = $context->language; $items = $releases->map(fn (PressRelease $release): array => [ 'title' => (string) $release->title, @@ -100,22 +214,7 @@ class FeedController extends Controller 'creator' => $release->company?->name, ])->all(); - $translation = $category?->translations->first(); - $categoryName = $translation?->name; - - $channel = [ - 'title' => $categoryName !== null - ? __('web.rss.category_title', ['category' => $categoryName, 'brand' => $publisher]) - : __('web.rss.portal_title', ['brand' => $publisher]), - 'link' => $categoryName !== null - ? $base.'/'.$language.'/category/'.$slug - : $base.'/'.$language, - 'self' => $categoryName !== null - ? $base.'/'.$language.'/category/'.$slug.'/feed' - : $base.'/'.$language.'/feed', - 'description' => filled($translation?->description) - ? (string) $translation->description - : (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $publisher])), + $channel += [ 'language' => $language, 'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(), ]; diff --git a/app/Http/Controllers/Web/LlmsTxtController.php b/app/Http/Controllers/Web/LlmsTxtController.php index abfc632c..5ca093f6 100644 --- a/app/Http/Controllers/Web/LlmsTxtController.php +++ b/app/Http/Controllers/Web/LlmsTxtController.php @@ -106,7 +106,7 @@ class LlmsTxtController extends Controller '- Newsroom eines Unternehmens: '.$base.'/de/newsroom/{slug} — Übersicht unter '.$base.'/de/newsrooms', '- Kategorie: '.$base.'/de/category/{slug}', '- Sitemap: '.$base.'/sitemap.xml — News-Sitemap (Meldungen der letzten 48 Stunden): '.$base.'/sitemap-news.xml', - '- RSS-Feed aller Meldungen: '.$base.'/de/feed — je Kategorie: '.$base.'/de/category/{slug}/feed', + '- RSS-Feeds: alle Meldungen '.$base.'/de/feed — je Kategorie '.$base.'/de/category/{slug}/feed — je Newsroom '.$base.'/de/newsroom/{slug}/feed — Übersicht: '.$base.'/de/rss', '', '## Kategorien', '', diff --git a/app/Http/Controllers/Web/RssPageController.php b/app/Http/Controllers/Web/RssPageController.php new file mode 100644 index 00000000..f7b645c5 --- /dev/null +++ b/app/Http/Controllers/Web/RssPageController.php @@ -0,0 +1,28 @@ + $context, + 'feedCategories' => CategoryNavigation::items(null), + ]); + } +} diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php index 7e50bc9a..69e993a9 100644 --- a/app/Http/Controllers/Web/SitemapController.php +++ b/app/Http/Controllers/Web/SitemapController.php @@ -34,7 +34,7 @@ class SitemapController extends Controller /** @var list */ private const PORTAL_PAGES = [ - 'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api', + 'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api', 'rss', 'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen', 'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden', ]; diff --git a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php index 326133d1..35bb09ce 100644 --- a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php +++ b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php @@ -61,7 +61,7 @@ class RedirectPortalOnlyPagesFromHub // Kategorie und Jahresarchiv gibt es auf beiden Portalen — dort // entscheidet das Standard-Portal. - if (! is_string($slug) || ! in_array($routeName, ['release.detail', 'newsroom', 'newsroom.claim'], true)) { + if (! is_string($slug) || ! in_array($routeName, ['release.detail', 'newsroom', 'newsroom.claim', 'newsroom.feed'], true)) { return StaticPageCanonical::defaultPortalBase(); } diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php index 746cf990..dbffeb41 100644 --- a/app/Services/PressRelease/VisibilityStats.php +++ b/app/Services/PressRelease/VisibilityStats.php @@ -175,7 +175,7 @@ class VisibilityStats /** * RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der - * Feed der Branche und der Portal-Feed. Beide führen die jüngsten + * Feed der Branche, der des Newsrooms und der Portal-Feed. Beide führen die jüngsten * Meldungen — eine Aussage über uns, nicht über Abonnenten. * * @param list $links @@ -190,6 +190,7 @@ class VisibilityStats $edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : SetEdition::DEFAULT_EDITION; $categorySlug = $pressRelease->category?->translations()->where('locale', $edition)->value('slug'); $categoryName = $pressRelease->localizedCategoryName($edition); + $companySlug = $pressRelease->company?->slug; $feeds = []; @@ -203,6 +204,13 @@ class VisibilityStats ]; } + if ($companySlug !== null) { + $feeds[] = [ + 'label' => $link['label'].' · Newsroom', + 'url' => $base.'/'.$edition.'/newsroom/'.$companySlug.'/feed', + ]; + } + $feeds[] = [ 'label' => $link['label'], 'url' => $base.'/'.$edition.'/feed', diff --git a/config/canonical.php b/config/canonical.php index d50ff395..5e4b4c2c 100644 --- a/config/canonical.php +++ b/config/canonical.php @@ -82,7 +82,7 @@ return [ | führende Portal. */ - 'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche'], + 'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche', 'rss'], /* | Portal-INHALTE (Kategorie, Jahresarchiv, Meldung, Newsroom). Die @@ -93,7 +93,7 @@ return [ | sonst `portal_only_host`. */ - 'portal_only_content_routes' => ['kategorie', 'kategorie.archiv', 'kategorie.feed', 'feed', 'release.detail', 'newsroom', 'newsroom.claim'], + 'portal_only_content_routes' => ['kategorie', 'kategorie.archiv', 'kategorie.feed', 'feed', 'release.detail', 'newsroom', 'newsroom.claim', 'newsroom.feed'], 'portal_only_host' => 'presseecho', diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 9a5e67ad..82e9b9db 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -206,7 +206,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. | Nr. | Arbeitspaket | Aufwand | Abhängigkeit | |---|---|---|---| -| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal (Protokoll Teil 11) | S | 2.3 | +| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste (Protokoll Teil 11) | S | 2.3 | | 3.2 | Opt-in E-Mail-Digest je Branche, Double-Opt-in | M | 3.1, Bounce-Suppression live | | 3.3 | Agenturmodell: mehrere Firmen unter einem Konto, aus den Sammeladressen aus 1a.2 abgeleitet | L | 1a.2 | | 3.4 | Gezielte Ansprache kleiner PR-Agenturen und freier Texter | L | 3.3 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 49d04475..2f0d6b06 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -2199,3 +2199,38 @@ ohne Befund. **Deploy:** `view:cache`/`route:cache` neu; kein `migrate`, kein `cache:clear` (neue Cache-Keys). +### Nachtrag 21.09.2026 — Newsroom-Feeds, RSS-Seite, Topleiste + +Auf Wunsch des Betreibers ergänzt: + +- **Feed je Newsroom:** `/{edition}/newsroom/{slug}/feed` + (`FeedController::newsroom`). Gleiche Sichtbarkeitsregel wie die + Newsroom-Seite: aktive Firma, nicht entfernt, mindestens eine + veröffentlichte Meldung — sonst 404. Die Sichtbarkeitsprüfung läuft vor + dem Cache, damit Deaktivierung und Entfernung sofort greifen; eine in + einer Zusammenführung aufgegangene Firma führt per 301 zum Feed des + bleibenden Newsrooms (1a.3). Kanalbeschreibung aus dem Boilerplate. + Newsroom-Seite: Autodiscovery (Newsroom- und Portal-Feed) und sichtbarer + Link „RSS-Feed" in der Kopfzeile neben „Newsroom seit". Hub: 301 auf + das Portal der Firma (`RedirectPortalOnlyPagesFromHub`). +- **RSS-Übersichtsseite** `/{edition}/rss` (`RssPageController`, + `web.rss`, Texte `static.rss.*` DE/EN): Was RSS ist, wie ein Reader + funktioniert (drei Schritte, Beispiele Feedly/Inoreader/NetNewsWire/ + Thunderbird/Outlook), dann Hauptfeed, alle aktiven Top-Level-Branchen + mit Feed-Adresse, Muster des Newsroom-Feeds mit Link auf die + Newsroom-Übersicht, Hinweise (Ausgaben DE/EN, Schwesterportal, Anriss + statt Volltext, keine Weiterverwendung). Portalseite wie Preise: + Portal-Kontext angewendet (eine `Route::view` hätte auf presseecho das + Branding von businessportal24 gezeigt), auf dem Hub 301 auf presseecho + (`portal_only_pages`), in der Portal-Sitemap, in `llms.txt`. +- **Topleiste:** „RSS" neben FAQ · Hilfe · API (Desktop), im Burger-Menü + unter den Service-Links und im Footer. Der Hub-Header hat die drei Links + nicht und bekommt deshalb auch kein RSS. +- Panel „Auslieferung": je Portal jetzt Branchen-, Newsroom- und + Portal-Feed. + +Tests: `FeedTest` (+3: Newsroom-Feed mit Sichtbarkeit, Hub-301 aufs +Firmenportal, Autodiscovery auf der Newsroom-Seite), `RssPageTest` (4), +`StaticPagesTest`-Datensatz, `VisibilityStatsTest`. Pint, Larastan ohne +Befund. Deploy unverändert: `view:cache`/`route:cache`. + diff --git a/lang/de/static.php b/lang/de/static.php index 5cad6dfb..29c22db1 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -444,6 +444,48 @@ return [ 'cta_faq' => 'FAQ durchsuchen', ], + 'rss' => [ + 'meta_title' => 'RSS-Feeds – Pressemitteilungen abonnieren', + 'meta_description' => 'Was RSS ist, wie ein Feed-Reader funktioniert und welche Feeds es hier gibt: alle Meldungen, jede Branche, jeder Newsroom.', + 'eyebrow' => 'RSS', + 'title' => 'RSS-Feeds', + 'lead' => 'Neue Pressemitteilungen von :brand automatisch in Ihrem Feed-Reader – ohne Konto, ohne Newsletter, ohne Nachverfolgung.', + 'breadcrumb' => 'RSS', + + 'what_heading' => 'Was ist RSS?', + 'what_p1' => 'RSS ist ein offenes, seit über zwanzig Jahren etabliertes Format, mit dem Websites ihre neuesten Inhalte als maschinenlesbare Liste bereitstellen. Ein Programm auf Ihrer Seite – der Feed-Reader – ruft diese Liste regelmäßig ab und zeigt Ihnen, was seit dem letzten Besuch neu ist. Sie müssen keine Seite mehr von Hand aufrufen, und niemand muss Ihnen eine E-Mail schicken.', + 'what_p2' => ':brand bietet solche Feeds für alle Meldungen, für jede Branche und für jeden Newsroom an. Jeder Eintrag enthält Titel, Anrisstext, Unternehmen, Branche, Veröffentlichungszeit und den Link zur vollständigen Meldung.', + + 'how_heading' => 'So funktioniert es', + 'how_step1' => 'Feed-Reader wählen', + 'how_step1_text' => 'Zum Beispiel Feedly oder Inoreader im Browser, NetNewsWire auf dem Mac, Thunderbird oder Outlook am Rechner. Viele Redaktionssysteme und Intranets können Feeds ebenfalls einlesen.', + 'how_step2' => 'Feed-Adresse kopieren', + 'how_step2_text' => 'Die Adresse steht unten neben jedem Feed. Auf Branchen- und Newsroom-Seiten finden Sie zusätzlich den Link „RSS-Feed"; die meisten Reader erkennen den Feed auch, wenn Sie nur die Seitenadresse eingeben.', + 'how_step3' => 'Im Reader abonnieren', + 'how_step3_text' => 'Adresse einfügen, fertig. Ab jetzt erscheinen neue Meldungen von selbst; abbestellen geht jederzeit im Reader, ohne dass wir davon erfahren.', + 'how_p1' => 'Jeder Feed führt die jüngsten :count Meldungen, neueste zuerst. Eine Meldung erscheint wenige Minuten nach ihrer Veröffentlichung; wird eine Meldung zurückgezogen, verschwindet sie auch aus dem Feed.', + 'how_p2' => 'Die Feeds sind kostenlos, brauchen kein Konto und setzen keine Cookies. Wer sie abonniert hat, wissen wir nicht – ein Reader ruft die Adresse ab wie ein Browser eine Seite.', + + 'feeds_heading' => 'Feeds auf :brand', + 'main_eyebrow' => 'Hauptfeed', + 'main_title' => 'Alle Pressemitteilungen auf :brand', + 'main_text' => 'Jede neue Meldung des Portals, branchenübergreifend.', + 'categories_heading' => 'Feeds je Branche', + 'categories_text' => 'Nur die Meldungen einer Branche, inklusive ihrer Unterthemen. Der Branchenname führt zur Seite, „RSS-Feed" zur Feed-Adresse.', + 'categories_empty' => 'Derzeit sind keine Branchen aktiv.', + 'newsrooms_heading' => 'Feeds je Newsroom', + 'newsrooms_p1' => 'Jedes Unternehmen mit veröffentlichten Meldungen hat einen Newsroom – und jeder Newsroom einen eigenen Feed. Damit verfolgen Sie gezielt die Meldungen eines Unternehmens, etwa als Redaktion, Analyst oder Wettbewerbsbeobachter.', + 'newsrooms_p2' => 'Den Link „RSS-Feed" finden Sie im Kopf jeder Newsroom-Seite. Die Adresse folgt immer demselben Muster:', + 'newsrooms_slug_placeholder' => 'unternehmen', + 'newsrooms_link' => 'Alle Newsrooms durchsuchen', + + 'notes_heading' => 'Hinweise', + 'note_editions' => 'Die Feeds gibt es je Ausgabe: deutsch unter :de, englisch unter :en. Enthalten sind jeweils die Meldungen in dieser Sprache.', + 'note_other_portal' => 'Das Schwesterportal :portal hat eigene Feeds:', + 'note_content' => 'Die Feeds enthalten den Anrisstext, nicht den vollständigen Meldungstext. Für Volltext, Bilder und Pressekontakte führt der Link auf die Meldung.', + 'note_free' => 'Eine Nutzung der Inhalte über das Lesen hinaus – etwa das Einbinden ganzer Meldungen auf eigenen Seiten – ist nicht Teil des Angebots; Urheber der Meldungen sind die genannten Unternehmen.', + ], + 'publish' => [ 'meta_title' => 'Pressemitteilung veröffentlichen – :brand', 'meta_description' => 'Pressemitteilungen geprüft veröffentlichen und dauerhaft auffindbar im Archiv. Faire Preise, keine Vertragsbindung – Einreichung über den zentralen Publisher-Bereich.', diff --git a/lang/de/web.php b/lang/de/web.php index 5ed7ff9e..134179ce 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -287,6 +287,7 @@ return [ 'empty' => 'Aktuell keine weiteren Meldungen in diesem Newsroom.', 'manage_hint' => 'Sie vertreten :company? Profil, Logo und Pressekontakte lassen sich kostenlos pflegen.', 'manage_link' => 'Diesen Newsroom verwalten', + 'feed_link' => 'RSS-Feed', 'own_title' => 'Eigenen Newsroom starten', 'own_text' => 'Bündeln Sie alle Ihre Pressemitteilungen unter einer eigenen Marken-URL – mit Logo, Branchen-Zuordnung und SEO-Vorteil.', 'own_cta' => 'Newsroom anlegen', @@ -330,6 +331,10 @@ return [ 'category_link_title' => 'RSS-Feed: Pressemitteilungen :category – :brand', 'link' => 'RSS-Feed', 'link_aria' => 'RSS-Feed der Branche :category', + 'newsroom_title' => 'Pressemitteilungen von :company – :brand', + 'newsroom_description' => 'Alle Pressemitteilungen von :company auf :brand.', + 'newsroom_link_title' => 'RSS-Feed: Pressemitteilungen von :company – :brand', + 'newsroom_link_aria' => 'RSS-Feed des Newsrooms :company', ], 'category_archive' => [ diff --git a/lang/en/static.php b/lang/en/static.php index 3c6beb5e..fd9f1ed3 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -442,6 +442,48 @@ return [ 'cta_faq' => 'Browse the FAQ', ], + 'rss' => [ + 'meta_title' => 'RSS feeds – subscribe to press releases', + 'meta_description' => 'What RSS is, how a feed reader works and which feeds exist here: all releases, every industry, every newsroom.', + 'eyebrow' => 'RSS', + 'title' => 'RSS feeds', + 'lead' => 'New press releases from :brand delivered to your feed reader – no account, no newsletter, no tracking.', + 'breadcrumb' => 'RSS', + + 'what_heading' => 'What is RSS?', + 'what_p1' => 'RSS is an open format, established for more than twenty years, that lets websites publish their latest content as a machine-readable list. A program on your side – the feed reader – fetches that list regularly and shows you what is new since your last visit. No more checking pages by hand, and nobody has to send you an email.', + 'what_p2' => ':brand offers such feeds for all releases, for every industry and for every newsroom. Each entry carries the title, a teaser, the company, the industry, the publication time and the link to the full release.', + + 'how_heading' => 'How it works', + 'how_step1' => 'Pick a feed reader', + 'how_step1_text' => 'For example Feedly or Inoreader in the browser, NetNewsWire on the Mac, Thunderbird or Outlook on the desktop. Many editorial systems and intranets can read feeds as well.', + 'how_step2' => 'Copy the feed address', + 'how_step2_text' => 'The address is listed next to every feed below. Industry and newsroom pages also carry an “RSS feed” link, and most readers detect the feed when you simply enter the page address.', + 'how_step3' => 'Subscribe in the reader', + 'how_step3_text' => 'Paste the address – done. New releases now show up by themselves; unsubscribing happens in your reader at any time, without us ever knowing.', + 'how_p1' => 'Every feed carries the latest :count releases, newest first. A release appears within minutes of publication; if a release is withdrawn, it disappears from the feed as well.', + 'how_p2' => 'The feeds are free, need no account and set no cookies. We do not know who subscribes – a reader fetches the address the way a browser fetches a page.', + + 'feeds_heading' => 'Feeds on :brand', + 'main_eyebrow' => 'Main feed', + 'main_title' => 'All press releases on :brand', + 'main_text' => 'Every new release on the portal, across all industries.', + 'categories_heading' => 'Feeds per industry', + 'categories_text' => 'Only the releases of one industry, including its sub-topics. The industry name leads to the page, “RSS feed” to the feed address.', + 'categories_empty' => 'No industries are active at the moment.', + 'newsrooms_heading' => 'Feeds per newsroom', + 'newsrooms_p1' => 'Every company with published releases has a newsroom – and every newsroom has its own feed. Follow the releases of one company specifically, for instance as an editor, analyst or competitor watcher.', + 'newsrooms_p2' => 'The “RSS feed” link sits in the header of every newsroom page. The address always follows the same pattern:', + 'newsrooms_slug_placeholder' => 'company', + 'newsrooms_link' => 'Browse all newsrooms', + + 'notes_heading' => 'Notes', + 'note_editions' => 'Feeds exist per edition: German at :de, English at :en. Each contains the releases published in that language.', + 'note_other_portal' => 'Our sister portal :portal has feeds of its own:', + 'note_content' => 'The feeds contain the teaser, not the full text. For the full release, images and press contacts follow the link.', + 'note_free' => 'Using the content beyond reading – for example embedding whole releases on your own pages – is not part of the offer; the companies named are the authors of their releases.', + ], + 'publish' => [ 'meta_title' => 'Publish a press release – :brand', 'meta_description' => 'Publish quality-checked press releases, permanently discoverable in the archive. Fair prices, no lock-in – submission via the central publisher area.', diff --git a/lang/en/web.php b/lang/en/web.php index 1e01e31a..4b6da9a2 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -282,6 +282,7 @@ return [ 'empty' => 'No further releases in this newsroom at the moment.', 'manage_hint' => 'You represent :company? Profile, logo and press contacts can be maintained free of charge.', 'manage_link' => 'Manage this newsroom', + 'feed_link' => 'RSS feed', 'own_title' => 'Start your own newsroom', 'own_text' => 'Bundle all your press releases under your own branded URL – with logo, industry mapping and SEO benefit.', 'own_cta' => 'Create newsroom', @@ -325,6 +326,10 @@ return [ 'category_link_title' => 'RSS feed: :category press releases – :brand', 'link' => 'RSS feed', 'link_aria' => 'RSS feed for :category', + 'newsroom_title' => 'Press releases from :company – :brand', + 'newsroom_description' => 'All press releases from :company on :brand.', + 'newsroom_link_title' => 'RSS feed: press releases from :company – :brand', + 'newsroom_link_aria' => 'RSS feed of the :company newsroom', ], 'category_archive' => [ diff --git a/resources/views/components/web/site-footer.blade.php b/resources/views/components/web/site-footer.blade.php index ad116ef8..08b3f5e0 100644 --- a/resources/views/components/web/site-footer.blade.php +++ b/resources/views/components/web/site-footer.blade.php @@ -59,6 +59,7 @@
  • {{ __('web.footer.publish') }}
  • {{ __('web.footer.pricing') }}
  • API
  • +
  • RSS
  • diff --git a/resources/views/components/web/site-header.blade.php b/resources/views/components/web/site-header.blade.php index 026cd8cd..d3ee4a80 100644 --- a/resources/views/components/web/site-header.blade.php +++ b/resources/views/components/web/site-header.blade.php @@ -94,6 +94,7 @@ + @@ -441,7 +442,7 @@ {{-- Service-Links: auf Mobile sonst nur im Footer erreichbar (FAQ/Hilfe/API liegen in der Topbar hinter lg:). --}}
    - @foreach ([['label' => __('web.header.pricing'), 'href' => route('preise')], ['label' => 'FAQ', 'href' => route('faq')], ['label' => __('web.header.help'), 'href' => route('hilfe')], ['label' => 'API', 'href' => route('api')]] as $service) + @foreach ([['label' => __('web.header.pricing'), 'href' => route('preise')], ['label' => 'FAQ', 'href' => route('faq')], ['label' => __('web.header.help'), 'href' => route('hilfe')], ['label' => 'API', 'href' => route('api')], ['label' => 'RSS', 'href' => route('rss')]] as $service) {{ $service['label'] }} diff --git a/resources/views/web/newsroom.blade.php b/resources/views/web/newsroom.blade.php index 2069625b..7bdb0122 100644 --- a/resources/views/web/newsroom.blade.php +++ b/resources/views/web/newsroom.blade.php @@ -36,6 +36,9 @@ // Organization-Markup verknüpft PMs (author) mit diesem Profil. // Portal-Basis wie beim Canonical im Layout (unabhängig von forceRootUrl). $newsroomUrl = StructuredData::newsroomUrl(StructuredData::portalBase($domainUrl ?? null), $company->slug); + // RSS-Feed des Newsrooms (Roadmap 3.1) — Adresse aus der Portal-Basis. + $newsroomFeedUrl = StructuredData::portalBase($domainUrl ?? null).'/'.app()->getLocale().'/newsroom/'.$company->slug.'/feed'; + $portalFeedUrl = StructuredData::portalBase($domainUrl ?? null).'/'.app()->getLocale().'/feed'; $jsonLd = [ '@context' => 'https://schema.org', '@type' => 'Organization', @@ -58,6 +61,11 @@ @endsection +@section('feed_links') + + +@endsection + @section('structured_data') @endsection @@ -115,6 +123,11 @@ {{ __('web.newsroom_page.since', ['year' => $newsroomStats['sinceYear']]) }} @endif + + + + {{ __('web.newsroom_page.feed_link') }} +
    diff --git a/resources/views/web/rss.blade.php b/resources/views/web/rss.blade.php new file mode 100644 index 00000000..c916be62 --- /dev/null +++ b/resources/views/web/rss.blade.php @@ -0,0 +1,104 @@ +@extends('web.layouts.web-master') + +@php + // Roadmap 3.1: Übersicht der Feeds dieses Portals. Adressen aus der + // Portal-Basis (wie Canonical und Autodiscovery), nicht aus url()/route(), + // die auf dem Hub den falschen Host trügen. Kontext und Kategorien liefert + // RssPageController. + $edition = app()->getLocale(); + $themeKey = $portalContext->domainKey; + $portalBase = rtrim((string) ($portalContext->domainConfig['url'] ?? config('app.url')), '/'); + $brand = (array) ($portalContext->domainConfig['brand'] ?? []); + $brandLabel = ($brand['name'] ?? $themeKey).($brand['accent'] ?? ''); + + $otherKey = $themeKey === 'presseecho' ? 'businessportal24' : 'presseecho'; + $otherBrand = (array) config('domains.domains.'.$otherKey.'.brand', []); + $otherLabel = ($otherBrand['name'] ?? $otherKey).($otherBrand['accent'] ?? ''); + $otherBase = rtrim((string) config('domains.domains.'.$otherKey.'.url'), '/'); + + $portalFeed = $portalBase.'/'.$edition.'/feed'; + $categories = $feedCategories; + $maxItems = \App\Http\Controllers\Web\FeedController::MAX_ITEMS; +@endphp + +@section('title', __('static.rss.meta_title', ['brand' => $brandLabel])) +@section('meta_description', __('static.rss.meta_description')) + +@section('feed_links') + +@endsection + +@section('content') + +
    +

    {{ __('static.rss.what_heading') }}

    +

    {{ __('static.rss.what_p1') }}

    +

    {{ __('static.rss.what_p2', ['brand' => $brandLabel]) }}

    + +

    {{ __('static.rss.how_heading') }}

    +
      +
    1. {{ __('static.rss.how_step1') }} — {{ __('static.rss.how_step1_text') }}
    2. +
    3. {{ __('static.rss.how_step2') }} — {{ __('static.rss.how_step2_text') }}
    4. +
    5. {{ __('static.rss.how_step3') }} — {{ __('static.rss.how_step3_text') }}
    6. +
    +

    {{ __('static.rss.how_p1', ['count' => $maxItems]) }}

    +

    {{ __('static.rss.how_p2') }}

    +
    + + {{-- Portal-Feed --}} +

    {{ __('static.rss.feeds_heading', ['brand' => $brandLabel]) }}

    +
    +
    {{ __('static.rss.main_eyebrow') }}
    +

    {{ __('static.rss.main_title', ['brand' => $brandLabel]) }}

    +

    {{ __('static.rss.main_text') }}

    + + + {{ $portalFeed }} + +
    + + {{-- Branchen-Feeds --}} +

    {{ __('static.rss.categories_heading') }}

    +

    {{ __('static.rss.categories_text') }}

    + @if ($categories->isNotEmpty()) + + @else +

    {{ __('static.rss.categories_empty') }}

    + @endif + + {{-- Newsroom-Feeds --}} +

    {{ __('static.rss.newsrooms_heading') }}

    +
    +

    {{ __('static.rss.newsrooms_p1') }}

    +

    {{ __('static.rss.newsrooms_p2') }} {{ $portalBase }}/{{ $edition }}/newsroom/{{ __('static.rss.newsrooms_slug_placeholder') }}/feed

    +

    {{ __('static.rss.newsrooms_link') }}

    + +

    {{ __('static.rss.notes_heading') }}

    +
      +
    • {{ __('static.rss.note_editions', ['de' => $portalBase.'/de/feed', 'en' => $portalBase.'/en/feed']) }}
    • +
    • {{ __('static.rss.note_other_portal', ['portal' => $otherLabel]) }} {{ $otherBase }}/{{ $edition }}/rss
    • +
    • {{ __('static.rss.note_content') }}
    • +
    • {{ __('static.rss.note_free') }}
    • +
    +
    +
    +@endsection diff --git a/routes/web.php b/routes/web.php index 3cca28b4..4f16d0eb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -20,6 +20,7 @@ use App\Http\Controllers\Web\PressReleaseReportController; use App\Http\Controllers\Web\ReleaseDetailController; use App\Http\Controllers\Web\ReleaseShareTrackingController; use App\Http\Controllers\Web\ReportFormController; +use App\Http\Controllers\Web\RssPageController; use App\Http\Controllers\Web\SitemapController; use App\Http\Controllers\Web\SucheController; use App\Http\Controllers\Web\TerminationController; @@ -162,7 +163,7 @@ $htmlRedirect = static function (string $edition, string $path) { $staticPages = [ 'preise', 'faq', 'veroeffentlichen', 'suche', 'newsrooms', 'api', - 'hilfe', 'gute-pressemitteilung', 'impressum', 'datenschutz', 'agb', + 'hilfe', 'gute-pressemitteilung', 'rss', 'impressum', 'datenschutz', 'agb', 'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen', ]; @@ -231,8 +232,8 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func // RSS-Feeds (Roadmap 3.1): alle Meldungen der Ausgabe und je Branche // (inkl. Unterkategorien). Der Hub leitet auf das Portal weiter. - Route::get('feed', FeedController::class)->name('feed'); - Route::get('category/{slug}/feed', FeedController::class)->name('kategorie.feed'); + Route::get('feed', [FeedController::class, 'portal'])->name('feed'); + Route::get('category/{slug}/feed', [FeedController::class, 'category'])->name('kategorie.feed'); // Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution) Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail'); @@ -266,6 +267,8 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func Route::get('suche', SucheController::class)->name('suche'); Route::get('newsrooms', NewsroomsController::class)->name('newsrooms'); Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom'); + // RSS-Feed eines Newsrooms (Roadmap 3.1), gleiche Sichtbarkeitsregel wie die Seite. + Route::get('newsroom/{slug}/feed', [FeedController::class, 'newsroom'])->name('newsroom.feed'); // „Diesen Newsroom verwalten" (Roadmap 1b.2): Anmeldelink an die // hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit. Route::get('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'show'])->name('newsroom.claim'); @@ -274,6 +277,8 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func ->name('newsroom.claim.store'); Route::view('api', 'web.api')->name('api'); Route::view('hilfe', 'web.hilfe')->name('hilfe'); + // RSS-Übersicht (Roadmap 3.1): was Feeds sind, wie man sie abonniert, welche es hier gibt. + Route::get('rss', RssPageController::class)->name('rss'); // Roadmap 1d.1: Hilfeseite ohne kommerziellen Anteil — Ziel des // Textbausteins in der Ablehnungsmail (1d.2). Route::view('gute-pressemitteilung', 'web.gute-pressemitteilung')->name('gute-pressemitteilung'); diff --git a/tests/Feature/VisibilityStatsTest.php b/tests/Feature/VisibilityStatsTest.php index a0ec8949..de6704f0 100644 --- a/tests/Feature/VisibilityStatsTest.php +++ b/tests/Feature/VisibilityStatsTest.php @@ -133,6 +133,7 @@ test('the release summary reports sitemap presence only for public releases and // RSS-Feeds (3.1): Branchen-Feed und Portal-Feed des eigenen Portals. ->and(collect($summary['feeds'])->pluck('url')->all())->toBe([ 'https://businessportal24.test/de/category/handel/feed', + 'https://businessportal24.test/de/newsroom/'.$company->slug.'/feed', 'https://businessportal24.test/de/feed', ]); diff --git a/tests/Feature/Web/FeedTest.php b/tests/Feature/Web/FeedTest.php index 024ae027..d6a5ca55 100644 --- a/tests/Feature/Web/FeedTest.php +++ b/tests/Feature/Web/FeedTest.php @@ -138,12 +138,15 @@ test('category and home pages announce the feeds for autodiscovery and link them $this->get('https://presseecho.test/llms.txt')->assertSuccessful() ->assertSee('https://presseecho.test/de/feed', false) - ->assertSee('https://presseecho.test/de/category/{slug}/feed', false); + ->assertSee('https://presseecho.test/de/category/{slug}/feed', false) + ->assertSee('https://presseecho.test/de/newsroom/{slug}/feed', false) + ->assertSee('https://presseecho.test/de/rss', false); }); test('the hub redirects feed addresses to the portal', function () { /** @var TestCase $this */ feedCategory(Portal::Both); + $company = Company::factory()->businessportal24()->create(['slug' => 'nordwind', 'is_active' => true]); $this->get('https://pressekonto.test/de/feed') ->assertRedirect('https://presseecho.test/de/feed') @@ -151,6 +154,62 @@ test('the hub redirects feed addresses to the portal', function () { $this->get('https://pressekonto.test/de/category/handel/feed') ->assertRedirect('https://presseecho.test/de/category/handel/feed') ->assertStatus(301); + // Newsroom-Feed folgt dem Portal der Firma, nicht dem Standard-Portal. + $this->get('https://pressekonto.test/de/newsroom/nordwind/feed') + ->assertRedirect('https://businessportal24.test/de/newsroom/nordwind/feed') + ->assertStatus(301); + $this->get('https://pressekonto.test/de/newsroom/gibt-es-nicht/feed')->assertNotFound(); +}); + +test('the newsroom feed lists the releases of one company on the own portal and follows the newsroom visibility', function () { + /** @var TestCase $this */ + $category = feedCategory(); + $company = Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true, 'boilerplate' => '

    Nordwind baut Windparks.

    ']); + $other = Company::factory()->presseecho()->create(['slug' => 'andere', 'is_active' => true]); + + $mine = fn (string $slug, array $attributes = []) => PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company) + ->create(['slug' => $slug, 'language' => 'de', 'published_at' => now()->subHour(), ...$attributes]); + + $mine('nordwind-neu', ['title' => 'Nordwind eröffnet Windpark']); + $mine('nordwind-alt', ['published_at' => now()->subDays(2)]); + $mine('nordwind-en', ['language' => 'en']); + PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($other)->create(['slug' => 'andere-firma', 'language' => 'de', 'published_at' => now()->subHour()]); + + $response = $this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertSuccessful(); + $xml = (string) $response->getContent(); + + expect($xml) + ->toContain('Pressemitteilungen von Nordwind Energie AG – presseecho') + ->toContain('https://presseecho.test/de/newsroom/nordwind') + ->toContain('') + ->toContain('Nordwind baut Windparks.') + ->toContain('press-release/nordwind-neu') + ->toContain('press-release/nordwind-alt'); + $this->assertStringNotContainsString('andere-firma', $xml); + $this->assertStringNotContainsString('nordwind-en', $xml); + + // Ohne veröffentlichte Meldung keine öffentliche Seite — und kein Feed. + $this->get('https://presseecho.test/de/newsroom/andere/feed')->assertSuccessful(); + $empty = Company::factory()->presseecho()->create(['slug' => 'leer', 'is_active' => true]); + $this->get('https://presseecho.test/de/newsroom/leer/feed')->assertNotFound(); + + // Deaktiviert oder entfernt: sofort weg, auch mit warmem Cache. + $company->update(['is_active' => false]); + $this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertNotFound(); + $company->update(['is_active' => true, 'gone_at' => now()]); + $this->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertNotFound(); +}); + +test('the newsroom page announces its feed for autodiscovery and links it', function () { + /** @var TestCase $this */ + $category = feedCategory(); + $company = Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true]); + PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create(['slug' => 'nordwind-neu', 'language' => 'de', 'published_at' => now()->subHour()]); + + $this->get('https://presseecho.test/de/newsroom/nordwind')->assertSuccessful() + ->assertSee('', false) + ->assertSee('href="https://presseecho.test/de/newsroom/nordwind/feed" type="application/rss+xml"', false) + ->assertSeeText('RSS-Feed'); }); test('the feed is cached with the page data and cleared with the public pages', function () { @@ -159,10 +218,10 @@ test('the feed is cached with the page data and cleared with the public pages', feedRelease($category, 'erste'); $this->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful(); - $key = WebPageCache::key('feed', ['presseecho', 'de', 'handel']); + $key = WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel']); expect(Cache::has($key))->toBeTrue(); WebCaches::forgetPublicPages(); - expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'handel'])))->toBeFalse(); + expect(Cache::has(WebPageCache::key('feed', ['presseecho', 'de', 'category', 'handel'])))->toBeFalse(); }); diff --git a/tests/Feature/Web/RssPageTest.php b/tests/Feature/Web/RssPageTest.php new file mode 100644 index 00000000..41d2b8df --- /dev/null +++ b/tests/Feature/Web/RssPageTest.php @@ -0,0 +1,73 @@ +create(['portal' => $portal, 'is_active' => true]); + $category->translations()->create(['locale' => 'de', 'name' => $name, 'slug' => $slug]); + $category->translations()->create(['locale' => 'en', 'name' => $name.' EN', 'slug' => $slug.'-en']); + + return $category; +} + +test('the rss page explains feeds and lists the main feed, every industry feed and the newsroom pattern', function () { + /** @var TestCase $this */ + rssCategory('handel', 'Handel'); + rssCategory('energie', 'Energie'); + $inactive = rssCategory('veraltet', 'Veraltet'); + $inactive->update(['is_active' => false]); + rssCategory('fremd', 'Fremdportal', Portal::Businessportal24); + + $this->get('https://presseecho.test/de/rss') + ->assertSuccessful() + ->assertSeeText('RSS-Feeds') + ->assertSeeText('Was ist RSS?') + ->assertSeeText('So funktioniert es') + ->assertSeeText('Feeds auf presseecho') + ->assertSee('href="https://presseecho.test/de/feed"', false) + ->assertSeeText('https://presseecho.test/de/category/handel/feed') + ->assertSeeText('https://presseecho.test/de/category/energie/feed') + ->assertSeeText('/newsroom/unternehmen/feed') + ->assertSee('href="https://businessportal24.test/de/rss"', false) + ->assertSee('', false) + ->assertDontSeeText('veraltet/feed') + ->assertDontSeeText('fremd/feed'); +}); + +test('the rss page renders in english on the en edition', function () { + /** @var TestCase $this */ + rssCategory('handel', 'Handel', Portal::Both); + + $this->get('https://businessportal24.test/en/rss') + ->assertSuccessful() + ->assertSeeText('What is RSS?') + ->assertSeeText('https://businessportal24.test/en/category/handel-en/feed') + ->assertSee('href="https://businessportal24.test/en/feed"', false); +}); + +test('the top bar, the mobile menu and the footer link to the rss page', function () { + /** @var TestCase $this */ + $html = (string) $this->get('https://presseecho.test/de')->assertSuccessful()->getContent(); + + // Topleiste (Desktop), Burger-Menü und Footer — route() trägt in der Suite den konfigurierten Root-Host, deshalb nur der Pfad. + expect(substr_count($html, '/de/rss"'))->toBeGreaterThanOrEqual(3); +}); + +test('the hub sends the rss page to the leading portal and the sitemap lists it on the portals only', function () { + /** @var TestCase $this */ + $this->get('https://pressekonto.test/de/rss') + ->assertRedirect('https://presseecho.test/de/rss') + ->assertStatus(301); + + $this->get('https://presseecho.test/sitemap.xml') + ->assertSee('https://presseecho.test/de/rss', false); + $this->get('https://pressekonto.test/sitemap.xml') + ->assertDontSee('/de/rss', false); +}); diff --git a/tests/Feature/Web/StaticPagesTest.php b/tests/Feature/Web/StaticPagesTest.php index bf70b67e..40e3b34a 100644 --- a/tests/Feature/Web/StaticPagesTest.php +++ b/tests/Feature/Web/StaticPagesTest.php @@ -13,6 +13,7 @@ dataset('static pages', [ 'hilfe' => ['de/hilfe', 'Hilfe-Center'], 'gute-pressemitteilung' => ['de/gute-pressemitteilung', 'Was eine gute Pressemitteilung ausmacht'], 'api' => ['de/api', 'API-Dokumentation'], + 'rss' => ['de/rss', 'RSS-Feeds'], 'richtlinien' => ['de/richtlinien', 'Veröffentlichungsrichtlinien'], 'melden' => ['de/melden', 'Inhalte melden oder Änderungen beantragen'], ]);