Files
orascheandClaude Opus 5.5 9211c8a395 Megamenu: rail display for page trees, cache tags, editorial promo boxes
- 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>
2026-11-02 17:35:23 +01:00

480 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\EvoMegamenuJson\Service;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Turns one "Megamenu" plugin element into the payload the front end reads.
*
* The element carries its entries inline; every entry carries its columns
* (each with items) and, for the teaser layout, its cards. Presentation
* settings live in the element's FlexForm and travel with the menu, so the
* front end takes its behaviour from the payload instead of hard-coding it.
*
* Content stages (FlexForm `stage`) decide how much of the editorial detail
* is emitted: 1 = structure only, 2 = plus images and teaser lines,
* 3 = plus the featured slot. A front end built for stage 1 keeps working
* when the stage is raised - later stages only add keys.
*/
final class MenuBuilder
{
private const TABLE_CONTENT = 'tt_content';
private const TABLE_ENTRY = 'tx_evomegamenujson_entry';
private const TABLE_COLUMN = 'tx_evomegamenujson_column';
private const TABLE_ITEM = 'tx_evomegamenujson_item';
private const TABLE_TEASER = 'tx_evomegamenujson_teaser';
/** getMenu() does not filter "hide in menus" by itself. */
private const NAV_WHERE = 'AND nav_hide = 0';
private const DEFAULT_SETTINGS = [
'stage' => 3,
'pendingBehaviour' => 'fallback',
'showCounts' => true,
'showViewAll' => true,
'viewAllLabel' => 'View all %s',
'openOn' => 'hover',
'closeDelay' => 140,
'columnsPerRow' => 4,
'panelWidth' => 'container',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly FlexFormService $flexFormService,
private readonly PageRepository $pageRepository,
private readonly FileRepository $fileRepository,
private readonly EventDispatcherInterface $eventDispatcher,
) {}
/**
* @param array<string,mixed>|null $contentRow pass the row when it is already at hand
* @return array{settings:array<string,mixed>,entries:array<int,array<string,mixed>>}
*/
public function build(int $contentUid, ContentObjectRenderer $cObj, ?array $contentRow = null): array
{
$row = $contentRow ?? $this->fetchContentRow($contentUid);
if ($row === null) {
return ['settings' => self::DEFAULT_SETTINGS, 'entries' => []];
}
$settings = $this->settingsOf($row);
$stage = (int)$settings['stage'];
$entries = [];
foreach ($this->children(self::TABLE_ENTRY, (int)$row['uid']) as $entry) {
$entries[] = $this->buildEntry($entry, $stage, $cObj);
}
return ['settings' => $settings, 'entries' => $entries];
}
/**
* @param array<string,mixed> $entry
* @return array<string,mixed>
*/
private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array
{
$layout = (string)($entry['layout'] ?? 'columns');
if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree', 'spotlight'], true)) {
$layout = 'columns';
}
$columns = [];
foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) {
$items = [];
foreach ($this->children(self::TABLE_ITEM, (int)$column['uid']) as $item) {
$items[] = $this->buildItem($item, $stage, $cObj);
}
$columns[] = [
'title' => (string)$column['title'],
'link' => $this->url((string)$column['link'], $cObj),
'items' => $items,
];
}
// Page-tree panels (2026-10-23): the columns mirror the live subtree
// of the page the entry links to - one column per child, its children
// as the items - so the menu can never age against the page structure
// again (the stale 9-market panel after the markets realign was the
// trigger). Curated columns are ignored on this layout; teasers and
// the featured slot stay editorial.
if ($layout === 'pagetree') {
$columns = $this->treeColumns((string)$entry['link'], $stage, $cObj);
}
// Spotlight panels (2026-10-27, customer wish "visual menu like
// rossvideo.com"): a flat left-rail nav built live from the linked
// page's children (abstract + page media per item, so the front end
// can swap the visual area on hover) next to live content cards that
// a site package supplies via BuildSpotlightEvent - this extension
// does not know the data sources. Curated columns are ignored here
// like on pagetree; teasers and the featured slot stay editorial.
if ($layout === 'spotlight') {
$columns = [];
}
$built = [
'title' => (string)$entry['title'],
'link' => $this->url((string)$entry['link'], $cObj),
'layout' => $layout,
'columns' => $columns,
];
// Page-tree display (2026-11-02): same columns, two presentations -
// `columns` side by side, or `rail`: the columns as a list on the
// left, the hovered column's items as image cards on the right
// (`cardSize` regular|compact). Additive: a front end that does not
// know `display` keeps rendering columns.
if ($layout === 'pagetree') {
$display = (string)($entry['tree_display'] ?? 'columns');
$built['display'] = str_starts_with($display, 'rail') ? 'rail' : 'columns';
if ($built['display'] === 'rail') {
$built['cardSize'] = $display === 'rail-compact' ? 'compact' : 'regular';
}
}
// Catalog panels: the teaser cards serve as the featured tiles (left
// column), the catalog data itself comes from a listener of
// BuildCatalogEvent - this extension does not know the data source.
if ($layout === 'catalog') {
$event = $this->eventDispatcher->dispatch(new BuildCatalogEvent($entry, $stage));
$built['catalog'] = $event->getCatalog();
}
if ($layout === 'spotlight') {
$built['nav'] = $this->navItems((string)$entry['link'], $stage, $cObj);
$spotlightEvent = $this->eventDispatcher->dispatch(new BuildSpotlightEvent($entry, $stage));
$built['spotlight'] = $spotlightEvent->getSpotlight();
}
if ($layout === 'teasers' || $layout === 'catalog') {
$teasers = [];
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
$card = [
'kicker' => (string)$teaser['kicker'],
'title' => (string)$teaser['title'],
'link' => $this->url((string)$teaser['link'], $cObj),
];
if ($stage >= 2) {
$card['text'] = trim((string)($teaser['teasertext'] ?? ''));
$card['image'] = $this->image(self::TABLE_TEASER, 'image', (int)$teaser['uid']);
}
$teasers[] = $card;
}
$built['teasers'] = $teasers;
}
if ($stage >= 3) {
$built['featured'] = ((int)($entry['featured_enable'] ?? 0) === 1)
? [
'kicker' => (string)$entry['featured_kicker'],
'title' => (string)$entry['featured_title'],
'text' => trim((string)($entry['featured_text'] ?? '')),
'link' => $this->url((string)$entry['featured_link'], $cObj),
'image' => $this->image(self::TABLE_ENTRY, 'featured_image', (int)$entry['uid']),
]
: null;
}
return $built;
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
private function buildItem(array $item, int $stage, ContentObjectRenderer $cObj): array
{
$built = [
'title' => (string)$item['title'],
'link' => $this->url((string)$item['link'], $cObj),
];
if ((int)($item['pending'] ?? 0) === 1) {
$built['pending'] = true;
}
if ($stage >= 2) {
$teaser = trim((string)($item['teaser'] ?? ''));
if ($teaser !== '') {
$built['teaser'] = $teaser;
}
$badge = trim((string)($item['badge'] ?? ''));
if ($badge !== '') {
$built['badge'] = $badge;
}
$built['image'] = $this->image(self::TABLE_ITEM, 'image', (int)$item['uid']);
}
return $built;
}
/**
* Columns from the page tree below the entry's linked page. getMenu()
* applies the frontend restrictions (hidden, time, fe_group); nav_hide
* is NOT an enable field and goes through the additional where clause
* (found 2026-10-27 - the news detail page stayed in the spotlight
* rail). An editor hides a page and it leaves the menu with the next
* cache flush.
*
* @return array<int,array<string,mixed>>
*/
private function treeColumns(string $entryLink, int $stage, ContentObjectRenderer $cObj): array
{
$rootUid = $this->linkedPageUid($entryLink);
if ($rootUid <= 0) {
return [];
}
$columns = [];
foreach ($this->pageRepository->getMenu($rootUid, '*', 'sorting', self::NAV_WHERE) as $child) {
$items = [];
foreach ($this->pageRepository->getMenu((int)$child['uid'], '*', 'sorting', self::NAV_WHERE) as $grandchild) {
$item = [
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
];
// card data for the `rail` display (2026-11-02), same rules
// as the spotlight nav: abstract as teaser, page media image
if ($stage >= 2) {
$teaser = trim((string)($grandchild['abstract'] ?? ''));
if ($teaser !== '') {
$item['teaser'] = $teaser;
}
$item['image'] = $this->image('pages', 'media', (int)$grandchild['uid']);
}
$items[] = $item;
}
$column = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj),
'items' => $items,
];
// visual menus (2026-10-27): a page with media gets its first
// image as the column visual - attach an image in the page
// properties and the panel turns into image cards by itself
if ($stage >= 2) {
$image = $this->image('pages', 'media', (int)$child['uid']);
if ($image !== null) {
$column['image'] = $image;
}
}
$columns[] = $column;
}
return $columns;
}
/**
* Flat left-rail nav for the spotlight layout: the linked page's children
* with abstract and page media, so the front end can render the rail and
* swap the visual area on hover. Same visibility rules as treeColumns.
*
* @return array<int,array<string,mixed>>
*/
private function navItems(string $entryLink, int $stage, ContentObjectRenderer $cObj): array
{
$rootUid = $this->linkedPageUid($entryLink);
if ($rootUid <= 0) {
return [];
}
$items = [];
foreach ($this->pageRepository->getMenu($rootUid, '*', 'sorting', self::NAV_WHERE) as $child) {
$item = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj),
];
if ($stage >= 2) {
$teaser = trim((string)($child['abstract'] ?? ''));
if ($teaser !== '') {
$item['teaser'] = $teaser;
}
$item['image'] = $this->image('pages', 'media', (int)$child['uid']);
}
$items[] = $item;
}
return $items;
}
/** Page uid a menu entry links to; 0 when the link is no page link. */
private function linkedPageUid(string $entryLink): int
{
if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) {
return (int)$matches[1];
}
if (ctype_digit(trim($entryLink))) {
return (int)trim($entryLink);
}
return 0;
}
// ------------------------------------------------------------ internals
/** @return array<string,mixed>|null */
private function fetchContentRow(int $uid): ?array
{
if ($uid <= 0) {
return null;
}
$queryBuilder = $this->queryBuilder(self::TABLE_CONTENT);
$row = $queryBuilder->select('*')->from(self::TABLE_CONTENT)
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)))
->executeQuery()->fetchAssociative();
if ($row === false) {
return null;
}
return $this->overlay(self::TABLE_CONTENT, $row);
}
/**
* Children of one parent record, in backend sorting order, with the
* frontend restrictions (hidden, time, delete) applied and translations
* overlaid.
*
* @return array<int,array<string,mixed>>
*/
private function children(string $table, int $parentUid): array
{
$queryBuilder = $this->queryBuilder($table);
$rows = $queryBuilder->select('*')->from($table)
->where(
$queryBuilder->expr()->eq('parentid', $queryBuilder->createNamedParameter($parentUid, ParameterType::INTEGER)),
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
)
->orderBy('sorting', 'ASC')
->executeQuery()->fetchAllAssociative();
$overlaid = [];
foreach ($rows as $row) {
$translated = $this->overlay($table, $row);
if ($translated !== null) {
$overlaid[] = $translated;
}
}
return $overlaid;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>|null null when the translation hides the record
*/
private function overlay(string $table, array $row): ?array
{
try {
$overlaid = $this->pageRepository->getLanguageOverlay($table, $row);
return is_array($overlaid) ? $overlaid : null;
} catch (\Throwable) {
return $row;
}
}
private function queryBuilder(string $table): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
return $queryBuilder;
}
/**
* FlexForm settings merged over the defaults, typed the way the front end
* expects them.
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function settingsOf(array $row): array
{
$flex = $this->flexFormService->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
$stored = is_array($flex['settings'] ?? null) ? $flex['settings'] : [];
$settings = array_merge(self::DEFAULT_SETTINGS, array_filter($stored, static fn($v): bool => $v !== '' && $v !== null));
$settings['stage'] = max(1, min(3, (int)$settings['stage']));
$settings['closeDelay'] = max(0, (int)$settings['closeDelay']);
$settings['columnsPerRow'] = max(2, min(6, (int)$settings['columnsPerRow']));
$settings['showCounts'] = (bool)$settings['showCounts'];
$settings['showViewAll'] = (bool)$settings['showViewAll'];
$settings['openOn'] = $settings['openOn'] === 'click' ? 'click' : 'hover';
$settings['panelWidth'] = $settings['panelWidth'] === 'full' ? 'full' : 'container';
$settings['pendingBehaviour'] = in_array($settings['pendingBehaviour'], ['fallback', 'mute', 'hide'], true)
? $settings['pendingBehaviour'] : 'fallback';
$settings['viewAllLabel'] = (string)$settings['viewAllLabel'];
return $settings;
}
/** Resolved URL for a link field; '' when empty or unresolvable. */
private function url(string $link, ContentObjectRenderer $cObj): string
{
$link = trim($link);
if ($link === '') {
return '';
}
try {
return (string)$cObj->typoLink_URL(['parameter' => $link, 'forceAbsoluteUrl' => false]);
} catch (\Throwable) {
return '';
}
}
/**
* First file reference of a field as a plain object - url plus the
* metadata a menu needs. Null when nothing is attached.
*
* @return array<string,mixed>|null
*/
private function image(string $table, string $field, int $uid): ?array
{
try {
$references = $this->fileRepository->findByRelation($table, $field, $uid);
} catch (\Throwable) {
return null;
}
$reference = $references[0] ?? null;
if ($reference === null) {
return null;
}
try {
// Menu tiles are small: ship a processed 640px webp instead of the
// original upload - multi-MB teaser images went over the wire
// (found 2026-10-23). 640 matches the search-tile convention; the
// original URL stays the fallback when processing fails, so the
// menu never loses an image over a converter hiccup.
try {
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions(
$reference,
['maxWidth' => 640, 'fileExtension' => 'webp']
);
return [
'url' => $imageService->getImageUri($processed),
'alternative' => (string)$reference->getProperty('alternative'),
'title' => (string)$reference->getProperty('title'),
'width' => (int)$processed->getProperty('width'),
'height' => (int)$processed->getProperty('height'),
];
} catch (\Throwable) {
return [
'url' => $reference->getPublicUrl(),
'alternative' => (string)$reference->getProperty('alternative'),
'title' => (string)$reference->getProperty('title'),
'width' => (int)$reference->getProperty('width'),
'height' => (int)$reference->getProperty('height'),
];
}
} catch (\Throwable) {
return null;
}
}
}