From 7df08b9017c3dbbd86c8a1a08fdc9286ab3a6ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Wed, 4 Nov 2026 17:19:46 +0100 Subject: [PATCH] Sitemap lastmod and handling of past events - SitemapListener: for event detail pages (event tstamp) and regular pages; events that ended more than 12 months ago are removed from the sitemap - PastEventPolicy: events stay indexable for 12 months after they end (recurring events are searched before the next date), afterwards the detail page gets noindex,follow but stays reachable as archive - detail template data: is_past and next_occurrence (next published event with the same title) for a notice with a link to the next date Co-Authored-By: Claude Opus 5.5 --- .../EventFullTemplateDataListener.php | 55 +++++++++ src/EventListener/SitemapListener.php | 104 ++++++++++++++++++ src/Seo/PastEventPolicy.php | 48 ++++++++ 3 files changed, 207 insertions(+) create mode 100644 src/EventListener/SitemapListener.php create mode 100644 src/Seo/PastEventPolicy.php diff --git a/src/EventListener/EventFullTemplateDataListener.php b/src/EventListener/EventFullTemplateDataListener.php index 2184bd0..a128820 100644 --- a/src/EventListener/EventFullTemplateDataListener.php +++ b/src/EventListener/EventFullTemplateDataListener.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Mummert\EventManagerBundle\EventListener; +use Contao\CalendarEventsModel; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; +use Contao\CoreBundle\Routing\ContentUrlGenerator; use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag; use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor; use Contao\StringUtil; @@ -12,6 +14,7 @@ use Contao\Template; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Mummert\EventManagerBundle\Seo\EventMetaTitle; +use Mummert\EventManagerBundle\Seo\PastEventPolicy; #[AsHook('parseTemplate', method: 'onParseTemplate')] class EventFullTemplateDataListener @@ -21,6 +24,7 @@ class EventFullTemplateDataListener public function __construct( private readonly Connection $connection, private readonly ResponseContextAccessor $responseContextAccessor, + private readonly ContentUrlGenerator $contentUrlGenerator, ) { } @@ -47,6 +51,7 @@ class EventFullTemplateDataListener $this->setMetaTitle($data); $this->setMetaDescriptionFallback($data, $locationRow); + $this->applyPastEventPolicy($template, $data, $eventId); $template->location_row = $locationRow; $template->organizations = $organizations; @@ -138,6 +143,56 @@ class EventFullTemplateDataListener $headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.'); } + /** + * Vergangene Events: Hinweis + Link auf den nächsten Termin gleichen Namens; + * länger als PastEventPolicy::INDEX_MONTHS vorbei: noindex,follow. + * + * @param array $data + */ + private function applyPastEventPolicy(Template $template, array $data, int $eventId): void + { + $template->is_past = PastEventPolicy::isPast($data); + $template->next_occurrence = null; + + if (!$template->is_past) { + return; + } + + if (PastEventPolicy::isOutsideIndexWindow($data)) { + $headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class); + + if ($headBag instanceof HtmlHeadBag) { + $headBag->setMetaRobots('noindex,follow'); + } + } + + // nächster veröffentlichter Termin mit gleichem (gespeicherten) Titel + $nextId = $this->connection->fetchOne( + "SELECT e.id FROM tl_calendar_events e + WHERE e.published = 1 AND e.id != ? + AND LOWER(TRIM(e.title)) = (SELECT LOWER(TRIM(o.title)) FROM tl_calendar_events o WHERE o.id = ?) + AND COALESCE(NULLIF(e.endDate, 0), e.startDate) >= ? + AND (e.start = '' OR e.start <= ?) AND (e.stop = '' OR e.stop > ?) + ORDER BY e.startDate ASC LIMIT 1", + [$eventId, $eventId, strtotime('today'), time(), time()], + ); + + $next = $nextId ? CalendarEventsModel::findById((int) $nextId) : null; + + if (null === $next) { + return; + } + + try { + $template->next_occurrence = [ + 'url' => $this->contentUrlGenerator->generate($next), + 'date' => EventMetaTitle::formatDate((int) $next->startDate, (int) $next->endDate), + ]; + } catch (\Throwable) { + // Kein Link möglich (z. B. Archiv ohne Zielseite): nur der Hinweis + } + } + /** @return array */ private function fetchLocationRow(int $locationId): array { diff --git a/src/EventListener/SitemapListener.php b/src/EventListener/SitemapListener.php new file mode 100644 index 0000000..89fbc9a --- /dev/null +++ b/src/EventListener/SitemapListener.php @@ -0,0 +1,104 @@ + für Event-Detailseiten (tstamp des Events) und reguläre Seiten + * (tstamp der Seite), damit Suchmaschinen geänderte Seiten gezielt neu abrufen + * - entfernt Events, deren Ende länger als PastEventPolicy::INDEX_MONTHS zurückliegt; + * diese Seiten stehen auf noindex,follow (EventFullTemplateDataListener) + * + * Läuft nach dem SitemapListener des Calendar-Bundles (der die Events einfügt). + */ +#[AsEventListener(priority: -100)] +class SitemapListener +{ + private const NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'; + + public function __construct( + private readonly Connection $connection, + ) { + } + + public function __invoke(SitemapEvent $event): void + { + $document = $event->getDocument(); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('s', self::NS); + + $events = $this->loadEvents(); + $pages = $this->loadPages(); + + /** @var \DOMElement $url */ + foreach (iterator_to_array($xpath->query('//s:url')) as $url) { + $loc = $xpath->query('s:loc', $url)->item(0); + + if (!$loc instanceof \DOMElement || $xpath->query('s:lastmod', $url)->length > 0) { + continue; + } + + $path = rawurldecode(trim((string) parse_url(trim($loc->textContent), PHP_URL_PATH), '/')); + $segments = '' === $path ? [] : explode('/', $path); + $lastmod = null; + + if (\count($segments) >= 2 && isset($events[end($segments)])) { + $row = $events[end($segments)]; + + if (PastEventPolicy::isOutsideIndexWindow($row)) { + $url->parentNode?->removeChild($url); + + continue; + } + + $lastmod = (int) $row['tstamp']; + } elseif (isset($pages[$path])) { + $lastmod = $pages[$path]; + } + + if ($lastmod > 0) { + $element = $document->createElementNS(self::NS, 'lastmod', date('Y-m-d\TH:i:sP', $lastmod)); + $loc->parentNode?->insertBefore($element, $loc->nextSibling); + } + } + } + + /** + * @return array> alias => Event (bei doppelten Aliassen der neueste Stand) + */ + private function loadEvents(): array + { + $events = []; + + foreach ($this->connection->fetchAllAssociative('SELECT alias, tstamp, startDate, endDate FROM tl_calendar_events WHERE published = 1 ORDER BY tstamp') as $row) { + if ('' !== (string) $row['alias']) { + $events[(string) $row['alias']] = $row; + } + } + + return $events; + } + + /** + * @return array URL-Pfad => tstamp ("" = Startseite) + */ + private function loadPages(): array + { + $pages = []; + + foreach ($this->connection->fetchAllAssociative("SELECT alias, tstamp FROM tl_page WHERE type = 'regular' AND published = 1") as $row) { + $alias = (string) $row['alias']; + $pages['index' === $alias ? '' : $alias] = (int) $row['tstamp']; + } + + return $pages; + } +} diff --git a/src/Seo/PastEventPolicy.php b/src/Seo/PastEventPolicy.php new file mode 100644 index 0000000..b24a920 --- /dev/null +++ b/src/Seo/PastEventPolicy.php @@ -0,0 +1,48 @@ + $event (startDate, endDate) + */ + public static function endTimestamp(array $event): int + { + $end = (int) ($event['endDate'] ?? 0) ?: (int) ($event['startDate'] ?? 0); + + // Ende des letzten Veranstaltungstags + return $end > 0 ? (int) strtotime('tomorrow', $end) - 1 : 0; + } + + /** + * @param array $event + */ + public static function isPast(array $event, ?int $now = null): bool + { + $end = self::endTimestamp($event); + + return $end > 0 && $end < ($now ?? time()); + } + + /** + * @param array $event + */ + public static function isOutsideIndexWindow(array $event, ?int $now = null): bool + { + $end = self::endTimestamp($event); + + return $end > 0 && $end < (int) strtotime('-'.self::INDEX_MONTHS.' months', $now ?? time()); + } +}