- 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>
480 lines
19 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|