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, ) {} /** * @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') === 'teasers' ? 'teasers' : '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, ]; } $built = [ 'title' => (string)$entry['title'], 'link' => $this->url((string)$entry['link'], $cObj), 'layout' => $layout, 'columns' => $columns, ]; if ($layout === 'teasers') { $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; } // ------------------------------------------------------------ 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 { 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; } } }