From 5bb4e374b478110e7630aba2bb514044fbb5318a Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 11 Sep 2026 11:44:20 +0200 Subject: [PATCH] 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 --- composer.json | 1 + composer.lock | 41 +- config/sites/vitec/config.yaml | 1 + config/sites/vitec/settings.yaml | 1 + migrations/check_megamenu_schema.php | 51 ++ migrations/check_typoscript_templates.php | 69 +++ .../Classes/Service/MenuBuilder.php | 303 ++++++++++ .../Classes/UserFunc/MegamenuJsonRenderer.php | 90 +++ .../Configuration/FlexForms/Megamenu.xml | 169 ++++++ .../evo_megamenu_json/Configuration/Icons.php | 12 + .../Configuration/Services.yaml | 13 + .../Configuration/Sets/Megamenu/config.yaml | 9 + .../Sets/Megamenu/settings.definitions.yaml | 19 + .../Sets/Megamenu/setup.typoscript | 41 ++ .../TCA/Overrides/tt_content.php | 64 +++ .../TCA/tx_evomegamenujson_column.php | 85 +++ .../TCA/tx_evomegamenujson_entry.php | 184 ++++++ .../TCA/tx_evomegamenujson_item.php | 94 ++++ .../TCA/tx_evomegamenujson_teaser.php | 85 +++ packages/evo_megamenu_json/README.md | 101 ++++ .../Private/Language/locallang_db.xlf | 96 ++++ .../Resources/Public/Icons/column.svg | 1 + .../Resources/Public/Icons/entry.svg | 1 + .../Resources/Public/Icons/item.svg | 1 + .../Resources/Public/Icons/plugin.svg | 1 + .../Resources/Public/Icons/teaser.svg | 1 + packages/evo_megamenu_json/composer.json | 24 + packages/evo_megamenu_json/ext_emconf.php | 21 + packages/evo_megamenu_json/ext_tables.sql | 69 +++ .../Classes/Command/DebugSetsCommand.php | 84 +++ .../Classes/Command/SeedMegamenuCommand.php | 523 ++++++++++++++++++ 31 files changed, 2251 insertions(+), 4 deletions(-) create mode 100644 migrations/check_megamenu_schema.php create mode 100644 migrations/check_typoscript_templates.php create mode 100644 packages/evo_megamenu_json/Classes/Service/MenuBuilder.php create mode 100644 packages/evo_megamenu_json/Classes/UserFunc/MegamenuJsonRenderer.php create mode 100644 packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml create mode 100644 packages/evo_megamenu_json/Configuration/Icons.php create mode 100644 packages/evo_megamenu_json/Configuration/Services.yaml create mode 100644 packages/evo_megamenu_json/Configuration/Sets/Megamenu/config.yaml create mode 100644 packages/evo_megamenu_json/Configuration/Sets/Megamenu/settings.definitions.yaml create mode 100644 packages/evo_megamenu_json/Configuration/Sets/Megamenu/setup.typoscript create mode 100644 packages/evo_megamenu_json/Configuration/TCA/Overrides/tt_content.php create mode 100644 packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_column.php create mode 100644 packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php create mode 100644 packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_item.php create mode 100644 packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_teaser.php create mode 100644 packages/evo_megamenu_json/README.md create mode 100644 packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf create mode 100644 packages/evo_megamenu_json/Resources/Public/Icons/column.svg create mode 100644 packages/evo_megamenu_json/Resources/Public/Icons/entry.svg create mode 100644 packages/evo_megamenu_json/Resources/Public/Icons/item.svg create mode 100644 packages/evo_megamenu_json/Resources/Public/Icons/plugin.svg create mode 100644 packages/evo_megamenu_json/Resources/Public/Icons/teaser.svg create mode 100644 packages/evo_megamenu_json/composer.json create mode 100644 packages/evo_megamenu_json/ext_emconf.php create mode 100644 packages/evo_megamenu_json/ext_tables.sql create mode 100644 packages/vitec/Classes/Command/DebugSetsCommand.php create mode 100644 packages/vitec/Classes/Command/SeedMegamenuCommand.php diff --git a/composer.json b/composer.json index 7bf35a4..c3abb01 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "apache-solr-for-typo3/solr": "^14.0@RC", "b13/container": "^3.1", "b13/typo3-updater": "^1.1", + "evomedien/evo-megamenu-json": "^1.0.0", "evomedien/vitec": "^1.0.1", "friendsoftypo3/content-blocks": "^2.4", "friendsoftypo3/headless": "^5.0@rc", diff --git a/composer.lock b/composer.lock index b7c907d..96a09ff 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d902283b2e3364081e0daec494c39cef", + "content-hash": "9baf94c6d03b967820c531b235568ece", "packages": [ { "name": "apache-solr-for-typo3/solr", @@ -1424,6 +1424,39 @@ }, "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", "version": "1.0.1", @@ -10233,10 +10266,10 @@ }, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, + "platform": [], + "platform-dev": [], "platform-overrides": { "php": "8.4.0" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.3.0" } diff --git a/config/sites/vitec/config.yaml b/config/sites/vitec/config.yaml index f67b787..3a657c9 100644 --- a/config/sites/vitec/config.yaml +++ b/config/sites/vitec/config.yaml @@ -24,6 +24,7 @@ dependencies: - apache-solr-for-typo3/solr-open-search - apache-solr-for-typo3/solr-stylesheets - apache-solr-for-typo3/solr-bootstrap-css + - evomedien/megamenu frontendBase: '' headless: 1 languages: diff --git a/config/sites/vitec/settings.yaml b/config/sites/vitec/settings.yaml index 434c564..74c2daa 100644 --- a/config/sites/vitec/settings.yaml +++ b/config/sites/vitec/settings.yaml @@ -2,3 +2,4 @@ vitec.debugMode: true my.example.setting: Test menu.footer.pageUids: '1,2,3' menu.meta.pageUids: '5' +megamenu.contentUid: 10366 diff --git a/migrations/check_megamenu_schema.php b/migrations/check_megamenu_schema.php new file mode 100644 index 0000000..d1f7d29 --- /dev/null +++ b/migrations/check_megamenu_schema.php @@ -0,0 +1,51 @@ + 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"); diff --git a/migrations/check_typoscript_templates.php b/migrations/check_typoscript_templates.php new file mode 100644 index 0000000..9204cf4 --- /dev/null +++ b/migrations/check_typoscript_templates.php @@ -0,0 +1,69 @@ + 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"; + } +} diff --git a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php new file mode 100644 index 0000000..02fa6d0 --- /dev/null +++ b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php @@ -0,0 +1,303 @@ + 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|null $contentRow pass the row when it is already at hand + * @return array{settings:array,entries:array>} + */ + 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 $entry + * @return array + */ + 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 $item + * @return array + */ + 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|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> + */ + 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 $row + * @return array|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 $row + * @return array + */ + 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|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; + } + } +} diff --git a/packages/evo_megamenu_json/Classes/UserFunc/MegamenuJsonRenderer.php b/packages/evo_megamenu_json/Classes/UserFunc/MegamenuJsonRenderer.php new file mode 100644 index 0000000..c680d13 --- /dev/null +++ b/packages/evo_megamenu_json/Classes/UserFunc/MegamenuJsonRenderer.php @@ -0,0 +1,90 @@ +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 $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; + } +} diff --git a/packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml b/packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml new file mode 100644 index 0000000..b6970a7 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml @@ -0,0 +1,169 @@ + + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.content + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.description + + select + selectSingle + 3 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.description + + select + selectSingle + fallback + + + + fallback + + + + mute + + + + hide + + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showCounts.description + + check + checkboxToggle + 1 + + + + + + + check + checkboxToggle + 1 + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.viewAllLabel.description + + input + 30 + View all %s + + + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.behaviour + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.description + + select + selectSingle + hover + + + + hover + + + + click + + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.closeDelay.description + + number + 8 + 140 + + 0 + 1000 + + + + + + + LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.columnsPerRow.description + + number + 8 + 4 + + 2 + 6 + + + + + + + + select + selectSingle + container + + + + container + + + + full + + + + + + + + + + diff --git a/packages/evo_megamenu_json/Configuration/Icons.php b/packages/evo_megamenu_json/Configuration/Icons.php new file mode 100644 index 0000000..1cab887 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/Icons.php @@ -0,0 +1,12 @@ + [ + 'provider' => SvgIconProvider::class, + 'source' => 'EXT:evo_megamenu_json/Resources/Public/Icons/plugin.svg', + ], +]; diff --git a/packages/evo_megamenu_json/Configuration/Services.yaml b/packages/evo_megamenu_json/Configuration/Services.yaml new file mode 100644 index 0000000..97c2e43 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/Services.yaml @@ -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 diff --git a/packages/evo_megamenu_json/Configuration/Sets/Megamenu/config.yaml b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/config.yaml new file mode 100644 index 0000000..891561d --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/config.yaml @@ -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 diff --git a/packages/evo_megamenu_json/Configuration/Sets/Megamenu/settings.definitions.yaml b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/settings.definitions.yaml new file mode 100644 index 0000000..94db1d4 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/settings.definitions.yaml @@ -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 diff --git a/packages/evo_megamenu_json/Configuration/Sets/Megamenu/setup.typoscript b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/setup.typoscript new file mode 100644 index 0000000..dbb7aaf --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/Sets/Megamenu/setup.typoscript @@ -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} +} diff --git a/packages/evo_megamenu_json/Configuration/TCA/Overrides/tt_content.php b/packages/evo_megamenu_json/Configuration/TCA/Overrides/tt_content.php new file mode 100644 index 0000000..09a8597 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/TCA/Overrides/tt_content.php @@ -0,0 +1,64 @@ + $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, + '; +})(); diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_column.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_column.php new file mode 100644 index 0000000..914689f --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_column.php @@ -0,0 +1,85 @@ + [ + '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', + ], + ], + ], + ], +]; diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php new file mode 100644 index 0000000..4901995 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php @@ -0,0 +1,184 @@ + [ + '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', + ], + ], + ], +]; diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_item.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_item.php new file mode 100644 index 0000000..386ba87 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_item.php @@ -0,0 +1,94 @@ + [ + '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', + ], + ], + ], +]; diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_teaser.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_teaser.php new file mode 100644 index 0000000..6c4f1d5 --- /dev/null +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_teaser.php @@ -0,0 +1,85 @@ + [ + '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', + ], + ], + ], +]; diff --git a/packages/evo_megamenu_json/README.md b/packages/evo_megamenu_json/README.md new file mode 100644 index 0000000..5af77c2 --- /dev/null +++ b/packages/evo_megamenu_json/README.md @@ -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. diff --git a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000..e0a51bf --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,96 @@ + + + +
+ + + Megamenu + One editable megamenu: entries in the main bar, each opening a panel of columns or teaser cards. Published as JSON. + + Menu entries + The items in the main navigation bar, left to right. + Entries + + Menu entry + General + Columns + Teaser cards + Featured + Label + Shown in the main bar. + Target page + The bar item stays a real link - the panel opens on hover, a click goes to this page. + Panel layout + Columns for a structure menu, teaser cards for editorial sections such as news or stories. + Columns + Link column plus teaser cards + Columns + One column per group. In the teaser layout only the first column is rendered, next to the cards. + Teaser cards + Shown from content stage 2 on. + Show featured block + A highlighted block at the right edge of the panel. Reaches the JSON from content stage 3 on. + Kicker + Headline + Text + Link + Image + + Column + Column heading + Heading link + Optional - makes the column heading clickable, usually the group overview page. + Entries + + Menu item + Label + Link + Target page not ready + 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. + Teaser line + One short line below the label. Reaches the JSON from content stage 2 on. + Badge + Short marker such as "New" or "Coming soon". + Thumbnail + Reaches the JSON from content stage 2 on. + + Teaser card + Kicker + Small label above the headline, for example the section or market. + Headline + Text + Link + Image + + Content + Behaviour + Content stage + How much detail the JSON carries. Later stages only add keys, so a front end built for stage 1 keeps working. + 1 - structure only (labels and links) + 2 - plus images, teaser lines and cards + 3 - plus the featured block + Entries whose page is not ready + What the front end does with entries marked "target page not ready". + Link to the column heading instead + Show, but not clickable + Hide + Show entry count + The line at the foot of the panel, for example "39 entries in 8 columns". + Show "view all" link + Label for "view all" + %s is replaced by the entry label. + Panel opens on + Hover keeps the bar item clickable as a link; click turns it into a toggle. + Hover (item stays a link) + Click + Close delay (ms) + Grace period when the pointer leaves the menu, so moving diagonally into a column does not close it. + Columns per row + How many columns the panel places side by side before wrapping. + Panel width + Content width + Full window width + + + + diff --git a/packages/evo_megamenu_json/Resources/Public/Icons/column.svg b/packages/evo_megamenu_json/Resources/Public/Icons/column.svg new file mode 100644 index 0000000..fd3b44d --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Public/Icons/column.svg @@ -0,0 +1 @@ + diff --git a/packages/evo_megamenu_json/Resources/Public/Icons/entry.svg b/packages/evo_megamenu_json/Resources/Public/Icons/entry.svg new file mode 100644 index 0000000..bbfe92a --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Public/Icons/entry.svg @@ -0,0 +1 @@ + diff --git a/packages/evo_megamenu_json/Resources/Public/Icons/item.svg b/packages/evo_megamenu_json/Resources/Public/Icons/item.svg new file mode 100644 index 0000000..94dc051 --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Public/Icons/item.svg @@ -0,0 +1 @@ + diff --git a/packages/evo_megamenu_json/Resources/Public/Icons/plugin.svg b/packages/evo_megamenu_json/Resources/Public/Icons/plugin.svg new file mode 100644 index 0000000..0904bf0 --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Public/Icons/plugin.svg @@ -0,0 +1 @@ + diff --git a/packages/evo_megamenu_json/Resources/Public/Icons/teaser.svg b/packages/evo_megamenu_json/Resources/Public/Icons/teaser.svg new file mode 100644 index 0000000..47fb069 --- /dev/null +++ b/packages/evo_megamenu_json/Resources/Public/Icons/teaser.svg @@ -0,0 +1 @@ + diff --git a/packages/evo_megamenu_json/composer.json b/packages/evo_megamenu_json/composer.json new file mode 100644 index 0000000..0ffbf89 --- /dev/null +++ b/packages/evo_megamenu_json/composer.json @@ -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" + } + } +} diff --git a/packages/evo_megamenu_json/ext_emconf.php b/packages/evo_megamenu_json/ext_emconf.php new file mode 100644 index 0000000..6bec9a3 --- /dev/null +++ b/packages/evo_megamenu_json/ext_emconf.php @@ -0,0 +1,21 @@ + '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' => '', + ], + ], +]; diff --git a/packages/evo_megamenu_json/ext_tables.sql b/packages/evo_megamenu_json/ext_tables.sql new file mode 100644 index 0000000..a45181c --- /dev/null +++ b/packages/evo_megamenu_json/ext_tables.sql @@ -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 +); diff --git a/packages/vitec/Classes/Command/DebugSetsCommand.php b/packages/vitec/Classes/Command/DebugSetsCommand.php new file mode 100644 index 0000000..4f7b6b9 --- /dev/null +++ b/packages/vitec/Classes/Command/DebugSetsCommand.php @@ -0,0 +1,84 @@ +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('This TYPO3 version does not expose Site::getSets().'); + 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('headless or vitecset is not in the resolved list at all.'); + return Command::SUCCESS; + } + if ($headlessAt > $vitecAt) { + $output->writeln(sprintf( + 'PROBLEM: headless loads at %d, AFTER vitecset at %d - it resets `page` and drops the page-level fields.', + $headlessAt, $vitecAt + )); + } else { + $output->writeln(sprintf( + 'Order is fine: headless at %d, vitecset at %d.', + $headlessAt, $vitecAt + )); + } + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Command/SeedMegamenuCommand.php b/packages/vitec/Classes/Command/SeedMegamenuCommand.php new file mode 100644 index 0000000..117bdaf --- /dev/null +++ b/packages/vitec/Classes/Command/SeedMegamenuCommand.php @@ -0,0 +1,523 @@ +> */ + 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( + '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.', + $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('No storage page given - pass --pid=.'); + 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('Nothing could be built - is the structure in place?'); + 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('' . $warning . ''); + } + $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 . ''); + } + 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/. + // 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}> */ + 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>> + */ + 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 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|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> 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)); + } +}