/, * 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; $this->queueRedirect($datamap, (string)$child['slug'], (int)$child['uid']); $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); } } /** * Queues the 301 for a vacated slug in the same DataHandler run - * EXT:redirects' automatic creation does not fire for CLI DataHandler * runs (found 2026-10-26: every renamed URL answered 404). * * @param array>> $datamap */ private function queueRedirect(array &$datamap, string $oldSlug, int $pageUid): void { $oldSlug = rtrim($oldSlug, '/'); if ($oldSlug === '') { return; } $datamap['sys_redirect']['NEWR' . substr(md5($oldSlug), 0, 10)] = [ 'pid' => 0, 'source_host' => '*', 'source_path' => $oldSlug, 'target' => 't3://page?uid=' . $pageUid, 'target_statuscode' => 301, ]; } 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), '-'); } }