From cbe46cdba2d382ff00370de49e1f21a2eb823cff Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 23 Oct 2026 14:10:57 +0200 Subject: [PATCH] Megamenu page-tree panels; generic slug normaliser - evo_megamenu_json: new entry layout "pagetree" - the panel columns mirror the live subtree of the page the entry links to (one child per column, grandchildren as items, getMenu() restrictions apply), so the menu can never age against the page structure again. The stale 9-market panel after the markets realign was the trigger. Curated columns are ignored on this layout; teasers and the featured slot stay editorial. Same columns shape in the payload - front ends need no change beyond knowing the new layout string - new vitec:normalize-slugs --root= [--style=plain|and]: recursively realigns every slug below a root to /, cascading, with a sibling-collision warning; EXT:redirects covers the old URLs. TYPO3's slug generator keeps slashes from page titles (third hit: rtcc-/-rtic), this sweeps a whole tree. Run on /solutions: four fixes, including the long-standing passenger page and solution page 467. The misspelled "engagament" slug stays deliberately - it follows the page title and is fixed in the coordinated typo task Co-Authored-By: Claude Fable 5 --- .../Classes/Service/MenuBuilder.php | 48 ++++++- .../TCA/tx_evomegamenujson_entry.php | 1 + .../Private/Language/locallang_db.xlf | 1 + .../Classes/Command/NormalizeSlugsCommand.php | 133 ++++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 packages/vitec/Classes/Command/NormalizeSlugsCommand.php diff --git a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php index e6c7a70..d234866 100644 --- a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php +++ b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php @@ -86,7 +86,7 @@ final class MenuBuilder private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array { $layout = (string)($entry['layout'] ?? 'columns'); - if (!in_array($layout, ['columns', 'teasers', 'catalog'], true)) { + if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree'], true)) { $layout = 'columns'; } @@ -103,6 +103,16 @@ final class MenuBuilder ]; } + // Page-tree panels (2026-10-23): the columns mirror the live subtree + // of the page the entry links to - one column per child, its children + // as the items - so the menu can never age against the page structure + // again (the stale 9-market panel after the markets realign was the + // trigger). Curated columns are ignored on this layout; teasers and + // the featured slot stay editorial. + if ($layout === 'pagetree') { + $columns = $this->treeColumns((string)$entry['link'], $cObj); + } + $built = [ 'title' => (string)$entry['title'], 'link' => $this->url((string)$entry['link'], $cObj), @@ -177,6 +187,42 @@ final class MenuBuilder return $built; } + /** + * Columns from the page tree below the entry's linked page. getMenu() + * applies the frontend restrictions (hidden, time, nav_hide, fe_group), + * so an editor hides a page and it leaves the menu with the next flush. + * + * @return array> + */ + private function treeColumns(string $entryLink, ContentObjectRenderer $cObj): array + { + $rootUid = 0; + if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) { + $rootUid = (int)$matches[1]; + } elseif (ctype_digit(trim($entryLink))) { + $rootUid = (int)trim($entryLink); + } + if ($rootUid <= 0) { + return []; + } + $columns = []; + foreach ($this->pageRepository->getMenu($rootUid) as $child) { + $items = []; + foreach ($this->pageRepository->getMenu((int)$child['uid']) as $grandchild) { + $items[] = [ + 'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']), + 'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj), + ]; + } + $columns[] = [ + 'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']), + 'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj), + 'items' => $items, + ]; + } + return $columns; + } + // ------------------------------------------------------------ internals /** @return array|null */ diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php index 57a9f8a..1399cdf 100644 --- a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php @@ -105,6 +105,7 @@ return [ ['label' => $ll . 'layout.columns', 'value' => 'columns'], ['label' => $ll . 'layout.teasers', 'value' => 'teasers'], ['label' => $ll . 'layout.catalog', 'value' => 'catalog'], + ['label' => $ll . 'layout.pagetree', 'value' => 'pagetree'], ], ], ], diff --git a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf index 421dc9e..6ec3e7f 100644 --- a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf +++ b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf @@ -25,6 +25,7 @@ Columns Link column plus teaser cards Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles) + Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored) Columns One column per group. In the teaser layout only the first column is rendered, next to the cards. Teaser cards diff --git a/packages/vitec/Classes/Command/NormalizeSlugsCommand.php b/packages/vitec/Classes/Command/NormalizeSlugsCommand.php new file mode 100644 index 0000000..4411c1b --- /dev/null +++ b/packages/vitec/Classes/Command/NormalizeSlugsCommand.php @@ -0,0 +1,133 @@ +/, + * recursively and cascading (a fixed parent slug carries into its children). + * EXT:redirects covers the old URLs automatically. + * + * Why: TYPO3's own slug generator KEEPS slashes that live in page titles + * ("RTCC / RTIC" -> rtcc-/-rtic), which breaks routes and canonicals - the + * defect hit us three times (passenger page, solution page 467, RTCC). + * This command repairs a whole subtree in one sweep. + * + * --style picks the ampersand convention of the tree: + * plain "A & B" -> a-b (TYPO3 default, used below /solutions) + * and "A & B" -> a-and-b (used below /markets since the 10-23 align) + * Do not mix styles within one tree: the wrong style renames EVERY page. + * + * vendor/bin/typo3 vitec:normalize-slugs --root=/solutions + * vendor/bin/typo3 vitec:normalize-slugs --root=/solutions --apply + */ +#[AsCommand( + name: 'vitec:normalize-slugs', + description: 'Normalise page slugs below a root to parent-slug/title-slug (dry-run by default)' +)] +final class NormalizeSlugsCommand extends Command +{ + /** Page types that carry no meaningful slug of their own. */ + private const SKIP_DOKTYPES = [199, 254, 255]; + + protected function configure(): void + { + $this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /solutions'); + $this->addOption('style', null, InputOption::VALUE_REQUIRED, "Ampersand style: 'plain' (A & B -> a-b) or 'and' (-> a-and-b)", 'plain'); + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $rootSlug = rtrim((string)$input->getOption('root'), '/'); + $style = (string)$input->getOption('style') === 'and' ? 'and' : 'plain'; + if ($rootSlug === '') { + $output->writeln('--root is required (e.g. --root=/solutions)'); + return Command::FAILURE; + } + + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $root = $connection->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ?', [$rootSlug]); + if (!$root) { + $output->writeln('No page with slug ' . $rootSlug . ''); + return Command::FAILURE; + } + + $output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · root ' . $rootSlug . ' (uid ' . (int)$root['uid'] . ') · style ' . $style); + $datamap = []; + $changes = 0; + $this->walk($connection, (int)$root['uid'], $rootSlug, $style, $datamap, $changes, $output); + + if ($changes === 0) { + $output->writeln('All slugs already normalised.'); + return Command::SUCCESS; + } + if (!$apply) { + $output->writeln("\n" . $changes . ' change(s). DRY RUN - nothing written. Add --apply to write.'); + return Command::SUCCESS; + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + $output->writeln('' . $changes . ' slug(s) written (redirects follow). Flush the cache: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } + + /** + * Depth-first: the parent's EFFECTIVE (possibly just-fixed) slug prefixes + * the children. Collisions between siblings get a numeric suffix and a + * warning - that needs an editorial look, never silent overwriting. + * + * @param array>> $datamap + */ + private function walk($connection, int $parentUid, string $parentSlug, string $style, array &$datamap, int &$changes, OutputInterface $output): void + { + $taken = []; + foreach ($connection->fetchAllAssociative( + 'SELECT uid, title, slug, doktype FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting', + [$parentUid] + ) as $child) { + if (in_array((int)$child['doktype'], self::SKIP_DOKTYPES, true)) { + $this->walk($connection, (int)$child['uid'], $parentSlug, $style, $datamap, $changes, $output); + continue; + } + $expected = $parentSlug . '/' . $this->slugify((string)$child['title'], $style); + if (isset($taken[$expected])) { + $suffixed = $expected . '-' . ((int)$child['uid']); + $output->writeln(' ! sibling collision on ' . $expected . ' - "' . (string)$child['title'] . '" (uid ' . (int)$child['uid'] . ') gets ' . $suffixed . ', review editorially'); + $expected = $suffixed; + } + $taken[$expected] = true; + if ((string)$child['slug'] !== $expected) { + $datamap['pages'][(int)$child['uid']]['slug'] = $expected; + $changes++; + $output->writeln(sprintf(' ~ "%s" (uid %d) %s -> %s', (string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected)); + } + $this->walk($connection, (int)$child['uid'], $expected, $style, $datamap, $changes, $output); + } + } + + private function slugify(string $value, string $style): string + { + $value = mb_strtolower(trim($value)); + $value = str_replace('&', $style === 'and' ? ' and ' : ' ', $value); + return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-'); + } +}