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:
@@ -18,6 +18,7 @@
|
|||||||
"apache-solr-for-typo3/solr": "^14.0@RC",
|
"apache-solr-for-typo3/solr": "^14.0@RC",
|
||||||
"b13/container": "^3.1",
|
"b13/container": "^3.1",
|
||||||
"b13/typo3-updater": "^1.1",
|
"b13/typo3-updater": "^1.1",
|
||||||
|
"evomedien/evo-megamenu-json": "^1.0.0",
|
||||||
"evomedien/vitec": "^1.0.1",
|
"evomedien/vitec": "^1.0.1",
|
||||||
"friendsoftypo3/content-blocks": "^2.4",
|
"friendsoftypo3/content-blocks": "^2.4",
|
||||||
"friendsoftypo3/headless": "^5.0@rc",
|
"friendsoftypo3/headless": "^5.0@rc",
|
||||||
|
|||||||
41
composer.lock
generated
41
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d902283b2e3364081e0daec494c39cef",
|
"content-hash": "9baf94c6d03b967820c531b235568ece",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "apache-solr-for-typo3/solr",
|
"name": "apache-solr-for-typo3/solr",
|
||||||
@@ -1424,6 +1424,39 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-08-12T10:13:48+00:00"
|
"time": "2025-08-12T10:13:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "evomedien/evo-megamenu-json",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dist": {
|
||||||
|
"type": "path",
|
||||||
|
"url": "./packages/evo_megamenu_json",
|
||||||
|
"reference": "d466495bad7206fe59926644544da125747b73c0"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"type": "typo3-cms-extension",
|
||||||
|
"extra": {
|
||||||
|
"typo3/cms": {
|
||||||
|
"extension-key": "evo_megamenu_json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Evomedien\\EvoMegamenuJson\\": "Classes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"GPL-2.0-or-later"
|
||||||
|
],
|
||||||
|
"description": "Editable megamenu for headless TYPO3 - one plugin fills columns, teaser cards and a featured slot, delivered as JSON",
|
||||||
|
"transport-options": {
|
||||||
|
"relative": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "evomedien/vitec",
|
"name": "evomedien/vitec",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -10233,10 +10266,10 @@
|
|||||||
},
|
},
|
||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {},
|
"platform": [],
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.4.0"
|
"php": "8.4.0"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ dependencies:
|
|||||||
- apache-solr-for-typo3/solr-open-search
|
- apache-solr-for-typo3/solr-open-search
|
||||||
- apache-solr-for-typo3/solr-stylesheets
|
- apache-solr-for-typo3/solr-stylesheets
|
||||||
- apache-solr-for-typo3/solr-bootstrap-css
|
- apache-solr-for-typo3/solr-bootstrap-css
|
||||||
|
- evomedien/megamenu
|
||||||
frontendBase: ''
|
frontendBase: ''
|
||||||
headless: 1
|
headless: 1
|
||||||
languages:
|
languages:
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ vitec.debugMode: true
|
|||||||
my.example.setting: Test
|
my.example.setting: Test
|
||||||
menu.footer.pageUids: '1,2,3'
|
menu.footer.pageUids: '1,2,3'
|
||||||
menu.meta.pageUids: '5'
|
menu.meta.pageUids: '5'
|
||||||
|
megamenu.contentUid: 10366
|
||||||
|
|||||||
51
migrations/check_megamenu_schema.php
Normal file
51
migrations/check_megamenu_schema.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: is the evo_megamenu_json schema in place?
|
||||||
|
* Checks the four menu tables and the inline column on tt_content.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/check_megamenu_schema.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
$tables = [
|
||||||
|
'tx_evomegamenujson_entry',
|
||||||
|
'tx_evomegamenujson_column',
|
||||||
|
'tx_evomegamenujson_item',
|
||||||
|
'tx_evomegamenujson_teaser',
|
||||||
|
];
|
||||||
|
$missing = 0;
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$exists = (bool)$pdo->query("SHOW TABLES LIKE " . $pdo->quote($table))->fetchColumn();
|
||||||
|
$count = $exists ? (int)$pdo->query("SELECT COUNT(*) FROM `$table`")->fetchColumn() : 0;
|
||||||
|
printf("%-32s %s%s\n", $table, $exists ? 'OK' : 'FEHLT', $exists ? " ($count Datensaetze)" : '');
|
||||||
|
$missing += $exists ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$col = $pdo->query("SHOW COLUMNS FROM tt_content LIKE 'tx_evomegamenujson_entries'")->fetch();
|
||||||
|
printf("%-32s %s\n", 'tt_content.tx_..._entries', $col ? 'OK' : 'FEHLT');
|
||||||
|
$missing += $col ? 0 : 1;
|
||||||
|
|
||||||
|
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND CType = 'evomegamenujson_megamenu'")->fetchColumn();
|
||||||
|
printf("%-32s %d\n", 'Megamenu-Elemente angelegt', $ce);
|
||||||
|
|
||||||
|
echo "\n" . ($missing === 0
|
||||||
|
? "Schema vollstaendig - Plugin kann angelegt werden.\n"
|
||||||
|
: "$missing Objekt(e) fehlen - Schema-Update noetig.\n");
|
||||||
69
migrations/check_typoscript_templates.php
Normal file
69
migrations/check_typoscript_templates.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: TypoScript template records and their static includes.
|
||||||
|
* A static include that pulls headless in AFTER the site sets resets the
|
||||||
|
* whole `page` object and silently drops page-level fields.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/check_typoscript_templates.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) { exit("no project root\n"); }
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '', $db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT uid, pid, title, root, clear, hidden, deleted, include_static_file, tstamp,
|
||||||
|
LENGTH(config) AS config_len, LENGTH(constants) AS const_len
|
||||||
|
FROM sys_template ORDER BY pid, sorting"
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
echo "=== sys_template ===\n";
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
printf("uid %d | pid %d | %s%s%s%s | geaendert %s\n",
|
||||||
|
$r['uid'], $r['pid'], $r['title'],
|
||||||
|
$r['root'] ? ' | ROOT' : '',
|
||||||
|
$r['hidden'] ? ' | HIDDEN' : '',
|
||||||
|
$r['deleted'] ? ' | DELETED' : '',
|
||||||
|
date('Y-m-d H:i', (int)$r['tstamp'])
|
||||||
|
);
|
||||||
|
printf(" clear=%s config=%d Zeichen constants=%d Zeichen\n", $r['clear'], $r['config_len'], $r['const_len']);
|
||||||
|
$inc = trim((string)$r['include_static_file']);
|
||||||
|
if ($inc !== '') {
|
||||||
|
echo " include_static_file:\n";
|
||||||
|
foreach (explode(',', $inc) as $i => $one) {
|
||||||
|
printf(" %2d. %s\n", $i + 1, trim($one));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " include_static_file: (leer)\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything in the config field that resets page?
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ((int)$r['config_len'] === 0) { continue; }
|
||||||
|
$cfg = $pdo->query("SELECT config FROM sys_template WHERE uid = " . (int)$r['uid'])->fetchColumn();
|
||||||
|
$hits = [];
|
||||||
|
foreach (preg_split('/\R/', (string)$cfg) as $n => $line) {
|
||||||
|
if (preg_match('/^\s*page\s*[<=]/', $line) || str_contains($line, 'lib.headlessPage')) {
|
||||||
|
$hits[] = ($n + 1) . ': ' . trim($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($hits !== []) {
|
||||||
|
echo "\n=== page-Zuweisungen im config von uid {$r['uid']} ===\n" . implode("\n", $hits) . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
303
packages/evo_megamenu_json/Classes/Service/MenuBuilder.php
Normal file
303
packages/evo_megamenu_json/Classes/Service/MenuBuilder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
169
packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml
Normal file
169
packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml
Normal 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>
|
||||||
12
packages/evo_megamenu_json/Configuration/Icons.php
Normal file
12
packages/evo_megamenu_json/Configuration/Icons.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
13
packages/evo_megamenu_json/Configuration/Services.yaml
Normal file
13
packages/evo_megamenu_json/Configuration/Services.yaml
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
';
|
||||||
|
})();
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
101
packages/evo_megamenu_json/README.md
Normal file
101
packages/evo_megamenu_json/README.md
Normal 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.
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
24
packages/evo_megamenu_json/composer.json
Normal file
24
packages/evo_megamenu_json/composer.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/evo_megamenu_json/ext_emconf.php
Normal file
21
packages/evo_megamenu_json/ext_emconf.php
Normal 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' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
69
packages/evo_megamenu_json/ext_tables.sql
Normal file
69
packages/evo_megamenu_json/ext_tables.sql
Normal 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
|
||||||
|
);
|
||||||
84
packages/vitec/Classes/Command/DebugSetsCommand.php
Normal file
84
packages/vitec/Classes/Command/DebugSetsCommand.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic: prints the site sets in the order TYPO3 actually resolves them.
|
||||||
|
*
|
||||||
|
* Set order decides who wins: friendsoftypo3/headless resets the whole `page`
|
||||||
|
* object (`page < lib.headlessPage`), so every set that adds page-level
|
||||||
|
* fields has to load AFTER it. When headless ends up last, those fields
|
||||||
|
* vanish without any error - which is exactly the failure this command was
|
||||||
|
* written for (2026-09-11).
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:debug-sets
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:debug-sets',
|
||||||
|
description: 'Print the resolved site set order (diagnoses page-level TypoScript being reset)'
|
||||||
|
)]
|
||||||
|
final class DebugSetsCommand extends Command
|
||||||
|
{
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('site', null, InputOption::VALUE_REQUIRED, 'Site identifier', 'vitec');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$identifier = (string)$input->getOption('site');
|
||||||
|
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByIdentifier($identifier);
|
||||||
|
|
||||||
|
if (!method_exists($site, 'getSets')) {
|
||||||
|
$output->writeln('<error>This TYPO3 version does not expose Site::getSets().</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets = $site->getSets();
|
||||||
|
$output->writeln(sprintf('Site "%s": %d sets, in load order', $identifier, count($sets)));
|
||||||
|
|
||||||
|
$position = 0;
|
||||||
|
$headlessAt = null;
|
||||||
|
$vitecAt = null;
|
||||||
|
foreach ($sets as $set) {
|
||||||
|
$position++;
|
||||||
|
$name = is_object($set) && property_exists($set, 'name') ? (string)$set->name : (string)$set;
|
||||||
|
if ($name === 'friendsoftypo3/headless') {
|
||||||
|
$headlessAt = $position;
|
||||||
|
}
|
||||||
|
if ($name === 'evomedien/vitecset') {
|
||||||
|
$vitecAt = $position;
|
||||||
|
}
|
||||||
|
$mark = in_array($name, ['friendsoftypo3/headless', 'evomedien/vitecset'], true) ? ' <-- ' : ' ';
|
||||||
|
$output->writeln(sprintf('%2d.%s%s', $position, $mark, $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('');
|
||||||
|
if ($headlessAt === null || $vitecAt === null) {
|
||||||
|
$output->writeln('<comment>headless or vitecset is not in the resolved list at all.</comment>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($headlessAt > $vitecAt) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'<error>PROBLEM: headless loads at %d, AFTER vitecset at %d - it resets `page` and drops the page-level fields.</error>',
|
||||||
|
$headlessAt, $vitecAt
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'<info>Order is fine: headless at %d, vitecset at %d.</info>',
|
||||||
|
$headlessAt, $vitecAt
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
523
packages/vitec/Classes/Command/SeedMegamenuCommand.php
Normal file
523
packages/vitec/Classes/Command/SeedMegamenuCommand.php
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills one "Megamenu" element (EXT:evo_megamenu_json) with the site's real
|
||||||
|
* structure, so the editors start from a complete menu instead of a blank
|
||||||
|
* element - roughly 5 entries, 25 columns and 90 items that would otherwise
|
||||||
|
* be clicked together by hand.
|
||||||
|
*
|
||||||
|
* This is a PROJECT-side seed, deliberately not part of the neutral
|
||||||
|
* extension: it knows where VITEC keeps its structure.
|
||||||
|
*
|
||||||
|
* Products columns = level-1 product categories, items = level-2
|
||||||
|
* (the family pages do not exist yet, so every item
|
||||||
|
* is marked `pending` and the front end falls back to
|
||||||
|
* the column link)
|
||||||
|
* Solutions columns = the 8 group categories, items = the 39 solution
|
||||||
|
* records, linked through their `detail_page`
|
||||||
|
* Markets columns = level-1 market categories, items = level-2; linked
|
||||||
|
* through the market record's `detail_page` where one
|
||||||
|
* exists, `pending` otherwise
|
||||||
|
* Insights one column of section links (pages still to be built)
|
||||||
|
* Stories teaser layout, four usecase records with their card image
|
||||||
|
*
|
||||||
|
* Column links are resolved against the page tree by title, so a renamed
|
||||||
|
* page is reported rather than silently linked to nothing. Run it once;
|
||||||
|
* everything after that is editorial work in the backend.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:seed-megamenu --pid=42 --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:seed-megamenu --pid=42
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:seed-megamenu',
|
||||||
|
description: 'Create a Megamenu element filled with the live product, solution and market structure'
|
||||||
|
)]
|
||||||
|
final class SeedMegamenuCommand extends Command
|
||||||
|
{
|
||||||
|
private const CTYPE = 'evomegamenujson_megamenu';
|
||||||
|
private const T_ENTRY = 'tx_evomegamenujson_entry';
|
||||||
|
private const T_COLUMN = 'tx_evomegamenujson_column';
|
||||||
|
private const T_ITEM = 'tx_evomegamenujson_item';
|
||||||
|
private const T_TEASER = 'tx_evomegamenujson_teaser';
|
||||||
|
|
||||||
|
private int $newId = 0;
|
||||||
|
/** @var array<string,array<string,mixed>> */
|
||||||
|
private array $datamap = [];
|
||||||
|
/** @var string[] */
|
||||||
|
private array $warnings = [];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage page/folder for the element and its records');
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||||
|
$this->addOption('force', null, InputOption::VALUE_NONE, 'Seed even though a megamenu element already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
|
||||||
|
$existing = $connection->fetchAssociative(
|
||||||
|
"SELECT uid, pid FROM tt_content WHERE deleted = 0 AND CType = ? ORDER BY uid LIMIT 1",
|
||||||
|
[self::CTYPE]
|
||||||
|
);
|
||||||
|
if ($existing !== false && !$input->getOption('force')) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'<comment>A megamenu element already exists (uid %d on pid %d). Edit it in the backend, or re-run with --force to add a second one.</comment>',
|
||||||
|
$existing['uid'], $existing['pid']
|
||||||
|
));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int)($input->getOption('pid') ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
$pid = (int)($existing['pid'] ?? 0);
|
||||||
|
}
|
||||||
|
if ($pid <= 0) {
|
||||||
|
$output->writeln('<error>No storage page given - pass --pid=<uid of a sysfolder or page>.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentId = $this->id();
|
||||||
|
$entryIds = [];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
$this->buildProducts($pid, $output),
|
||||||
|
$this->buildSolutions($pid, $output),
|
||||||
|
$this->buildMarkets($pid, $output),
|
||||||
|
$this->buildInsights($pid, $output),
|
||||||
|
$this->buildStories($pid, $output),
|
||||||
|
] as $entryId) {
|
||||||
|
if ($entryId !== null) {
|
||||||
|
$entryIds[] = $entryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entryIds === []) {
|
||||||
|
$output->writeln('<error>Nothing could be built - is the structure in place?</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->datamap['tt_content'][$contentId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'CType' => self::CTYPE,
|
||||||
|
'header' => 'Main navigation',
|
||||||
|
'tx_evomegamenujson_entries' => implode(',', $entryIds),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->warnings as $warning) {
|
||||||
|
$output->writeln('<comment>' . $warning . '</comment>');
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf("\n%d entries, %d columns, %d items, %d teaser cards on pid %d.",
|
||||||
|
count($entryIds),
|
||||||
|
count($this->datamap[self::T_COLUMN] ?? []),
|
||||||
|
count($this->datamap[self::T_ITEM] ?? []),
|
||||||
|
count($this->datamap[self::T_TEASER] ?? []),
|
||||||
|
$pid
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln('DRY RUN - nothing written.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($this->datamap, []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
foreach ($dataHandler->errorLog as $error) {
|
||||||
|
$output->writeln('<error>' . $error . '</error>');
|
||||||
|
}
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uid = (int)($dataHandler->substNEWwithIDs[$contentId] ?? 0);
|
||||||
|
$output->writeln(sprintf('Megamenu element created: uid %d.', $uid));
|
||||||
|
$output->writeln(sprintf('Point the site setting megamenu.contentUid at %d, then: vendor/bin/typo3 cache:flush', $uid));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- entries
|
||||||
|
|
||||||
|
private function buildProducts(int $pid, OutputInterface $output): ?string
|
||||||
|
{
|
||||||
|
$root = $this->pageBySlug('/products');
|
||||||
|
$categories = $this->categoryTree('Product');
|
||||||
|
if ($categories === []) {
|
||||||
|
$this->warnings[] = 'Products skipped: no category tree below "Product".';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||||
|
|
||||||
|
$columnIds = [];
|
||||||
|
foreach ($categories as $group) {
|
||||||
|
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||||
|
if ($page === null) {
|
||||||
|
$this->warnings[] = 'Products: no page found for category "' . $group['title'] . '".';
|
||||||
|
}
|
||||||
|
$itemIds = [];
|
||||||
|
foreach ($group['children'] as $family) {
|
||||||
|
// The family pages are still to be built (decision 2026-09-04:
|
||||||
|
// product families are content pages) - mark them pending so
|
||||||
|
// the front end falls back to the category link.
|
||||||
|
$itemIds[] = $this->item($pid, $family['title'], '', true);
|
||||||
|
}
|
||||||
|
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Products: %d columns, %d items (all pending - family pages missing)',
|
||||||
|
count($columnIds), count($this->datamap[self::T_ITEM] ?? [])));
|
||||||
|
|
||||||
|
return $this->entry($pid, 'Products', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSolutions(int $pid, OutputInterface $output): ?string
|
||||||
|
{
|
||||||
|
$root = $this->pageBySlug('/solutions');
|
||||||
|
$categories = $this->categoryTree('Solution');
|
||||||
|
if ($categories === []) {
|
||||||
|
$this->warnings[] = 'Solutions skipped: no category tree below "Solution".';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||||
|
$byCategory = $this->recordsByCategory('tx_vitec_domain_model_solution');
|
||||||
|
|
||||||
|
$columnIds = [];
|
||||||
|
$itemCount = 0;
|
||||||
|
foreach ($categories as $group) {
|
||||||
|
$records = $byCategory[(int)$group['uid']] ?? [];
|
||||||
|
if ($records === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||||
|
$itemIds = [];
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$detail = (int)$record['detail_page'];
|
||||||
|
$itemIds[] = $this->item($pid, (string)$record['title'],
|
||||||
|
$detail > 0 ? $this->pageLink($detail) : '', $detail === 0);
|
||||||
|
$itemCount++;
|
||||||
|
}
|
||||||
|
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Solutions: %d columns, %d items', count($columnIds), $itemCount));
|
||||||
|
|
||||||
|
return $this->entry($pid, 'Solutions', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildMarkets(int $pid, OutputInterface $output): ?string
|
||||||
|
{
|
||||||
|
$root = $this->pageBySlug('/markets');
|
||||||
|
$categories = $this->categoryTree('Market');
|
||||||
|
if ($categories === []) {
|
||||||
|
$this->warnings[] = 'Markets skipped: no category tree below "Market".';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||||
|
$detailByTitle = $this->detailPageByTitle('tx_vitec_domain_model_market');
|
||||||
|
|
||||||
|
$columnIds = [];
|
||||||
|
$pending = 0;
|
||||||
|
$itemCount = 0;
|
||||||
|
foreach ($categories as $group) {
|
||||||
|
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||||
|
$itemIds = [];
|
||||||
|
foreach ($group['children'] as $market) {
|
||||||
|
$detail = (int)($detailByTitle[$this->norm($market['title'])] ?? 0);
|
||||||
|
$itemIds[] = $this->item($pid, $market['title'],
|
||||||
|
$detail > 0 ? $this->pageLink($detail) : '', $detail === 0);
|
||||||
|
$pending += $detail === 0 ? 1 : 0;
|
||||||
|
$itemCount++;
|
||||||
|
}
|
||||||
|
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Markets: %d columns, %d items (%d pending - no detail page)',
|
||||||
|
count($columnIds), $itemCount, $pending));
|
||||||
|
|
||||||
|
return $this->entry($pid, 'Markets', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildInsights(int $pid, OutputInterface $output): ?string
|
||||||
|
{
|
||||||
|
$root = $this->pageBySlug('/insights');
|
||||||
|
if ($root === null) {
|
||||||
|
$this->warnings[] = 'Insights skipped: no page /insights.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$pages = $this->childPagesByTitle((int)$root['uid']);
|
||||||
|
$sections = ['News & Articles', 'Events & Webinars', 'Blog'];
|
||||||
|
|
||||||
|
$itemIds = [];
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$page = $pages[$this->norm($section)] ?? null;
|
||||||
|
$itemIds[] = $this->item($pid, $section, $page ? $this->pageLink((int)$page['uid']) : '', $page === null);
|
||||||
|
}
|
||||||
|
$columnId = $this->column($pid, 'Sections', $this->pageLink((int)$root['uid']), $itemIds);
|
||||||
|
$output->writeln('Insights: 1 column, ' . count($itemIds) . ' section links (teaser cards stay editorial)');
|
||||||
|
|
||||||
|
return $this->entry($pid, 'Insights', $this->pageLink((int)$root['uid']), 'columns', [$columnId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStories(int $pid, OutputInterface $output): ?string
|
||||||
|
{
|
||||||
|
$root = $this->pageBySlug('/success-stories');
|
||||||
|
if ($root === null) {
|
||||||
|
$this->warnings[] = 'Success Stories skipped: no page /success-stories.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_usecase');
|
||||||
|
$stories = $connection->fetchAllAssociative(
|
||||||
|
"SELECT uid, title, teaser, slug, detail_page FROM tx_vitec_domain_model_usecase
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND slug <> ''
|
||||||
|
ORDER BY tstamp DESC LIMIT 4"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stories have no `detail_page` of their own - one detail page serves
|
||||||
|
// them all and SuccessStoryPathRewrite builds /success-stories/<slug>.
|
||||||
|
// The seed writes that path, the same URL the list plugin emits.
|
||||||
|
$basePath = '/' . trim((string)$root['slug'], '/');
|
||||||
|
|
||||||
|
$teaserIds = [];
|
||||||
|
$withoutLink = 0;
|
||||||
|
foreach ($stories as $story) {
|
||||||
|
$detail = (int)$story['detail_page'];
|
||||||
|
$slug = trim((string)$story['slug'], '/');
|
||||||
|
$link = $detail > 0
|
||||||
|
? $this->pageLink($detail)
|
||||||
|
: ($slug !== '' ? $basePath . '/' . $slug : '');
|
||||||
|
$withoutLink += $link === '' ? 1 : 0;
|
||||||
|
$teaserIds[] = $this->teaser(
|
||||||
|
$pid,
|
||||||
|
'Success Story',
|
||||||
|
(string)$story['title'],
|
||||||
|
$this->shorten((string)($story['teaser'] ?? '')),
|
||||||
|
$link,
|
||||||
|
$this->fileOf('tx_vitec_domain_model_usecase', 'card_image', (int)$story['uid'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($withoutLink > 0) {
|
||||||
|
$this->warnings[] = 'Success Stories: ' . $withoutLink . ' card(s) without link - story has neither detail page nor slug.';
|
||||||
|
}
|
||||||
|
$output->writeln('Success Stories: teaser layout, ' . count($teaserIds) . ' cards');
|
||||||
|
|
||||||
|
return $this->entry($pid, 'Success Stories', $this->pageLink((int)$root['uid']), 'teasers', [], $teaserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------- record makers
|
||||||
|
|
||||||
|
/** @param string[] $columnIds @param string[] $teaserIds */
|
||||||
|
private function entry(int $pid, string $title, string $link, string $layout, array $columnIds, array $teaserIds = []): string
|
||||||
|
{
|
||||||
|
$id = $this->id();
|
||||||
|
$record = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $title,
|
||||||
|
'link' => $link,
|
||||||
|
'layout' => $layout,
|
||||||
|
];
|
||||||
|
if ($columnIds !== []) {
|
||||||
|
$record['menu_columns'] = implode(',', $columnIds);
|
||||||
|
}
|
||||||
|
if ($teaserIds !== []) {
|
||||||
|
$record['menu_teasers'] = implode(',', $teaserIds);
|
||||||
|
}
|
||||||
|
$this->datamap[self::T_ENTRY][$id] = $record;
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param string[] $itemIds */
|
||||||
|
private function column(int $pid, string $title, string $link, array $itemIds): string
|
||||||
|
{
|
||||||
|
$id = $this->id();
|
||||||
|
$this->datamap[self::T_COLUMN][$id] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $title,
|
||||||
|
'link' => $link,
|
||||||
|
'menu_items' => implode(',', $itemIds),
|
||||||
|
];
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function item(int $pid, string $title, string $link, bool $pending): string
|
||||||
|
{
|
||||||
|
$id = $this->id();
|
||||||
|
$this->datamap[self::T_ITEM][$id] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $title,
|
||||||
|
'link' => $link,
|
||||||
|
'pending' => $pending ? 1 : 0,
|
||||||
|
];
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function teaser(int $pid, string $kicker, string $title, string $text, string $link, int $fileUid): string
|
||||||
|
{
|
||||||
|
$id = $this->id();
|
||||||
|
$record = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'kicker' => $kicker,
|
||||||
|
'title' => $title,
|
||||||
|
'teasertext' => $text,
|
||||||
|
'link' => $link,
|
||||||
|
];
|
||||||
|
if ($fileUid > 0) {
|
||||||
|
$referenceId = $this->id();
|
||||||
|
$this->datamap['sys_file_reference'][$referenceId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'table_local' => 'sys_file',
|
||||||
|
'uid_local' => $fileUid,
|
||||||
|
'tablenames' => self::T_TEASER,
|
||||||
|
'fieldname' => 'image',
|
||||||
|
'uid_foreign' => $id,
|
||||||
|
];
|
||||||
|
$record['image'] = $referenceId;
|
||||||
|
}
|
||||||
|
$this->datamap[self::T_TEASER][$id] = $record;
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function id(): string
|
||||||
|
{
|
||||||
|
return 'NEW' . str_pad((string)++$this->newId, 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ lookups
|
||||||
|
|
||||||
|
/** @return array<int,array{uid:int,title:string,children:array<int,array{uid:int,title:string}>}> */
|
||||||
|
private function categoryTree(string $rootTitle): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
|
||||||
|
$rootUid = (int)$connection->fetchOne(
|
||||||
|
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = ?', [$rootTitle]
|
||||||
|
);
|
||||||
|
if ($rootUid <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$tree = [];
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [$rootUid]
|
||||||
|
) as $group) {
|
||||||
|
$children = [];
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [(int)$group['uid']]
|
||||||
|
) as $child) {
|
||||||
|
$children[] = ['uid' => (int)$child['uid'], 'title' => (string)$child['title']];
|
||||||
|
}
|
||||||
|
$tree[] = ['uid' => (int)$group['uid'], 'title' => (string)$group['title'], 'children' => $children];
|
||||||
|
}
|
||||||
|
return $tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records of a table grouped by the category they hang on, sorted by
|
||||||
|
* their position in the category (uid order = creation order).
|
||||||
|
*
|
||||||
|
* @return array<int,array<int,array<string,mixed>>>
|
||||||
|
*/
|
||||||
|
private function recordsByCategory(string $table): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||||
|
$rows = $connection->fetchAllAssociative(
|
||||||
|
"SELECT r.uid, r.title, r.detail_page, mm.uid_local AS category
|
||||||
|
FROM $table r
|
||||||
|
JOIN sys_category_record_mm mm ON mm.uid_foreign = r.uid AND mm.tablenames = ? AND mm.fieldname = 'categories'
|
||||||
|
WHERE r.deleted = 0 AND r.hidden = 0
|
||||||
|
ORDER BY r.uid",
|
||||||
|
[$table]
|
||||||
|
);
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$grouped[(int)$row['category']][] = $row;
|
||||||
|
}
|
||||||
|
return $grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,int> normalized record title => detail_page uid */
|
||||||
|
private function detailPageByTitle(string $table): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||||
|
$map = [];
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
"SELECT title, detail_page FROM $table WHERE deleted = 0 AND hidden = 0"
|
||||||
|
) as $row) {
|
||||||
|
$detail = (int)$row['detail_page'];
|
||||||
|
if ($detail > 0) {
|
||||||
|
$map[$this->norm((string)$row['title'])] = $detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function pageBySlug(string $slug): ?array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$row = $connection->fetchAssociative(
|
||||||
|
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid LIMIT 1',
|
||||||
|
[$slug]
|
||||||
|
);
|
||||||
|
if ($row === false) {
|
||||||
|
$this->warnings[] = 'No page with slug ' . $slug . '.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,array<string,mixed>> normalized title => page row */
|
||||||
|
private function childPagesByTitle(int $parentUid): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$map = [];
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND doktype = 1 AND sys_language_uid IN (0,-1) ORDER BY sorting',
|
||||||
|
[$parentUid]
|
||||||
|
) as $row) {
|
||||||
|
$map[$this->norm((string)$row['title'])] = $row;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** uid of the first file behind a FAL field, 0 when none. */
|
||||||
|
private function fileOf(string $table, string $field, int $uid): int
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
|
||||||
|
$fileUid = $connection->fetchOne(
|
||||||
|
"SELECT uid_local FROM sys_file_reference
|
||||||
|
WHERE deleted = 0 AND tablenames = ? AND fieldname = ? AND uid_foreign = ?
|
||||||
|
ORDER BY sorting_foreign LIMIT 1",
|
||||||
|
[$table, $field, $uid]
|
||||||
|
);
|
||||||
|
return is_numeric($fileUid) ? (int)$fileUid : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pageLink(int $pageUid): string
|
||||||
|
{
|
||||||
|
return 't3://page?uid=' . $pageUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shorten(string $text, int $max = 120): string
|
||||||
|
{
|
||||||
|
$flat = trim((string)preg_replace('/\s+/u', ' ', strip_tags($text)));
|
||||||
|
return mb_strlen($flat) > $max ? mb_substr($flat, 0, $max - 1) . '…' : $flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', mb_strtolower($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user