Sitemap lastmod and handling of past events

- SitemapListener: <lastmod> 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 <noreply@anthropic.com>
This commit is contained in:
admin.juergenandClaude Opus 5.5 committed 2026-11-04 17:19:46 +01:00
1 parent 5bf31e5043
commit 7df08b9017
3 files changed
+207

No files matched your search

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Mummert\EventManagerBundle\EventListener; namespace Mummert\EventManagerBundle\EventListener;
use Contao\CalendarEventsModel;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\CoreBundle\Routing\ContentUrlGenerator;
use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag; use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag;
use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor; use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor;
use Contao\StringUtil; use Contao\StringUtil;
@@ -12,6 +14,7 @@ use Contao\Template;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Mummert\EventManagerBundle\Seo\EventMetaTitle; use Mummert\EventManagerBundle\Seo\EventMetaTitle;
use Mummert\EventManagerBundle\Seo\PastEventPolicy;
#[AsHook('parseTemplate', method: 'onParseTemplate')] #[AsHook('parseTemplate', method: 'onParseTemplate')]
class EventFullTemplateDataListener class EventFullTemplateDataListener
@@ -21,6 +24,7 @@ class EventFullTemplateDataListener
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ResponseContextAccessor $responseContextAccessor, private readonly ResponseContextAccessor $responseContextAccessor,
private readonly ContentUrlGenerator $contentUrlGenerator,
) { ) {
} }
@@ -47,6 +51,7 @@ class EventFullTemplateDataListener
$this->setMetaTitle($data); $this->setMetaTitle($data);
$this->setMetaDescriptionFallback($data, $locationRow); $this->setMetaDescriptionFallback($data, $locationRow);
$this->applyPastEventPolicy($template, $data, $eventId);
$template->location_row = $locationRow; $template->location_row = $locationRow;
$template->organizations = $organizations; $template->organizations = $organizations;
@@ -138,6 +143,56 @@ class EventFullTemplateDataListener
$headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.'); $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<string, mixed> $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<string, string> */ /** @return array<string, string> */
private function fetchLocationRow(int $locationId): array private function fetchLocationRow(int $locationId): array
{ {
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\EventListener;
use Contao\CoreBundle\Event\SitemapEvent;
use Doctrine\DBAL\Connection;
use Mummert\EventManagerBundle\Seo\PastEventPolicy;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* Ergänzt die Contao-Sitemap:
*
* - <lastmod> 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<string, array<string, mixed>> 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<string, int> 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;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Seo;
/**
* Umgang mit vergangenen Veranstaltungen.
*
* Bis INDEX_MONTHS nach dem Ende bleiben Events indexierbar (wiederkehrende Events
* wie Weihnachtsmärkte werden vor dem nächsten Termin gesucht). Danach:
* noindex,follow und nicht mehr in der Sitemap. Die Seiten bleiben als Archiv erreichbar.
*/
final class PastEventPolicy
{
public const INDEX_MONTHS = 12;
/**
* @param array<string, mixed> $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<string, mixed> $event
*/
public static function isPast(array $event, ?int $now = null): bool
{
$end = self::endTimestamp($event);
return $end > 0 && $end < ($now ?? time());
}
/**
* @param array<string, mixed> $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());
}
}