SEO: event meta title with date, Open Graph defaults, cache invalidation

- event detail pages: <title> is "Title – 03.12.2026" (multi-day ranges
  supported) so recurring events with the same name stay distinguishable;
  H1 and lists keep the editor's title
- meta description fallback for events without teaser/description (date,
  time, location); a teaser that is only an insert tag (shared series
  text) also falls back, so a series does not share one description
- EventOpenGraphProvider for numero2/contao-opengraph3 (registered only
  when the bundle is installed, priority above its CalendarEventsProvider):
  og:title with date, og:description from teaser, event image as og:image
- FrontendCacheInvalidator: frontend event/organization edits write via
  DBAL, so invalidate the same cache tags the backend uses; tag the map
  and organization listing, invalidate the generic organization tag on
  backend saves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
admin.juergenandClaude Opus 5.5 committed 2026-11-04 16:58:33 +01:00
1 parent bdc8988e28
commit 5bf31e5043
12 files changed
+384

No files matched your search

+3
View File
@@ -29,5 +29,8 @@
"contao-components/installer": true, "contao-components/installer": true,
"contao/manager-plugin": true "contao/manager-plugin": true
} }
},
"suggest": {
"numero2/contao-opengraph3": "Open-Graph-Standardwerte (Titel mit Datum, Teaser, Eventbild) für Event-Detailseiten"
} }
} }
+2
View File
@@ -9,6 +9,8 @@ services:
- ../src/DependencyInjection/ - ../src/DependencyInjection/
- ../src/Contao/Manager/ - ../src/Contao/Manager/
- ../src/EventManagerBundle.php - ../src/EventManagerBundle.php
- ../src/OpenGraph/ # nur mit numero2/contao-opengraph3, siehe EventManagerExtension
- ../src/Seo/ # statische Helfer, kein Service
Mummert\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback: Mummert\EventManagerBundle\EventListener\DataContainer\SetDateAddedCallback:
tags: tags:
+8
View File
@@ -305,3 +305,11 @@ $GLOBALS['TL_DCA']['tl_organization']['fields']['alias']['save_callback'][] = st
return $value; return $value;
}; };
// Organisationsliste und Karte sind mit dem allgemeinen Tabellen-Tag getaggt
// (FrontendCacheInvalidator): beim Speichern im Backend mit invalidieren.
$GLOBALS['TL_DCA']['tl_organization']['config']['oninvalidate_cache_tags_callback'][] = static function ($dc, array $tags): array {
$tags[] = \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator::ORGANIZATION_TAG;
return $tags;
};
@@ -29,11 +29,15 @@ class EventMapController extends AbstractFrontendModuleController
public function __construct( public function __construct(
private readonly MapModuleDataProvider $mapModuleDataProvider, private readonly MapModuleDataProvider $mapModuleDataProvider,
private readonly \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator $cacheInvalidator,
) { ) {
} }
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{ {
// Karte zeigt Events aller Kalender und Organisationen: bei Änderungen neu erzeugen
$this->tagResponse($this->cacheInvalidator->getMapTags());
$containerId = sprintf('eventmanager-map-%d', (int) ($model->id ?? 0)); $containerId = sprintf('eventmanager-map-%d', (int) ($model->id ?? 0));
$dataElementId = sprintf('%s-data', $containerId); $dataElementId = sprintf('%s-data', $containerId);
$filterWrapperId = sprintf('%s-filter', $containerId); $filterWrapperId = sprintf('%s-filter', $containerId);
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Mummert\EventManagerBundle\DependencyInjection; namespace Mummert\EventManagerBundle\DependencyInjection;
use Mummert\EventManagerBundle\OpenGraph\EventOpenGraphProvider;
use numero2\Opengraph3Bundle\OpenGraph\Provider\ProviderInterface;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\Extension;
@@ -15,5 +17,14 @@ class EventManagerExtension extends Extension
{ {
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yaml'); $loader->load('services.yaml');
// Open-Graph-Standardwerte für Events, nur wenn numero2/contao-opengraph3 installiert ist.
// Höhere Priorität als dessen eigener CalendarEventsProvider (der erste passende Provider gewinnt).
if (interface_exists(ProviderInterface::class)) {
$container->register(EventOpenGraphProvider::class, EventOpenGraphProvider::class)
->setAutowired(true)
->setAutoconfigured(false)
->addTag('numero2_opengraph3.provider', ['priority' => 10]);
}
} }
} }
@@ -5,10 +5,13 @@ declare(strict_types=1);
namespace Mummert\EventManagerBundle\EventListener; namespace Mummert\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook; use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag;
use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor;
use Contao\StringUtil; use Contao\StringUtil;
use Contao\Template; use Contao\Template;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use Mummert\EventManagerBundle\Seo\EventMetaTitle;
#[AsHook('parseTemplate', method: 'onParseTemplate')] #[AsHook('parseTemplate', method: 'onParseTemplate')]
class EventFullTemplateDataListener class EventFullTemplateDataListener
@@ -17,6 +20,7 @@ class EventFullTemplateDataListener
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ResponseContextAccessor $responseContextAccessor,
) { ) {
} }
@@ -41,6 +45,9 @@ class EventFullTemplateDataListener
$organizations = $this->fetchOrganizations($eventId); $organizations = $this->fetchOrganizations($eventId);
$tags = $this->fetchTags($eventId); $tags = $this->fetchTags($eventId);
$this->setMetaTitle($data);
$this->setMetaDescriptionFallback($data, $locationRow);
$template->location_row = $locationRow; $template->location_row = $locationRow;
$template->organizations = $organizations; $template->organizations = $organizations;
$template->tags = $tags; $template->tags = $tags;
@@ -62,6 +69,75 @@ class EventFullTemplateDataListener
$template->tag_fields = $this->toFlatTagFields($tags); $template->tag_fields = $this->toFlatTagFields($tags);
} }
/**
* Meta-Titel mit Datum ("Titel – 03.12.2026"), damit wiederkehrende Events
* gleichen Namens in den Suchergebnissen unterscheidbar sind. H1 und Listen
* zeigen weiterhin nur den Redaktions-Titel.
*
* @param array<string, mixed> $data
*/
private function setMetaTitle(array $data): void
{
$headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class);
if (!$headBag instanceof HtmlHeadBag) {
return;
}
$title = EventMetaTitle::build($data);
if ('' !== $title) {
$headBag->setTitle($title);
}
}
/**
* Ersatz-Description für Events ohne Teaser/Beschreibung (Redakteure pflegen
* keine Meta-Description): "Titel am 03.12.2026 um 16:30 Uhr in Ort, Stadt."
*
* @param array<string, mixed> $data
* @param array<string, string> $locationRow
*/
private function setMetaDescriptionFallback(array $data, array $locationRow): void
{
$headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class);
if (!$headBag instanceof HtmlHeadBag) {
return;
}
// Vorhandene Description behalten, außer sie ist leer oder stammt aus einem Teaser,
// der nur aus einem Insert-Tag besteht (z. B. {{insert_content::ID}} als gemeinsamer
// Serien-Teaser: sonst hätten alle Events der Serie dieselbe Description).
$hasOwnDescription = '' !== trim(strip_tags((string) ($data['description'] ?? '')));
$teaserIsInsertTagOnly = (bool) preg_match('/^\{\{[^{}]+\}\}$/', trim((string) ($data['teaser'] ?? '')));
if ($hasOwnDescription || ('' !== trim($headBag->getMetaDescription()) && !$teaserIsInsertTagOnly)) {
return;
}
$title = html_entity_decode(strip_tags((string) ($data['title'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$date = EventMetaTitle::formatDate((int) ($data['startDate'] ?? 0), (int) ($data['endDate'] ?? 0));
if ('' === $date) {
return;
}
$description = trim($title).(str_contains($date, '–') ? ' vom '.str_replace('–', ' bis ', $date) : ' am '.$date);
if (!empty($data['addTime']) && !empty($data['startTime'])) {
$description .= ' um '.date('H:i', (int) $data['startTime']).' Uhr';
}
$place = implode(', ', array_filter([trim((string) ($locationRow['title'] ?? '')), trim((string) ($locationRow['city'] ?? ''))]));
if ('' !== $place) {
$description .= ' in '.$place;
}
$headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.');
}
/** @return array<string, string> */ /** @return array<string, string> */
private function fetchLocationRow(int $locationId): array private function fetchLocationRow(int $locationId): array
{ {
@@ -18,6 +18,7 @@ class OrganizationListingTemplateDataListener
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ?LoggerInterface $logger = null, private readonly ?LoggerInterface $logger = null,
private readonly \Mummert\EventManagerBundle\Service\FrontendCacheInvalidator $cacheInvalidator,
) { ) {
} }
@@ -27,6 +28,9 @@ class OrganizationListingTemplateDataListener
return; return;
} }
// Seite bei Änderungen an Organisationen automatisch aus dem Cache werfen
$this->cacheInvalidator->tagOrganizationListing();
$tbody = $template->tbody; $tbody = $template->tbody;
if (!\is_array($tbody) || [] === $tbody) { if (!\is_array($tbody) || [] === $tbody) {
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\OpenGraph;
use Contao\CalendarEventsModel;
use Contao\CoreBundle\InsertTag\InsertTagParser;
use Contao\Input;
use Contao\ModuleModel;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\EventManagerBundle\Seo\EventMetaTitle;
use numero2\Opengraph3Bundle\OpenGraph\OpenGraphReference;
use numero2\Opengraph3Bundle\OpenGraph\Provider\ProviderInterface;
/**
* Open-Graph-Standardwerte für Event-Detailseiten (numero2/contao-opengraph3).
*
* Redakteure pflegen keine OG-Felder. Ohne diesen Provider würden alle Events
* Titel und Bild der Website-Root teilen. Werte, die im Event selbst gepflegt
* sind (Reiter "OpenGraph"), haben weiterhin Vorrang.
*
* Wird nur registriert, wenn numero2/contao-opengraph3 installiert ist
* (siehe EventManagerExtension).
*/
class EventOpenGraphProvider implements ProviderInterface
{
private const DESCRIPTION_LENGTH = 200;
public function __construct(
private readonly InsertTagParser $insertTagParser,
private readonly Connection $connection,
) {
}
public function supports(ModuleModel $model): bool
{
return 'eventreader' === $model->type;
}
public function getReference(ModuleModel $model): ?OpenGraphReference
{
$event = CalendarEventsModel::findPublishedByParentAndIdOrAlias(
(string) Input::get('auto_item'),
StringUtil::deserialize($model->cal_calendar, true),
);
if (null === $event) {
return null;
}
$row = $event->row();
$defaults = [
'og_type' => 'website',
'og_title' => EventMetaTitle::build($row),
'og_description' => $this->buildDescription($row),
];
if ($event->addImage && $event->singleSRC) {
$defaults['og_image'] = $event->singleSRC;
}
return new OpenGraphReference($event, $defaults);
}
/**
* @param array<string, mixed> $row
*/
private function buildDescription(array $row): string
{
foreach (['teaser', 'description'] as $field) {
$text = $this->toPlainText((string) ($row[$field] ?? ''));
if ('' !== $text) {
return $this->truncate($text);
}
}
// Kein Text gepflegt: Datum, Uhrzeit und Ort als Beschreibung
$parts = [EventMetaTitle::formatDate((int) ($row['startDate'] ?? 0), (int) ($row['endDate'] ?? 0))];
if (!empty($row['addTime']) && !empty($row['startTime'])) {
$parts[0] .= ', '.date('H:i', (int) $row['startTime']).' Uhr';
}
$locationId = (int) ($row['location_id'] ?? 0);
if ($locationId > 0) {
$location = $this->connection->fetchAssociative('SELECT title, city FROM tl_location WHERE id = ?', [$locationId]);
if ($location) {
$parts[] = trim(implode(', ', array_filter([(string) $location['title'], (string) $location['city']])));
}
}
return implode(' – ', array_filter($parts));
}
private function toPlainText(string $value): string
{
// Leer oder nur ein Insert-Tag (gemeinsamer Serien-Teaser): lieber Datum und Ort
if ('' === trim($value) || preg_match('/^\{\{[^{}]+\}\}$/', trim($value))) {
return '';
}
$value = $this->insertTagParser->replaceInline($value);
$value = preg_replace('/<(br|\/p|\/li|\/h[1-6])\b[^>]*>/i', ' ', $value) ?? $value;
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $value));
}
private function truncate(string $text): string
{
if (mb_strlen($text) <= self::DESCRIPTION_LENGTH) {
return $text;
}
$cut = mb_substr($text, 0, self::DESCRIPTION_LENGTH);
$space = mb_strrpos($cut, ' ');
return rtrim(false !== $space && $space > 120 ? mb_substr($cut, 0, $space) : $cut, " ,.;:–-").' …';
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Seo;
/**
* Meta-Titel für Event-Detailseiten: Redaktions-Titel plus Datum, z. B.
* "Sterne falten – 03.12.2026" oder "Reiterferien – 03.08.–07.08.2027".
*
* Das Datum erscheint nur im <title> und in og:title, nicht in der H1 und nicht
* in den Listen. So bleiben wiederkehrende Veranstaltungen gleichen Namens
* für Suchmaschinen unterscheidbar.
*/
final class EventMetaTitle
{
/**
* @param array<string, mixed> $event Event-Datensatz (title, pageTitle, startDate, endDate)
*/
public static function build(array $event): string
{
$base = self::clean((string) ($event['pageTitle'] ?? ''));
if ('' === $base) {
$base = self::clean((string) ($event['title'] ?? ''));
}
$date = self::formatDate((int) ($event['startDate'] ?? 0), (int) ($event['endDate'] ?? 0));
if ('' === $date) {
return $base;
}
return '' === $base ? $date : $base.' – '.$date;
}
public static function formatDate(int $start, int $end = 0): string
{
if ($start <= 0) {
return '';
}
if ($end <= 0 || date('Ymd', $end) === date('Ymd', $start)) {
return date('d.m.Y', $start);
}
if (date('Y', $end) === date('Y', $start)) {
return date('d.m.', $start).'–'.date('d.m.Y', $end);
}
return date('d.m.Y', $start).'–'.date('d.m.Y', $end);
}
private static function clean(string $value): string
{
$value = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/u', ' ', $value));
}
}
+17
View File
@@ -11,6 +11,7 @@ class EventRepository
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly FrontendCacheInvalidator $cacheInvalidator,
) { ) {
} }
@@ -71,6 +72,8 @@ class EventRepository
['id' => $eventId], ['id' => $eventId],
['id' => ParameterType::INTEGER], ['id' => ParameterType::INTEGER],
); );
$this->cacheInvalidator->invalidateEvent($eventId);
} }
public function duplicate(int $eventId): ?int public function duplicate(int $eventId): ?int
@@ -138,6 +141,8 @@ class EventRepository
$this->assignTagsToEvent($newEventId, $tagIds); $this->assignTagsToEvent($newEventId, $tagIds);
} }
$this->cacheInvalidator->invalidateEvent($newEventId);
return $newEventId; return $newEventId;
} }
@@ -264,6 +269,8 @@ class EventRepository
public function delete(int $eventId): void public function delete(int $eventId): void
{ {
$calendarId = (int) $this->connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]);
$this->connection->delete( $this->connection->delete(
'tl_tags_rel', 'tl_tags_rel',
['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId], ['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId],
@@ -281,6 +288,8 @@ class EventRepository
['id' => $eventId], ['id' => $eventId],
['id' => ParameterType::INTEGER], ['id' => ParameterType::INTEGER],
); );
$this->cacheInvalidator->invalidateEvent($eventId, $calendarId);
} }
/** @return array<string, mixed>|null */ /** @return array<string, mixed>|null */
@@ -508,6 +517,8 @@ class EventRepository
], ],
); );
} }
$this->cacheInvalidator->invalidateEvent($eventId);
} }
/** /**
@@ -525,6 +536,8 @@ class EventRepository
'id' => ParameterType::INTEGER, 'id' => ParameterType::INTEGER,
], ],
); );
$this->cacheInvalidator->invalidateEvent($eventId);
} }
/** /**
@@ -573,6 +586,8 @@ class EventRepository
$this->assignEventToOrganizations($eventId, $allowedOrganizationIds); $this->assignEventToOrganizations($eventId, $allowedOrganizationIds);
$this->cacheInvalidator->invalidateEvent($eventId);
return $eventId; return $eventId;
} }
@@ -730,6 +745,8 @@ class EventRepository
['id' => $eventId], ['id' => $eventId],
$types, $types,
); );
$this->cacheInvalidator->invalidateEvent($eventId);
} }
/** @return array<int> */ /** @return array<int> */
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Mummert\EventManagerBundle\Service;
use Contao\CoreBundle\Cache\CacheTagManager;
use Doctrine\DBAL\Connection;
/**
* Leert den Contao-Seitencache nach Änderungen aus dem Frontend (Veranstalterbereich).
*
* Die Repositories schreiben direkt per DBAL. Anders als beim Speichern im Backend
* (DC_Table) bekommt Contao davon nichts mit, ohne Invalidierung wären Änderungen
* erst nach Ablauf der Cache-Zeit sichtbar.
*
* Tags wie im Backend: Event selbst + Kalender-Archiv (Listen, Kalender, Karte).
*/
class FrontendCacheInvalidator
{
public const ORGANIZATION_TAG = 'contao.db.tl_organization';
public function __construct(
private readonly CacheTagManager $cacheTagManager,
private readonly Connection $connection,
) {
}
public function invalidateEvent(int $eventId, ?int $calendarId = null): void
{
$tags = ['contao.db.tl_calendar_events.'.$eventId];
$calendarId ??= (int) $this->connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]);
if ($calendarId > 0) {
$tags[] = 'contao.db.tl_calendar.'.$calendarId;
}
$this->cacheTagManager->invalidateTags($tags);
}
public function invalidateOrganization(int $organizationId): void
{
$this->cacheTagManager->invalidateTags([self::ORGANIZATION_TAG, self::ORGANIZATION_TAG.'.'.$organizationId]);
}
/**
* Tags für Seiten, die Events aller Kalender und Organisationen zeigen (Karte).
*
* @return list<string>
*/
public function getMapTags(): array
{
$tags = [self::ORGANIZATION_TAG];
foreach ($this->connection->fetchFirstColumn('SELECT id FROM tl_calendar') as $calendarId) {
$tags[] = 'contao.db.tl_calendar.'.$calendarId;
}
return $tags;
}
public function tagOrganizationListing(): void
{
$this->cacheTagManager->tagWith([self::ORGANIZATION_TAG]);
}
}
+7
View File
@@ -11,6 +11,7 @@ class OrganizationRepository
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly FrontendCacheInvalidator $cacheInvalidator,
) { ) {
} }
@@ -85,6 +86,8 @@ class OrganizationRepository
'id' => ParameterType::INTEGER, 'id' => ParameterType::INTEGER,
], ],
); );
$this->cacheInvalidator->invalidateOrganization($organizationId);
} }
public function updateLogo(int $organizationId, ?string $logoUuid): void public function updateLogo(int $organizationId, ?string $logoUuid): void
@@ -106,6 +109,8 @@ class OrganizationRepository
['id' => $organizationId], ['id' => $organizationId],
$types, $types,
); );
$this->cacheInvalidator->invalidateOrganization($organizationId);
} }
/** /**
@@ -208,6 +213,8 @@ class OrganizationRepository
], ],
); );
} }
$this->cacheInvalidator->invalidateOrganization($organizationId);
} }
/** @return array<int> */ /** @return array<int> */