- evo_megamenu_json: new entry layout "pagetree" - the panel columns mirror the live subtree of the page the entry links to (one child per column, grandchildren as items, getMenu() restrictions apply), 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. Same columns shape in the payload - front ends need no change beyond knowing the new layout string - new vitec:normalize-slugs --root=<slug> [--style=plain|and]: recursively realigns every slug below a root to <parent-slug>/<slugified-title>, cascading, with a sibling-collision warning; EXT:redirects covers the old URLs. TYPO3's slug generator keeps slashes from page titles (third hit: rtcc-/-rtic), this sweeps a whole tree. Run on /solutions: four fixes, including the long-standing passenger page and solution page 467. The misspelled "engagament" slug stays deliberately - it follows the page title and is fixed in the coordinated typo task Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
364 lines
14 KiB
PHP
364 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\EvoMegamenuJson\Service;
|
|
|
|
use Doctrine\DBAL\ParameterType;
|
|
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
|
|
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\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';
|
|
|
|
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'], 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'], $cObj);
|
|
}
|
|
|
|
$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 === '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, nav_hide, fe_group),
|
|
* so an editor hides a page and it leaves the menu with the next flush.
|
|
*
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private function treeColumns(string $entryLink, ContentObjectRenderer $cObj): array
|
|
{
|
|
$rootUid = 0;
|
|
if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) {
|
|
$rootUid = (int)$matches[1];
|
|
} elseif (ctype_digit(trim($entryLink))) {
|
|
$rootUid = (int)trim($entryLink);
|
|
}
|
|
if ($rootUid <= 0) {
|
|
return [];
|
|
}
|
|
$columns = [];
|
|
foreach ($this->pageRepository->getMenu($rootUid) as $child) {
|
|
$items = [];
|
|
foreach ($this->pageRepository->getMenu((int)$child['uid']) as $grandchild) {
|
|
$items[] = [
|
|
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
|
|
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
|
|
];
|
|
}
|
|
$columns[] = [
|
|
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
|
|
'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
return $columns;
|
|
}
|
|
|
|
// ------------------------------------------------------------ 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 {
|
|
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;
|
|
}
|
|
}
|
|
}
|