- Page-tree entries get a "Page tree display" field (columns | rail |
rail-compact), emitted as display + cardSize: Markets and Solutions
show their main pages as a list and the sub-pages as image cards on
hover. Page-tree items carry image (page media) and teaser (abstract)
from stage 2. Additive; front ends ignoring display render columns.
- vitec:attach-page-media gains --depth=2 and falls back to the page's
hero background before the grey placeholder; the 39 sub-solution
pages got their hero image as page media.
- MegamenuJsonRenderer tags every page carrying the menu with the menu
tables and tt_content_<uid>, so saving menu records refreshes all
pages instead of waiting for a full flush.
- Products catalog: drop catalog.news; both promo boxes ("Product
highlight", "Product news") are the entry's editorial teaser cards.
ProvideInsightsSpotlight back to its previous state.
- Spec v1.29 and v1.30 (header version corrected), README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
4.9 KiB
PHP
123 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\EvoMegamenuJson\UserFunc;
|
|
|
|
use Doctrine\DBAL\ParameterType;
|
|
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
|
|
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
|
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
|
|
use TYPO3\CMS\Core\Cache\CacheTag;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|
|
|
/**
|
|
* Emits one megamenu as JSON. Serves both wiring points:
|
|
*
|
|
* - as a field of the plugin's own content element (no configuration -
|
|
* the element renders itself),
|
|
* - as a page-level field for the site header, configured with either
|
|
* `contentUid` (a specific plugin element) or `storagePid` (the first
|
|
* megamenu element found on that folder).
|
|
*
|
|
* TYPO3 v14 hands the ContentObjectRenderer in through the setter rather
|
|
* than the constructor; without it $this->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
|
|
* `<table>` and `<table>_<uid>` 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<string,mixed> $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;
|
|
}
|
|
}
|