cObj stays null and the element
* path cannot see which record it is rendering.
*
* Cache (2026-11-02): the menu is part of every cached page, but its records
* live on a storage folder - saving them cleared only that folder, and every
* page cached before kept the old menu until a full flush. The page is
* therefore tagged with the menu tables; the DataHandler flushes the tags
* `
` and `_` of every record it writes, so any save in the
* menu refreshes all pages carrying it. Data the panels read from the page
* tree (pagetree, catalog, spotlight) is not covered and still needs a flush.
*/
final class MegamenuJsonRenderer
{
private const CTYPE = 'evomegamenujson_megamenu';
private const RECORD_TABLES = [
'tx_evomegamenujson_entry',
'tx_evomegamenujson_column',
'tx_evomegamenujson_item',
'tx_evomegamenujson_teaser',
];
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
/**
* @param array $conf TypoScript: contentUid, storagePid
*/
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$cObj = $this->cObj ?? GeneralUtility::makeInstance(ContentObjectRenderer::class);
$row = null;
$uid = (int)($conf['contentUid'] ?? 0);
if ($uid <= 0) {
$data = is_array($cObj->data ?? null) ? $cObj->data : [];
if ((string)($data['CType'] ?? '') === self::CTYPE) {
// rendering the element itself
$row = $data;
$uid = (int)($data['uid'] ?? 0);
}
}
if ($uid <= 0 && (int)($conf['storagePid'] ?? 0) > 0) {
$uid = $this->firstElementOn((int)$conf['storagePid']);
}
if ($uid <= 0) {
return '';
}
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
$this->tagPageCache($cObj, $uid);
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
// A broken menu must never take the page payload down with it.
return '';
}
}
/** Tags the page cache with the menu element and tables (see class comment). */
private function tagPageCache(ContentObjectRenderer $cObj, int $contentUid): void
{
try {
$collector = $cObj->getRequest()->getAttribute('frontend.cache.collector');
if (!$collector instanceof CacheDataCollectorInterface) {
return;
}
$tags = array_map(static fn(string $table): CacheTag => new CacheTag($table), self::RECORD_TABLES);
$collector->addCacheTags(new CacheTag('tt_content_' . $contentUid), ...$tags);
} catch (\Throwable) {
// without tags the menu still renders, it just ages until a flush
}
}
/** uid of the first megamenu element on a storage folder, 0 when none. */
private function firstElementOn(int $pid): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
$uid = $queryBuilder->select('uid')->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter(self::CTYPE))
)
->orderBy('sorting', 'ASC')
->setMaxResults(1)
->executeQuery()->fetchOne();
return is_numeric($uid) ? (int)$uid : 0;
}
}