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
This commit is contained in:
2026-09-11 11:44:20 +02:00
parent e9eaa62d89
commit 5bb4e374b4
31 changed files with 2251 additions and 4 deletions

View File

@@ -0,0 +1,303 @@
<?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;
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Evomedien\EvoMegamenuJson\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Emits one megamenu as JSON. Serves both wiring points:
*
* - as a field of the plugin's own content element (no configuration -
* the element renders itself),
* - as a page-level field for the site header, configured with either
* `contentUid` (a specific plugin element) or `storagePid` (the first
* megamenu element found on that folder).
*
* TYPO3 v14 hands the ContentObjectRenderer in through the setter rather
* than the constructor; without it $this->cObj stays null and the element
* path cannot see which record it is rendering.
*/
final class MegamenuJsonRenderer
{
private const CTYPE = 'evomegamenujson_megamenu';
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
/**
* @param array<string,mixed> $conf TypoScript: contentUid, storagePid
*/
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
try {
$cObj = $this->cObj ?? GeneralUtility::makeInstance(ContentObjectRenderer::class);
$row = null;
$uid = (int)($conf['contentUid'] ?? 0);
if ($uid <= 0) {
$data = is_array($cObj->data ?? null) ? $cObj->data : [];
if ((string)($data['CType'] ?? '') === self::CTYPE) {
// rendering the element itself
$row = $data;
$uid = (int)($data['uid'] ?? 0);
}
}
if ($uid <= 0 && (int)($conf['storagePid'] ?? 0) > 0) {
$uid = $this->firstElementOn((int)$conf['storagePid']);
}
if ($uid <= 0) {
return '';
}
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
// A broken menu must never take the page payload down with it.
return '';
}
}
/** uid of the first megamenu element on a storage folder, 0 when none. */
private function firstElementOn(int $pid): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
$uid = $queryBuilder->select('uid')->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter(self::CTYPE))
)
->orderBy('sorting', 'ASC')
->setMaxResults(1)
->executeQuery()->fetchOne();
return is_numeric($uid) ? (int)$uid : 0;
}
}

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<!--
Presentation settings for one megamenu. Everything here is emitted as
`settings` in the JSON so the front end reads the behaviour from the
payload instead of hard-coding it.
-->
<T3DataStructure>
<sheets>
<sContent>
<ROOT>
<sheetTitle>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.content</sheetTitle>
<el>
<settings.stage>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.description</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<default>3</default>
<items>
<numIndex index="0">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.1</label>
<value>1</value>
</numIndex>
<numIndex index="1">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.2</label>
<value>2</value>
</numIndex>
<numIndex index="2">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.3</label>
<value>3</value>
</numIndex>
</items>
</config>
</settings.stage>
<settings.pendingBehaviour>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.description</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<default>fallback</default>
<items>
<numIndex index="0">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.fallback</label>
<value>fallback</value>
</numIndex>
<numIndex index="1">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.mute</label>
<value>mute</value>
</numIndex>
<numIndex index="2">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.hide</label>
<value>hide</value>
</numIndex>
</items>
</config>
</settings.pendingBehaviour>
<settings.showCounts>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showCounts</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showCounts.description</description>
<config>
<type>check</type>
<renderType>checkboxToggle</renderType>
<default>1</default>
</config>
</settings.showCounts>
<settings.showViewAll>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showViewAll</label>
<config>
<type>check</type>
<renderType>checkboxToggle</renderType>
<default>1</default>
</config>
</settings.showViewAll>
<settings.viewAllLabel>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.viewAllLabel</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.viewAllLabel.description</description>
<config>
<type>input</type>
<size>30</size>
<default>View all %s</default>
</config>
</settings.viewAllLabel>
</el>
</ROOT>
</sContent>
<sBehaviour>
<ROOT>
<sheetTitle>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.behaviour</sheetTitle>
<el>
<settings.openOn>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.description</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<default>hover</default>
<items>
<numIndex index="0">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.hover</label>
<value>hover</value>
</numIndex>
<numIndex index="1">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.click</label>
<value>click</value>
</numIndex>
</items>
</config>
</settings.openOn>
<settings.closeDelay>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.closeDelay</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.closeDelay.description</description>
<config>
<type>number</type>
<size>8</size>
<default>140</default>
<range>
<lower>0</lower>
<upper>1000</upper>
</range>
</config>
</settings.closeDelay>
<settings.columnsPerRow>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.columnsPerRow</label>
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.columnsPerRow.description</description>
<config>
<type>number</type>
<size>8</size>
<default>4</default>
<range>
<lower>2</lower>
<upper>6</upper>
</range>
</config>
</settings.columnsPerRow>
<settings.panelWidth>
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth</label>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<default>container</default>
<items>
<numIndex index="0">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth.container</label>
<value>container</value>
</numIndex>
<numIndex index="1">
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth.full</label>
<value>full</value>
</numIndex>
</items>
</config>
</settings.panelWidth>
</el>
</ROOT>
</sBehaviour>
</sheets>
</T3DataStructure>

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider;
return [
'evomegamenujson-plugin' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:evo_megamenu_json/Resources/Public/Icons/plugin.svg',
],
];

