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|null $contentRow pass the row when it is already at hand * @return array{settings:array,entries:array>} */ 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 $entry * @return array */ 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 $item * @return array */ 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> */ 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> */ 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|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> */ 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 $row * @return array|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 $row * @return array */ 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|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; } } }