Files
VITEC-website/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php
Oliver Rasche 5bb4e374b4 Add evo_megamenu_json extension and seed the megamenu
- new project-neutral extension evo_megamenu_json (Evomedien): a Megamenu
  plugin holding entries, columns, items, teaser cards and a featured slot
  as nested records; presentation settings travel with the payload so the
  front end reads behaviour instead of hard-coding it; published as
  page.10.fields.megaMenu, driven by the site settings megamenu.contentUid
  and megamenu.storagePid
- vitec:seed-megamenu fills one element from the live structure: 5 entries,
  26 columns, 95 items, 4 story cards; items without a page yet are flagged
  pending so the front end falls back to the column link
- vitec:debug-sets prints the resolved site set order
- read-only diagnostics: check_megamenu_schema.php, check_typoscript_templates.php
- docs: schema updates run via extension:setup (database:updateschema no
  longer exists in v14), and a JSON Accept header activates headless-mixed,
  which strips every set-added page field
2026-09-11 11:44:20 +02:00

304 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\EvoMegamenuJson\Service;
use Doctrine\DBAL\ParameterType;
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,
) {}
/**
* @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') === '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<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;
}
// ------------------------------------------------------------ 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;
}
}
}