View File

@@ -0,0 +1,13 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: false
Evomedien\EvoMegamenuJson\:
resource: '../Classes/*'
# Resolved through GeneralUtility::makeInstance() from the USER function,
# so it has to be reachable from the container.
Evomedien\EvoMegamenuJson\Service\MenuBuilder:
public: true

View File

@@ -0,0 +1,9 @@
name: evomedien/megamenu
label: EVO Megamenu (JSON)
# headless has to be loaded before this set: it resets the whole page object
# (`page < lib.headlessPage`), which would drop the page-level field added
# here. Declaring the dependency keeps that order without the site
# configuration having to care about the listing position.
dependencies:
- friendsoftypo3/headless

View File

@@ -0,0 +1,19 @@
categories:
megamenu:
label: 'Megamenu'
description: 'Which megamenu element is published at page level'
settings:
megamenu.contentUid:
label: 'Megamenu element (uid)'
description: 'Uid of the "Megamenu" content element to publish on every page as `megaMenu`. 0 = do not publish at page level.'
category: megamenu
type: int
default: 0
megamenu.storagePid:
label: 'Megamenu folder (pid)'
description: 'Alternative to the uid above: the first megamenu element found on this folder is published. Ignored when an element uid is set.'
category: megamenu
type: int
default: 0

View File

