diff --git a/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php
new file mode 100644
index 0000000..dbd519f
--- /dev/null
+++ b/packages/vitec/Classes/Command/SsrPurgeTargetsCommand.php
@@ -0,0 +1,53 @@
+addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product');
+ $this->addArgument('uid', InputArgument::REQUIRED, 'Record uid');
+ $this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true);
+ $paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
+ ->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields);
+ if ($paths === []) {
+ $output->writeln('nothing to purge (record not rendered anywhere)');
+ } elseif ($paths === [SsrPurgeTargetResolver::ALL]) {
+ $output->writeln('* (everything)');
+ } else {
+ $output->writeln(count($paths) . ' path(s):');
+ foreach ($paths as $path) {
+ $output->writeln(' ' . $path);
+ }
+ }
+ return Command::SUCCESS;
+ }
+}
diff --git a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php
index 49d5a9b..008417b 100644
--- a/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php
+++ b/packages/vitec/Classes/Hooks/DataHandlerSsrPurge.php
@@ -6,6 +6,7 @@ namespace Evomedien\Vitec\Hooks;
use TYPO3\CMS\Core\Core\Environment;
use Evomedien\Vitec\Service\SsrPurgeService;
+use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -19,10 +20,14 @@ use TYPO3\CMS\Core\Database\ConnectionPool;
* - pages: navigation-relevant field changed (title, nav_title, slug,
* hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page),
* otherwise just that page's path.
- * - tt_content: the hosting page's path.
- * - tx_news_* and tx_vitec_*: '*' (news feed the megamenu spotlight,
- * domain models render on many pages).
- * - delete/move/undelete commands: pages -> '*', records -> hosting page.
+ * - tt_content: the hosting page's path; the megamenu element -> '*'.
+ * - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that
+ * show the record (SsrPurgeTargetResolver: detail URL, explicit
+ * selections, list plugins, embedding records); '*' for URL/visibility
+ * changes, the newest news, events and anything unknown.
+ * - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page.
+ * - delete/move/undelete commands: pages, records and megamenu -> '*',
+ * content elements -> hosting page.
*
* Changes are queued per request and flushed ONCE in afterAllOperations,
* so bulk operations (importer!) end up as a single purge call.
@@ -31,7 +36,9 @@ use TYPO3\CMS\Core\Database\ConnectionPool;
*/
final class DataHandlerSsrPurge
{
- private const NAV_FIELDS = ['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
+ private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
+
+ private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
/** @var array */
private static array $queue = [];
@@ -59,6 +66,10 @@ final class DataHandlerSsrPurge
}
if ($table === 'tt_content') {
$recordId = $this->realId($id, $dataHandler);
+ if ($this->isMegamenuElement($recordId)) {
+ self::$queue['*'] = true;
+ return;
+ }
$pid = $fieldArray['pid'] ?? null;
if (!is_numeric($pid) || (int)$pid <= 0) {
$pid = $this->pidOfRecord($table, $recordId);
@@ -68,8 +79,16 @@ final class DataHandlerSsrPurge
}
return;
}
- if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
+ if (str_starts_with($table, self::MEGAMENU_PREFIX)) {
self::$queue['*'] = true;
+ return;
+ }
+ if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
+ $paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
+ ->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray));
+ foreach ($paths as $path) {
+ self::$queue[$path] = true;
+ }
}
}
@@ -94,17 +113,44 @@ final class DataHandlerSsrPurge
return;
}
if ($table === 'tt_content') {
+ if ($this->isMegamenuElement((int)$id)) {
+ self::$queue['*'] = true;
+ return;
+ }
$pid = $this->pidOfRecord($table, (int)$id);
if ($pid > 0) {
$this->queuePagePath($pid);
}
return;
}
- if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
+ if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')
+ || str_starts_with($table, self::MEGAMENU_PREFIX)) {
+ // Delete, move, copy, undelete: lists change everywhere.
self::$queue['*'] = true;
}
}
+ /**
+ * The megamenu element (CType evomegamenujson_megamenu) renders on
+ * every page through the site setting megamenu.contentUid.
+ */
+ private function isMegamenuElement(int $uid): bool
+ {
+ if ($uid <= 0) {
+ return false;
+ }
+ try {
+ $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
+ $qb->getRestrictions()->removeAll();
+ $cType = $qb->select('CType')->from('tt_content')
+ ->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
+ ->executeQuery()->fetchOne();
+ return $cType === 'evomegamenujson_megamenu';
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
public function processCmdmap_afterFinish(DataHandler $dataHandler): void
{
$this->flush('editor-command');
@@ -115,7 +161,7 @@ final class DataHandlerSsrPurge
if (self::$queue === []) {
return;
}
- $paths = array_keys(self::$queue);
+ $paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
self::$queue = [];
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
}
diff --git a/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php
new file mode 100644
index 0000000..0c75d79
--- /dev/null
+++ b/packages/vitec/Classes/Service/SsrPurgeTargetResolver.php
@@ -0,0 +1,296 @@
+ [parent field, parent table]. */
+ private const CHILD_TABLES = [
+ 'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
+ 'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
+ 'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
+ 'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
+ ];
+
+ /** Shown in the megamenu or rarely changed and used broadly. */
+ private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
+ 'tx_news_domain_model_tag'];
+
+ /** List plugins that show records of a table without a fixed selection. */
+ private const LIST_CTYPES = [
+ // Product lists (vitec_productlist) filter by category: productListPages().
+ 'tx_vitec_domain_model_product' => ['vitec_productcheck'],
+ 'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
+ 'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
+ 'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
+ 'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
+ 'tx_vitec_domain_model_location' => ['vitec_locationlist'],
+ 'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
+ 'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
+ ];
+
+ /** Records that embed a record of the key table: [embedding table, field]. */
+ private const EMBEDDED_IN = [
+ 'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
+ ['tx_vitec_product_related_text', 'related_uid']],
+ 'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
+ 'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
+ 'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
+ ];
+
+ /** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
+ private const DETAIL_ROUTES = [
+ 'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
+ 'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
+ 'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
+ ];
+
+ /** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
+ private const SPOTLIGHT_NEWS = 5;
+ private const SPOTLIGHT_NEWS_PIDS = [11, 442];
+
+ /**
+ * @param string[] $changedFields field names of the save ([] = unknown)
+ * @return list paths to purge, ['*'] for everything, [] for nothing visible
+ */
+ public function resolve(string $table, int $uid, array $changedFields): array
+ {
+ try {
+ return $this->doResolve($table, $uid, $changedFields);
+ } catch (\Throwable) {
+ return [self::ALL];
+ }
+ }
+
+ private function doResolve(string $table, int $uid, array $changedFields): array
+ {
+ if (in_array($table, self::INTERNAL_TABLES, true)) {
+ return [];
+ }
+ if ($uid <= 0) {
+ return [self::ALL];
+ }
+ if (isset(self::CHILD_TABLES[$table])) {
+ [$field, $parentTable] = self::CHILD_TABLES[$table];
+ $parentUid = (int)$this->connection($table)->executeQuery(
+ 'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
+ )->fetchOne();
+ return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
+ }
+ if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
+ return [self::ALL];
+ }
+ if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
+ return [self::ALL];
+ }
+ if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
+ return [self::ALL];
+ }
+
+ $paths = $this->pathsOf($table, $uid);
+ foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
+ foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
+ $paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
+ }
+ }
+ foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
+ $paths[] = $this->pagePath($pid);
+ }
+ if ($table === 'tx_vitec_domain_model_product') {
+ foreach ($this->productListPages($uid) as $pid) {
+ $paths[] = $this->pagePath($pid);
+ }
+ }
+ $paths = array_values(array_unique(array_filter($paths)));
+ return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
+ }
+
+ /** Detail URL(s) plus the pages that select the record explicitly. */
+ private function pathsOf(string $table, int $uid): array
+ {
+ $paths = $this->detailPaths($table, $uid);
+ $pids = $this->connection('sys_refindex')->executeQuery(
+ 'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
+ WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
+ ['tt_content', $table, $uid]
+ )->fetchFirstColumn();
+ foreach ($pids as $pid) {
+ $paths[] = $this->pagePath((int)$pid);
+ }
+ if ($table === 'tx_vitec_domain_model_solution') {
+ $detailPage = (int)$this->connection($table)->executeQuery(
+ 'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
+ )->fetchOne();
+ if ($detailPage > 0) {
+ $paths[] = $this->pagePath($detailPage);
+ }
+ }
+ return $paths;
+ }
+
+ /** @return list records embedding the given one */
+ private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
+ {
+ if ($embeddingTable === 'tx_vitec_product_related_text') {
+ $rows = $this->connection($embeddingTable)->executeQuery(
+ 'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
+ )->fetchFirstColumn();
+ return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
+ }
+ $rows = $this->connection('sys_refindex')->executeQuery(
+ 'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
+ [$embeddingTable, $field, $table, $uid]
+ )->fetchFirstColumn();
+ return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
+ }
+
+ private function detailPaths(string $table, int $uid): array
+ {
+ if (!isset(self::DETAIL_ROUTES[$table])) {
+ return [];
+ }
+ [$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
+ $paths = [];
+ foreach ($pageUids as $pageUid) {
+ try {
+ $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
+ $paths[] = $site->getRouter()->generateUri($pageUid, [
+ $namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
+ ])->getPath();
+ } catch (\Throwable) {
+ // Record not routable (hidden, no slug): its pages are covered by the lists.
+ }
+ }
+ return $paths;
+ }
+
+ private function pagesWithCTypes(array $cTypes): array
+ {
+ if ($cTypes === []) {
+ return [];
+ }
+ return array_map('intval', $this->connection('tt_content')->executeQuery(
+ 'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
+ [$cTypes],
+ [Connection::PARAM_STR_ARRAY]
+ )->fetchFirstColumn());
+ }
+
+ /**
+ * Pages with a product list that can show the product. Mirrors
+ * ProductListJsonRenderer: selected products (settings.selectedproducts)
+ * show exactly those - covered by the refindex; otherwise a category
+ * filter matches the selected categories and their whole subtree;
+ * "all products" or no category shows every product.
+ */
+ private function productListPages(int $productUid): array
+ {
+ // The product's categories plus all their ancestors.
+ $categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
+ "SELECT uid_local FROM sys_category_record_mm
+ WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
+ [$productUid]
+ )->fetchFirstColumn());
+ $seen = [];
+ $queue = $categories;
+ while ($queue !== []) {
+ $cat = array_shift($queue);
+ if ($cat <= 0 || isset($seen[$cat])) {
+ continue;
+ }
+ $seen[$cat] = true;
+ $queue[] = (int)$this->connection('sys_category')->executeQuery(
+ 'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
+ )->fetchOne();
+ }
+
+ $pages = [];
+ $lists = $this->connection('tt_content')->executeQuery(
+ "SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
+ )->fetchAllAssociative();
+ foreach ($lists as $list) {
+ $refs = $this->connection('sys_refindex')->executeQuery(
+ "SELECT ref_table, ref_uid FROM sys_refindex
+ WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
+ AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
+ [(int)$list['uid']]
+ )->fetchAllAssociative();
+ $selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
+ if ($selected !== []) {
+ continue; // fixed selection: in the refindex pages already when it contains the product
+ }
+ $listCategories = array_map('intval', array_column(
+ array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
+ 'ref_uid'
+ ));
+ $allProducts = preg_match('#settings\.allproducts">\s*1<#', (string)$list['pi_flexform']) === 1;
+ if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
+ $pages[] = (int)$list['pid'];
+ }
+ }
+ return array_values(array_unique($pages));
+ }
+
+ private function isSpotlightNews(int $uid): bool
+ {
+ $newest = $this->connection('tx_news_domain_model_news')->executeQuery(
+ 'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
+ ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
+ [self::SPOTLIGHT_NEWS_PIDS],
+ [Connection::PARAM_INT_ARRAY]
+ )->fetchFirstColumn();
+ return in_array($uid, array_map('intval', $newest), true);
+ }
+
+ private function pagePath(int $pageId): string
+ {
+ if ($pageId <= 0) {
+ return '';
+ }
+ try {
+ $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
+ return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
+ } catch (\Throwable) {
+ return ''; // Folder or page outside a site: nothing cached for it.
+ }
+ }
+
+ private function connection(string $table): Connection
+ {
+ return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
+ }
+}
diff --git a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
index ee0f04d..bcd9064 100644
--- a/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
+++ b/packages/vitec/Configuration/Sets/Vitecset/settings.definitions.yaml
@@ -130,7 +130,7 @@ settings:
vitec.ssr.autoPurge:
label: 'SSR auto-purge on editor changes'
- description: 'ON: saving pages/content/news automatically evicts the affected pages from the SSR page cache (menu-relevant changes evict everything). OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.'
+ description: 'ON: saving pages, content, news or VITEC records automatically evicts the pages that show them from the SSR page cache - a product only from its detail page and the lists that contain it. Menu-relevant changes (titles, URLs, visibility), the newest news, events and the megamenu evict everything. OFF: the SSR cache refreshes only by its TTL (5 min) or manual purge. Only relevant while SSR delivery is ON.'
category: VITEC
type: bool
default: true