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), '-'); + } +}