@@ -0,0 +1,41 @@
# =============================================================================
# EVO Megamenu JSON
#
# Two wiring points, one renderer:
# 1. the plugin element renders itself, so the menu can be previewed on the
# page it sits on (content.megamenu),
# 2. the site header needs the menu on every page - set `megamenu.contentUid`
# (or `megamenu.storagePid`) and it is published at page level as
# `megaMenu`.
#
# Two things are deliberate here:
#
# * No TypoScript condition around the page-level field. A condition that
# cannot be evaluated swallows every include that follows it and silently
# disables the TypoScript of all later sets. The renderer returns an empty
# value when nothing is configured - same result, no risk.
#
# * The JSON key is written out literally. Since TYPO3 v12 the TypoScript
# parser substitutes constants only on the VALUE side, so a `{$...}` in
# the object path produces no field at all (found the hard way,
# 2026-09-11). A project that needs another key copies these five lines
# into its own set.
#
# The USER function returns a JSON string; the headless JSON content object
# decodes it, so the menu arrives as a real object, not an escaped string.
# =============================================================================
tt_content.evomegamenujson_megamenu =< lib.contentElement
tt_content.evomegamenujson_megamenu {
fields {
megamenu = USER
megamenu.userFunc = Evomedien\EvoMegamenuJson\UserFunc\MegamenuJsonRenderer->render
}
}
page.10.fields.megaMenu = USER
page.10.fields.megaMenu {
userFunc = Evomedien\EvoMegamenuJson\UserFunc\MegamenuJsonRenderer->render
contentUid = {$megamenu.contentUid}
storagePid = {$megamenu.storagePid}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
defined('TYPO3') or die();
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
/**
* Registers the "Megamenu" plugin (CType evomegamenujson_megamenu).
*
* The element carries the whole menu: its entries live inline below it, and
* the FlexForm holds the presentation settings the front end reads back out
* of the JSON (stage, hover behaviour, column count, labels).
*
* Place ONE element per menu - typically on a sysfolder - and point the site
* setting `megamenu.contentUid` at it to expose the menu at page level.
*/
(static function (): void {
ExtensionUtility::registerPlugin(
'EvoMegamenuJson',
'Megamenu',
'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:plugin.megamenu',
'evomegamenujson-plugin',
'menu',
'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:plugin.megamenu.description',
'FILE:EXT:evo_megamenu_json/Configuration/FlexForms/Megamenu.xml'
);
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:';
$GLOBALS['TCA']['tt_content']['columns']['tx_evomegamenujson_entries'] = [
'label' => $ll . 'tt_content.entries',
'description' => $ll . 'tt_content.entries.description',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_evomegamenujson_entry',
'foreign_field' => 'parentid',
'foreign_table_field' => 'parenttable',
'foreign_sortby' => 'sorting',
'maxitems' => 12,
'appearance' => [
'collapseAll' => true,
'expandSingle' => true,
'useSortable' => true,
'newRecordLinkAddTitle' => true,
'levelLinksPosition' => 'top',
],
],
];
$GLOBALS['TCA']['tt_content']['types']['evomegamenujson_megamenu']['showitem'] = '
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
--palette--;;general,
header,
--div--;' . $ll . 'tt_content.tab.entries,
tx_evomegamenujson_entries,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:plugin,
pi_flexform,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
--palette--;;hidden,
--palette--;;access,
';
})();

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* A column inside an entry's panel: a headline that is itself a link,
* plus the list of items below it.
*/
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_column.';
return [
'ctrl' => [
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_column',
'label' => 'title',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'enablecolumns' => ['disabled' => 'hidden'],
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/column.svg',
'searchFields' => 'title',
],
'types' => [
'1' => ['showitem' => 'hidden, title, link, menu_items, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
],
'columns' => [
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [['label' => '', 'value' => 0]],
'foreign_table' => 'tx_evomegamenujson_column',
'foreign_table_where' => 'AND {#tx_evomegamenujson_column}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_column}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'parentid' => ['config' => ['type' => 'passthrough']],
'title' => [
'label' => $ll . 'title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
],
'link' => [
'label' => $ll . 'link',
'description' => $ll . 'link.description',
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record'], 'size' => 40],
],
'menu_items' => [
'label' => $ll . 'menu_items',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_evomegamenujson_item',
'foreign_field' => 'parentid',
'foreign_sortby' => 'sorting',
'maxitems' => 30,
'appearance' => [
'collapseAll' => true,
'expandSingle' => true,
'useSortable' => true,
'newRecordLinkAddTitle' => true,
'levelLinksPosition' => 'top',
],
],
],
],
];

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/**
* One item in the main navigation bar - the thing a visitor hovers.
*
* `link` is a real target: the bar item stays clickable and leads to the
* overview page, the panel only opens on hover. `layout` decides which of
* the two panel shapes renders - a column grid, or a small link column
* next to teaser cards.
*/
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_entry.';
return [
'ctrl' => [
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_entry',
'label' => 'title',
'label_alt' => 'layout',
'label_alt_force' => true,
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'enablecolumns' => [
'disabled' => 'hidden',
],
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/entry.svg',
'searchFields' => 'title',
],
'types' => [
'1' => [
'showitem' => '
--div--;' . $ll . 'tab.general,
hidden, title, link, layout,
--div--;' . $ll . 'tab.columns,
menu_columns,
--div--;' . $ll . 'tab.teasers,
menu_teasers,
--div--;' . $ll . 'tab.featured,
featured_enable, featured_kicker, featured_title, featured_text, featured_link, featured_image,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
sys_language_uid, l10n_parent
',
],
],
'columns' => [
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [['label' => '', 'value' => 0]],
'foreign_table' => 'tx_evomegamenujson_entry',
'foreign_table_where' => 'AND {#tx_evomegamenujson_entry}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_entry}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'parentid' => ['config' => ['type' => 'passthrough']],
'parenttable' => ['config' => ['type' => 'passthrough']],
'title' => [
'label' => $ll . 'title',
'description' => $ll . 'title.description',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
],
'link' => [
'label' => $ll . 'link',
'description' => $ll . 'link.description',
'config' => [
'type' => 'link',
'allowedTypes' => ['page', 'url', 'record'],
'size' => 40,
],
],
'layout' => [
'label' => $ll . 'layout',
'description' => $ll . 'layout.description',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 'columns',
'items' => [
['label' => $ll . 'layout.columns', 'value' => 'columns'],
['label' => $ll . 'layout.teasers', 'value' => 'teasers'],
],
],
],
'menu_columns' => [
'label' => $ll . 'menu_columns',
'description' => $ll . 'menu_columns.description',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_evomegamenujson_column',
'foreign_field' => 'parentid',
'foreign_sortby' => 'sorting',
'maxitems' => 12,
'appearance' => [
'collapseAll' => true,
'expandSingle' => true,
'useSortable' => true,
'showSynchronizationLink' => false,
'showAllLocalizationLink' => false,
'showPossibleLocalizationRecords' => false,
'newRecordLinkAddTitle' => true,
'levelLinksPosition' => 'top',
],
],
],
'menu_teasers' => [
'displayCond' => 'FIELD:layout:=:teasers',
'label' => $ll . 'menu_teasers',
'description' => $ll . 'menu_teasers.description',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_evomegamenujson_teaser',
'foreign_field' => 'parentid',
'foreign_sortby' => 'sorting',
'maxitems' => 8,
'appearance' => [
'collapseAll' => true,
'expandSingle' => true,
'useSortable' => true,
'newRecordLinkAddTitle' => true,
'levelLinksPosition' => 'top',
],
],
],
'featured_enable' => [
'label' => $ll . 'featured_enable',
'description' => $ll . 'featured_enable.description',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'featured_kicker' => [
'displayCond' => 'FIELD:featured_enable:=:1',
'label' => $ll . 'featured_kicker',
'config' => ['type' => 'input', 'size' => 30, 'eval' => 'trim', 'max' => 60],
],
'featured_title' => [
'displayCond' => 'FIELD:featured_enable:=:1',
'label' => $ll . 'featured_title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim'],
],
'featured_text' => [
'displayCond' => 'FIELD:featured_enable:=:1',
'label' => $ll . 'featured_text',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3],
],
'featured_link' => [
'displayCond' => 'FIELD:featured_enable:=:1',
'label' => $ll . 'featured_link',
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
],
'featured_image' => [
'displayCond' => 'FIELD:featured_enable:=:1',
'label' => $ll . 'featured_image',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types',
],
],
],
];

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* A single link line inside a column. `teaser` and `image` only reach the
* JSON from content stage 2 on, `pending` marks a target page that does not
* exist yet so the front end can grey the line out instead of linking into
* a 404.
*/
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_item.';
return [
'ctrl' => [
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_item',
'label' => 'title',
'label_alt' => 'teaser',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'enablecolumns' => ['disabled' => 'hidden'],
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/item.svg',
'searchFields' => 'title,teaser',
],
'types' => [
'1' => ['showitem' => 'hidden, title, link, pending, teaser, badge, image, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
],
'columns' => [
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [['label' => '', 'value' => 0]],
'foreign_table' => 'tx_evomegamenujson_item',
'foreign_table_where' => 'AND {#tx_evomegamenujson_item}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_item}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'parentid' => ['config' => ['type' => 'passthrough']],
'title' => [
'label' => $ll . 'title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
],
'link' => [
'label' => $ll . 'link',
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
],
'pending' => [
'label' => $ll . 'pending',
'description' => $ll . 'pending.description',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'teaser' => [
'label' => $ll . 'teaser',
'description' => $ll . 'teaser.description',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'max' => 255],
],
'badge' => [
'label' => $ll . 'badge',
'description' => $ll . 'badge.description',
'config' => ['type' => 'input', 'size' => 15, 'eval' => 'trim', 'max' => 60],
],
'image' => [
'label' => $ll . 'image',
'description' => $ll . 'image.description',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types',
],
],
],
];

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* A teaser card - the editorial half of the "teasers" layout, used where a
* menu should show what is new rather than a full product tree.
*/
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_teaser.';
return [
'ctrl' => [
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_teaser',
'label' => 'title',
'label_alt' => 'kicker',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'versioningWS' => true,
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'enablecolumns' => ['disabled' => 'hidden'],
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/teaser.svg',
'searchFields' => 'title,teasertext',
],
'types' => [
'1' => ['showitem' => 'hidden, kicker, title, teasertext, link, image, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
],
'columns' => [
'sys_language_uid' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
'config' => ['type' => 'language'],
],
'l10n_parent' => [
'displayCond' => 'FIELD:sys_language_uid:>:0',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 0,
'items' => [['label' => '', 'value' => 0]],
'foreign_table' => 'tx_evomegamenujson_teaser',
'foreign_table_where' => 'AND {#tx_evomegamenujson_teaser}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_teaser}.{#sys_language_uid} IN (-1,0)',
],
],
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
],
'parentid' => ['config' => ['type' => 'passthrough']],
'kicker' => [
'label' => $ll . 'kicker',
'description' => $ll . 'kicker.description',
'config' => ['type' => 'input', 'size' => 25, 'eval' => 'trim', 'max' => 60],
],
'title' => [
'label' => $ll . 'title',
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
],
'teasertext' => [
'label' => $ll . 'teasertext',
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3],
],
'link' => [
'label' => $ll . 'link',
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
],
'image' => [
'label' => $ll . 'image',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types',
],
],
],
];

