web: RSS-Feed je Newsroom, RSS-Übersichtsseite, „RSS" in der Topleiste (Roadmap 3.1, Nachtrag)
- /{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 <noreply@anthropic.com>
This commit is contained in:
parent
9d5e5a0b54
commit
ff75159b8f
22 changed files with 577 additions and 55 deletions
|
|
@ -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<string> $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<PressRelease>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<int, PressRelease> $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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'',
|
||||
|
|
|
|||
28
app/Http/Controllers/Web/RssPageController.php
Normal file
28
app/Http/Controllers/Web/RssPageController.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\CategoryNavigation;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
/**
|
||||
* RSS-Übersicht (Roadmap 3.1): erklärt Feeds und listet Hauptfeed,
|
||||
* Branchen-Feeds und das Newsroom-Muster des Portals. Portalseite wie Preise
|
||||
* oder Newsrooms — der Portal-Kontext wird angewendet, damit Branding und
|
||||
* Adressen zum aufgerufenen Portal gehören; auf dem Hub leitet die Route auf
|
||||
* das führende Portal (config/canonical.php, portal_only_pages).
|
||||
*/
|
||||
class RssPageController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$context = WebPortalContext::fromRequest();
|
||||
|
||||
return view('web.rss', [
|
||||
'portalContext' => $context,
|
||||
'feedCategories' => CategoryNavigation::items(null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ class SitemapController extends Controller
|
|||
|
||||
/** @var list<string> */
|
||||
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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<array{portal: Portal, label: string, url: string}> $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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
<li><a href="{{ route('veroeffentlichen') }}" class="cursor-pointer hover:text-white transition-colors">{{ __('web.footer.publish') }}</a></li>
|
||||
<li><a href="{{ route('preise') }}" class="cursor-pointer hover:text-white transition-colors">{{ __('web.footer.pricing') }}</a></li>
|
||||
<li><a href="{{ route('api') }}" class="cursor-pointer hover:text-white transition-colors">API</a></li>
|
||||
<li><a href="{{ route('rss') }}" class="cursor-pointer hover:text-white transition-colors">RSS</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@
|
|||
<a href="{{ route('faq') }}" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">FAQ</a>
|
||||
<a href="{{ route('hilfe') }}" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">{{ __('web.header.help') }}</a>
|
||||
<a href="{{ route('api') }}" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">API</a>
|
||||
<a href="{{ route('rss') }}" class="hidden lg:inline cursor-pointer hover:text-ink-on-dark transition-colors">RSS</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -441,7 +442,7 @@
|
|||
|
||||
{{-- Service-Links: auf Mobile sonst nur im Footer erreichbar (FAQ/Hilfe/API liegen in der Topbar hinter lg:). --}}
|
||||
<div class="mt-1 pt-1">
|
||||
@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)
|
||||
<a href="{{ $service['href'] }}"
|
||||
class="flex items-center justify-between px-3 py-3 text-[13.5px] border-b border-bg-rule last:border-b-0 font-medium text-ink-3 hover:text-ink hover:bg-bg cursor-pointer transition-colors">
|
||||
<span>{{ $service['label'] }}</span>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<link rel="alternate" hreflang="x-default" href="{{ url('/de/newsroom/'.$company->slug) }}">
|
||||
@endsection
|
||||
|
||||
@section('feed_links')
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.newsroom_link_title', ['company' => $company->name, 'brand' => $brandName.$brandAccent]) }}" href="{{ $newsroomFeedUrl }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.portal_link_title', ['brand' => $brandName.$brandAccent]) }}" href="{{ $portalFeedUrl }}">
|
||||
@endsection
|
||||
|
||||
@section('structured_data')
|
||||
<x-web.structured-data :data="$jsonLd" />
|
||||
@endsection
|
||||
|
|
@ -115,6 +123,11 @@
|
|||
<span class="text-ink-4" aria-hidden="true">·</span>
|
||||
<span>{{ __('web.newsroom_page.since', ['year' => $newsroomStats['sinceYear']]) }}</span>
|
||||
@endif
|
||||
<span class="text-ink-4" aria-hidden="true">·</span>
|
||||
<a href="{{ $newsroomFeedUrl }}" type="application/rss+xml" class="inline-flex items-center gap-1.5 font-medium hover:text-ink transition-colors" aria-label="{{ __('web.rss.newsroom_link_aria', ['company' => $company->name]) }}">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 12.5A1.5 1.5 0 1 1 3.5 14 1.5 1.5 0 0 1 2 12.5ZM2 8a6 6 0 0 1 6 6h2a8 8 0 0 0-8-8Zm0-4a10 10 0 0 1 10 10h2A12 12 0 0 0 2 2Z"/></svg>
|
||||
{{ __('web.newsroom_page.feed_link') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
104
resources/views/web/rss.blade.php
Normal file
104
resources/views/web/rss.blade.php
Normal file
|
|
@ -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')
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ __('web.rss.portal_link_title', ['brand' => $brandLabel]) }}" href="{{ $portalFeed }}">
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<x-web.static-page
|
||||
:eyebrow="__('static.rss.eyebrow')"
|
||||
:title="__('static.rss.title')"
|
||||
:lead="__('static.rss.lead', ['brand' => $brandLabel])"
|
||||
:breadcrumb="__('static.rss.breadcrumb')"
|
||||
>
|
||||
<div class="pm-body max-w-[760px]">
|
||||
<h2 id="was-ist-rss">{{ __('static.rss.what_heading') }}</h2>
|
||||
<p>{{ __('static.rss.what_p1') }}</p>
|
||||
<p>{{ __('static.rss.what_p2', ['brand' => $brandLabel]) }}</p>
|
||||
|
||||
<h2 id="so-funktioniert-es">{{ __('static.rss.how_heading') }}</h2>
|
||||
<ol>
|
||||
<li><strong>{{ __('static.rss.how_step1') }}</strong> — {{ __('static.rss.how_step1_text') }}</li>
|
||||
<li><strong>{{ __('static.rss.how_step2') }}</strong> — {{ __('static.rss.how_step2_text') }}</li>
|
||||
<li><strong>{{ __('static.rss.how_step3') }}</strong> — {{ __('static.rss.how_step3_text') }}</li>
|
||||
</ol>
|
||||
<p>{{ __('static.rss.how_p1', ['count' => $maxItems]) }}</p>
|
||||
<p>{{ __('static.rss.how_p2') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Portal-Feed --}}
|
||||
<h2 id="feeds" class="font-serif text-[22px] font-semibold tracking-[-0.3px] text-ink m-0 mt-12 mb-4">{{ __('static.rss.feeds_heading', ['brand' => $brandLabel]) }}</h2>
|
||||
<div class="border border-bg-rule bg-bg-elev p-6 sm:p-8">
|
||||
<div class="eyebrow mb-2">{{ __('static.rss.main_eyebrow') }}</div>
|
||||
<h3 class="font-serif text-[20px] font-semibold tracking-[-0.3px] text-ink m-0">{{ __('static.rss.main_title', ['brand' => $brandLabel]) }}</h3>
|
||||
<p class="text-[13px] text-ink-2 mt-1.5 mb-4">{{ __('static.rss.main_text') }}</p>
|
||||
<a href="{{ $portalFeed }}" type="application/rss+xml" class="inline-flex items-center gap-2 font-mono text-[13px] text-ink underline underline-offset-2 hover:text-brand transition-colors break-all">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 12.5A1.5 1.5 0 1 1 3.5 14 1.5 1.5 0 0 1 2 12.5ZM2 8a6 6 0 0 1 6 6h2a8 8 0 0 0-8-8Zm0-4a10 10 0 0 1 10 10h2A12 12 0 0 0 2 2Z"/></svg>
|
||||
{{ $portalFeed }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Branchen-Feeds --}}
|
||||
<h2 id="branchen" class="font-serif text-[22px] font-semibold tracking-[-0.3px] text-ink m-0 mt-12 mb-1.5">{{ __('static.rss.categories_heading') }}</h2>
|
||||
<p class="text-[13px] text-ink-2 m-0 mb-4">{{ __('static.rss.categories_text') }}</p>
|
||||
@if ($categories->isNotEmpty())
|
||||
<ul class="list-none p-0 m-0 grid gap-px bg-bg-rule border border-bg-rule grid-cols-1 md:grid-cols-2">
|
||||
@foreach ($categories as $category)
|
||||
@php $categoryFeed = $portalBase.'/'.$edition.'/category/'.$category['slug'].'/feed'; @endphp
|
||||
<li class="bg-bg p-4 sm:p-5">
|
||||
<div class="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<a href="{{ $category['href'] }}" class="text-[14px] font-semibold text-ink hover:text-brand transition-colors">{{ $category['label'] }}</a>
|
||||
<a href="{{ $categoryFeed }}" type="application/rss+xml" class="inline-flex items-center gap-1.5 text-[12px] font-semibold text-ink-3 hover:text-brand transition-colors" aria-label="{{ __('web.rss.link_aria', ['category' => $category['label']]) }}">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 12.5A1.5 1.5 0 1 1 3.5 14 1.5 1.5 0 0 1 2 12.5ZM2 8a6 6 0 0 1 6 6h2a8 8 0 0 0-8-8Zm0-4a10 10 0 0 1 10 10h2A12 12 0 0 0 2 2Z"/></svg>
|
||||
{{ __('web.rss.link') }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="font-mono text-[11.5px] text-ink-3 mt-1 break-all">{{ $categoryFeed }}</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<p class="text-[13px] text-ink-3 m-0">{{ __('static.rss.categories_empty') }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Newsroom-Feeds --}}
|
||||
<h2 id="newsrooms" class="font-serif text-[22px] font-semibold tracking-[-0.3px] text-ink m-0 mt-12 mb-1.5">{{ __('static.rss.newsrooms_heading') }}</h2>
|
||||
<div class="pm-body max-w-[760px]">
|
||||
<p>{{ __('static.rss.newsrooms_p1') }}</p>
|
||||
<p>{{ __('static.rss.newsrooms_p2') }} <code>{{ $portalBase }}/{{ $edition }}/newsroom/{{ __('static.rss.newsrooms_slug_placeholder') }}/feed</code></p>
|
||||
<p><a href="{{ route('newsrooms') }}">{{ __('static.rss.newsrooms_link') }}</a></p>
|
||||
|
||||
<h2 id="hinweise">{{ __('static.rss.notes_heading') }}</h2>
|
||||
<ul>
|
||||
<li>{{ __('static.rss.note_editions', ['de' => $portalBase.'/de/feed', 'en' => $portalBase.'/en/feed']) }}</li>
|
||||
<li>{{ __('static.rss.note_other_portal', ['portal' => $otherLabel]) }} <a href="{{ $otherBase }}/{{ $edition }}/rss">{{ $otherBase }}/{{ $edition }}/rss</a></li>
|
||||
<li>{{ __('static.rss.note_content') }}</li>
|
||||
<li>{{ __('static.rss.note_free') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</x-web.static-page>
|
||||
@endsection
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => '<p>Nordwind baut Windparks.</p>']);
|
||||
$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('<title>Pressemitteilungen von Nordwind Energie AG – presseecho</title>')
|
||||
->toContain('<link>https://presseecho.test/de/newsroom/nordwind</link>')
|
||||
->toContain('<atom:link href="https://presseecho.test/de/newsroom/nordwind/feed" rel="self" type="application/rss+xml" />')
|
||||
->toContain('<description>Nordwind baut Windparks.</description>')
|
||||
->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('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: Pressemitteilungen von Nordwind Energie AG – presseecho" href="https://presseecho.test/de/newsroom/nordwind/feed">', 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();
|
||||
});
|
||||
|
|
|
|||
73
tests/Feature/Web/RssPageTest.php
Normal file
73
tests/Feature/Web/RssPageTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* RSS-Übersichtsseite (Roadmap 3.1): erklärt Feeds und listet Hauptfeed,
|
||||
* Branchen-Feeds und das Newsroom-Muster. Verlinkt aus der Topleiste.
|
||||
*/
|
||||
function rssCategory(string $slug, string $name, Portal $portal = Portal::Presseecho): Category
|
||||
{
|
||||
$category = Category::factory()->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('<link rel="alternate" type="application/rss+xml" title="RSS-Feed: alle Pressemitteilungen – presseecho" href="https://presseecho.test/de/feed">', 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('<loc>https://presseecho.test/de/rss</loc>', false);
|
||||
$this->get('https://pressekonto.test/sitemap.xml')
|
||||
->assertDontSee('/de/rss</loc>', false);
|
||||
});
|
||||
|
|
@ -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'],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue