- /{edition}/feed und /{edition}/category/{slug}/feed (FeedController,
RSS 2.0 mit atom:link self, dc:creator, category; jüngste 50 der Ausgabe,
Branche inkl. aktiver Unterkategorien, nur visibleInArchive).
- Cache über WebPageCache (Key-Gruppe feed), Hub leitet per 301 aufs Portal
(canonical.portal_only_content_routes).
- Autodiscovery über @yield('feed_links') auf Start-, Branchen- und
Archivseiten, sichtbarer Link in der Archiv-Leiste, llms.txt nennt die
Feed-Muster, Block „RSS-Feeds" im Panel „Auslieferung"
(VisibilityStats::feedLinks).
- Übersetzungen unter web.rss (web.feed ist die Startseiten-Liste).
- Tests: FeedTest (7), VisibilityStatsTest erweitert.
- dev/2026-09-01: Sichtung 18.09. (Teil 11), zweite Teil-Serie des
Protokolls jetzt 7–10 mit nachgezogenen Verweisen, Anhang 1 abgeglichen,
E6 abgehakt, 2.6 extern, 1c.5/E8 wartet auf Daten, 3.1 abgehakt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
125 lines
5.1 KiB
PHP
125 lines
5.1 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Web;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Category;
|
||
use App\Models\PressRelease;
|
||
use App\Scopes\PortalScope;
|
||
use App\Support\StructuredData;
|
||
use App\Support\WebPageCache;
|
||
use App\Support\WebPortalContext;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\Response;
|
||
use Illuminate\Support\Carbon;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Gecacht wie die Seitendaten (WebPageCache: 5 min frisch, danach stale und
|
||
* Neuaufbau nach der Antwort); Entfernungen leeren ihn mit den übrigen
|
||
* Seiten-Caches. Der Hub hat keine Meldungen — die Routen leiten dort auf
|
||
* das Portal (config/canonical.php, portal_only_content_routes).
|
||
*/
|
||
class FeedController extends Controller
|
||
{
|
||
public const MAX_ITEMS = 50;
|
||
|
||
public const TEASER_LENGTH = 300;
|
||
|
||
public function __invoke(Request $request, string $edition, ?string $slug = null): 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 response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
|
||
}
|
||
|
||
private function render(WebPortalContext $context, string $base, ?string $slug): string
|
||
{
|
||
$language = $context->language;
|
||
$publisher = (string) StructuredData::publisher($base)['name'];
|
||
$brand = (array) ($context->domainConfig['brand'] ?? []);
|
||
|
||
$categoryIds = null;
|
||
$category = null;
|
||
|
||
if ($slug !== null) {
|
||
$category = Category::query()
|
||
->whereIn('portal', $context->portalValues)
|
||
->where('is_active', true)
|
||
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
|
||
->with(['translations' => fn ($query) => $query->where('locale', $language)])
|
||
->first();
|
||
|
||
if (! $category) {
|
||
abort(404);
|
||
}
|
||
|
||
$categoryIds = Category::query()
|
||
->where('parent_id', $category->id)
|
||
->where('is_active', true)
|
||
->pluck('id')
|
||
->prepend($category->id)
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
$releases = PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->forPortalValues($context->portalValues)
|
||
->forLanguage($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),
|
||
])
|
||
->orderByDesc('published_at')
|
||
->limit(self::MAX_ITEMS)
|
||
->get(['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']);
|
||
|
||
$items = $releases->map(fn (PressRelease $release): array => [
|
||
'title' => (string) $release->title,
|
||
'link' => $base.'/'.$language.'/press-release/'.$release->slug,
|
||
'published_at' => $release->published_at?->toRfc2822String() ?? '',
|
||
'description' => $release->teaserText(self::TEASER_LENGTH),
|
||
'category' => $release->localizedCategoryName($language),
|
||
'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])),
|
||
'language' => $language,
|
||
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
|
||
];
|
||
|
||
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
|
||
}
|
||
}
|