View File

@@ -0,0 +1,101 @@
# EVO Megamenu JSON
An editable megamenu for headless TYPO3. One content element holds the whole
menu; the extension publishes it as JSON, the front end renders it.
Project-neutral by design — nothing in here knows which site it runs on.
## What the editor gets
A plugin called **Megamenu**. Inside it:
| Level | Holds |
|---|---|
| **Entry** | one item in the main bar: label, target page, panel layout |
| **Column** | a heading (optionally a link) and its items |
| **Item** | label, link, teaser line, badge, thumbnail, “page not ready” flag |
| **Teaser card** | kicker, headline, text, link, image — for editorial panels |
| **Featured** | one highlighted block per entry, edited on the entry itself |
Two panel layouts per entry: `columns` for a structure menu (products,
solutions, markets) and `teasers` for a small link column next to cards
(news, stories).
## Installation
```bash
composer require evomedien/evo-megamenu-json
vendor/bin/typo3 extension:setup
vendor/bin/typo3 database:updateschema "*.add,*.change"
```
In the site configuration, add the set **EVO Megamenu (JSON)** and set:
| Setting | Meaning |
|---|---|
| `megamenu.contentUid` | uid of the Megamenu element to publish on every page |
| `megamenu.storagePid` | alternative: folder whose first Megamenu element is used |
Leave both uid and pid at 0 and the menu is only rendered where the element
sits — useful while building it.
## Payload
```json
"megaMenu": {
"settings": {
"stage": 3, "openOn": "hover", "closeDelay": 140, "columnsPerRow": 4,
"panelWidth": "container", "pendingBehaviour": "fallback",
"showCounts": true, "showViewAll": true, "viewAllLabel": "View all %s"
},
"entries": [
{
"title": "Products",
"link": "/products",
"layout": "columns",
"columns": [
{
"title": "IP Video Streaming",
"link": "/products/ip-video-streaming",
"items": [
{ "title": "Appliances", "link": "/products/ip-video-streaming/appliances",
"pending": true, "teaser": "MGW Diamond, Ace, Pico",
"image": { "url": "/fileadmin/...", "alternative": "", "title": "", "width": 600, "height": 400 } }
]
}
],
"featured": { "kicker": "Datasheet", "title": "MGW Diamond-H", "text": "…", "link": "/product/…", "image": { } }
}
]
}
```
`settings` travels with the menu on purpose: the front end reads hover
behaviour, close delay, column count and labels from the payload instead of
hard-coding them, so an editor can change them without a deploy.
### Content stages
`stage` decides how much detail is emitted — 1 structure only, 2 adds images,
teaser lines and cards, 3 adds the featured block. Later stages only **add**
keys, so a front end written for stage 1 keeps working when the stage is
raised.
### `pending`
An item whose target page does not exist yet carries `"pending": true`.
`settings.pendingBehaviour` says what to do with it: `fallback` (link to the
column heading instead), `mute` (show, not clickable) or `hide`. This keeps
a menu shippable while its deeper pages are still being built.
## Notes
* Every link is a resolved URL — page, record, external or file, run through
typolink at render time.
* Translations are overlaid per record, so a menu can be localised entry by
entry.
* The renderer never throws: a broken menu returns an empty value rather than
taking the page payload down.
* Without `friendsoftypo3/headless` the element has no JSON envelope to render
into — the extension is built for headless installations.

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.0">
<file source-language="en" datatype="plaintext" original="messages" date="2026-09-11T00:00:00Z" product-name="evo_megamenu_json">
<header/>
<body>
<trans-unit id="plugin.megamenu"><source>Megamenu</source></trans-unit>
<trans-unit id="plugin.megamenu.description"><source>One editable megamenu: entries in the main bar, each opening a panel of columns or teaser cards. Published as JSON.</source></trans-unit>
<trans-unit id="tt_content.entries"><source>Menu entries</source></trans-unit>
<trans-unit id="tt_content.entries.description"><source>The items in the main navigation bar, left to right.</source></trans-unit>
<trans-unit id="tt_content.tab.entries"><source>Entries</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry"><source>Menu entry</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tab.general"><source>General</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tab.columns"><source>Columns</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tab.teasers"><source>Teaser cards</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tab.featured"><source>Featured</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.title"><source>Label</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.title.description"><source>Shown in the main bar.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.link"><source>Target page</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.link.description"><source>The bar item stays a real link - the panel opens on hover, a click goes to this page.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout"><source>Panel layout</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.description"><source>Columns for a structure menu, teaser cards for editorial sections such as news or stories.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.columns"><source>Columns</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.teasers"><source>Link column plus teaser cards</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_teasers.description"><source>Shown from content stage 2 on.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_enable"><source>Show featured block</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_enable.description"><source>A highlighted block at the right edge of the panel. Reaches the JSON from content stage 3 on.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_kicker"><source>Kicker</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_title"><source>Headline</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_text"><source>Text</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_link"><source>Link</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.featured_image"><source>Image</source></trans-unit>
<trans-unit id="tx_evomegamenujson_column"><source>Column</source></trans-unit>
<trans-unit id="tx_evomegamenujson_column.title"><source>Column heading</source></trans-unit>
<trans-unit id="tx_evomegamenujson_column.link"><source>Heading link</source></trans-unit>
<trans-unit id="tx_evomegamenujson_column.link.description"><source>Optional - makes the column heading clickable, usually the group overview page.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_column.menu_items"><source>Entries</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item"><source>Menu item</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.title"><source>Label</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.link"><source>Link</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.pending"><source>Target page not ready</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.pending.description"><source>Marks the entry while its page is still being built. What happens then is set on the plugin: fall back to the column link, mute it, or hide it.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.teaser"><source>Teaser line</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.teaser.description"><source>One short line below the label. Reaches the JSON from content stage 2 on.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.badge"><source>Badge</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.badge.description"><source>Short marker such as "New" or "Coming soon".</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.image"><source>Thumbnail</source></trans-unit>
<trans-unit id="tx_evomegamenujson_item.image.description"><source>Reaches the JSON from content stage 2 on.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser"><source>Teaser card</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.kicker"><source>Kicker</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.kicker.description"><source>Small label above the headline, for example the section or market.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.title"><source>Headline</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.teasertext"><source>Text</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.link"><source>Link</source></trans-unit>
<trans-unit id="tx_evomegamenujson_teaser.image"><source>Image</source></trans-unit>
<trans-unit id="flexform.sheet.content"><source>Content</source></trans-unit>
<trans-unit id="flexform.sheet.behaviour"><source>Behaviour</source></trans-unit>
<trans-unit id="flexform.stage"><source>Content stage</source></trans-unit>
<trans-unit id="flexform.stage.description"><source>How much detail the JSON carries. Later stages only add keys, so a front end built for stage 1 keeps working.</source></trans-unit>
<trans-unit id="flexform.stage.1"><source>1 - structure only (labels and links)</source></trans-unit>
<trans-unit id="flexform.stage.2"><source>2 - plus images, teaser lines and cards</source></trans-unit>
<trans-unit id="flexform.stage.3"><source>3 - plus the featured block</source></trans-unit>
<trans-unit id="flexform.pendingBehaviour"><source>Entries whose page is not ready</source></trans-unit>
<trans-unit id="flexform.pendingBehaviour.description"><source>What the front end does with entries marked "target page not ready".</source></trans-unit>
<trans-unit id="flexform.pendingBehaviour.fallback"><source>Link to the column heading instead</source></trans-unit>
<trans-unit id="flexform.pendingBehaviour.mute"><source>Show, but not clickable</source></trans-unit>
<trans-unit id="flexform.pendingBehaviour.hide"><source>Hide</source></trans-unit>
<trans-unit id="flexform.showCounts"><source>Show entry count</source></trans-unit>
<trans-unit id="flexform.showCounts.description"><source>The line at the foot of the panel, for example "39 entries in 8 columns".</source></trans-unit>
<trans-unit id="flexform.showViewAll"><source>Show "view all" link</source></trans-unit>
<trans-unit id="flexform.viewAllLabel"><source>Label for "view all"</source></trans-unit>
<trans-unit id="flexform.viewAllLabel.description"><source>%s is replaced by the entry label.</source></trans-unit>
<trans-unit id="flexform.openOn"><source>Panel opens on</source></trans-unit>
<trans-unit id="flexform.openOn.description"><source>Hover keeps the bar item clickable as a link; click turns it into a toggle.</source></trans-unit>
<trans-unit id="flexform.openOn.hover"><source>Hover (item stays a link)</source></trans-unit>
<trans-unit id="flexform.openOn.click"><source>Click</source></trans-unit>
<trans-unit id="flexform.closeDelay"><source>Close delay (ms)</source></trans-unit>
<trans-unit id="flexform.closeDelay.description"><source>Grace period when the pointer leaves the menu, so moving diagonally into a column does not close it.</source></trans-unit>
<trans-unit id="flexform.columnsPerRow"><source>Columns per row</source></trans-unit>
<trans-unit id="flexform.columnsPerRow.description"><source>How many columns the panel places side by side before wrapping.</source></trans-unit>
<trans-unit id="flexform.panelWidth"><source>Panel width</source></trans-unit>
<trans-unit id="flexform.panelWidth.container"><source>Content width</source></trans-unit>
<trans-unit id="flexform.panelWidth.full"><source>Full window width</source></trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M2 2h5v1.8H2z"/><path fill="#94A3B8" d="M2 5h5v1.4H2zM2 7.6h5V9H2zM2 10.2h5v1.4H2z"/><path fill="#CBD5E1" d="M9 2h5v9.6H9z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1 2.5h14V5H1z"/><path fill="#94A3B8" d="M1 6.5h14v7H1z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><circle cx="3.2" cy="8" r="1.6" fill="#1D4ED8"/><path fill="#94A3B8" d="M6 6.4h8v1.5H6zM6 9.1h5.5v1.3H6z"/></svg>

