- getMenu() treats hidden/time/fe_group as enable fields but NOT
nav_hide - the treeColumns doc comment claimed otherwise, and the
news detail page ("article") stayed in the new spotlight rail after
the editor hid it in menus. All three getMenu() calls (spotlight nav,
pagetree columns and their items) now pass 'AND nav_hide = 0';
verified live (6 rail items)
- all four tx_evomegamenujson_* tables get
security.ignorePageTypeRestriction: they are inline children of the
menu plugin and live on whatever page the plugin sits on - creating
a new entry on the standard page failed with "table is not allowed"
- new vitec:attach-page-media --root=<slug> (dry-run by default):
attaches the numbered placeholder pages/<pid>-08.png (slot 08 = page
media / menu tile, the number doubles as the image wish list) as page
media to every child page without one; existing media is never
touched. Run for /support (8 pages) so the planned Support menu entry
renders as two 4-tile rows via the pagetree layout's column images
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
457 lines
18 KiB
PHP
457 lines
18 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,
|
|
];
|
|
|
|
// 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) {
|
|
$items[] = [
|
|
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
|
|
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
|
|
];
|
|
}
|
|
$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;
|
|
}
|
|
}
|
|
}
|