After

Width:  |  Height:  |  Size: 197 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1 2h14v2.2H1z"/><path fill="#64748B" d="M1 5.6h4.2v8.6H1zM6 5.6h4.2v8.6H6zM10.8 5.6H15v8.6h-4.2z"/></svg>

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1.5 2.5h13v6h-13z"/><path fill="#94A3B8" d="M1.5 10h13v1.4h-13zM1.5 12.3h8.5v1.3H1.5z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@@ -0,0 +1,24 @@
{
"name": "evomedien/evo-megamenu-json",
"type": "typo3-cms-extension",
"description": "Editable megamenu for headless TYPO3 - one plugin fills columns, teaser cards and a featured slot, delivered as JSON",
"version": "1.0.0",
"authors": [],
"license": "GPL-2.0-or-later",
"require": {
"typo3/cms-core": "^13.4 || ^14.0"
},
"suggest": {
"friendsoftypo3/headless": "Renders the plugin inside the headless JSON envelope and exposes the menu at page level"
},
"autoload": {
"psr-4": {
"Evomedien\\EvoMegamenuJson\\": "Classes"
}
},
"extra": {
"typo3/cms": {
"extension-key": "evo_megamenu_json"
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
$EM_CONF[$_EXTKEY] = [
'title' => 'EVO Megamenu JSON',
'description' => 'Editable megamenu for headless TYPO3: one plugin fills columns, teaser cards and a featured slot; the menu is delivered as JSON.',
'category' => 'plugin',
'author' => 'Evomedien',
'author_email' => '',
'state' => 'stable',
'clearCacheOnLoad' => 1,
'version' => '1.0.0',
'constraints' => [
'depends' => [
'typo3' => '13.4.0-14.9.99',
],
'conflicts' => [],
'suggests' => [
'headless' => '',
],
],
];

View File

@@ -0,0 +1,69 @@
#
# Megamenu entry - one item in the main navigation bar.
# Child of the "Megamenu" plugin element (tt_content).
#
CREATE TABLE tx_evomegamenujson_entry (
parentid int(11) DEFAULT '0' NOT NULL,
parenttable varchar(255) DEFAULT '' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
link varchar(1024) DEFAULT '' NOT NULL,
layout varchar(30) DEFAULT 'columns' NOT NULL,
menu_columns int(11) DEFAULT '0' NOT NULL,
menu_teasers int(11) DEFAULT '0' NOT NULL,
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
featured_kicker varchar(255) DEFAULT '' NOT NULL,
featured_title varchar(255) DEFAULT '' NOT NULL,
featured_text text,
featured_link varchar(1024) DEFAULT '' NOT NULL,
featured_image int(11) unsigned DEFAULT '0' NOT NULL,
KEY parent (parentid)
);
#
# Column inside an entry - a headline plus its list of items.
#
CREATE TABLE tx_evomegamenujson_column (
parentid int(11) DEFAULT '0' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
link varchar(1024) DEFAULT '' NOT NULL,
menu_items int(11) DEFAULT '0' NOT NULL,
KEY parent (parentid)
);
#
# Item inside a column - the actual link line.
#
CREATE TABLE tx_evomegamenujson_item (
parentid int(11) DEFAULT '0' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
link varchar(1024) DEFAULT '' NOT NULL,
teaser varchar(255) DEFAULT '' NOT NULL,
badge varchar(60) DEFAULT '' NOT NULL,
pending tinyint(1) unsigned DEFAULT '0' NOT NULL,
image int(11) unsigned DEFAULT '0' NOT NULL,
KEY parent (parentid)
);
#
# Teaser card inside an entry - used by the "teasers" layout.
#
CREATE TABLE tx_evomegamenujson_teaser (
parentid int(11) DEFAULT '0' NOT NULL,
kicker varchar(255) DEFAULT '' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
teasertext text,
link varchar(1024) DEFAULT '' NOT NULL,
image int(11) unsigned DEFAULT '0' NOT NULL,
KEY parent (parentid)
);
#
# Plugin element holds its entries inline.
#
CREATE TABLE tt_content (
tx_evomegamenujson_entries int(11) DEFAULT '0' NOT NULL
);