Markets branch: 11-market tree, sub-market import, hero pipeline

Structure per the agency agreement (Joe/Christina, confirmed 2026-10-23):
/markets -> eleven market hero pages -> their sub-market pages; the
use-case level stays sections on the sub-market pages.

- new vitec:align-market-pages: renames 8 main market pages onto the
  agency names (slug change -> EXT:redirects), creates the 3 new markets,
  hides the dropped "Security & Surveillance" (records/stories
  untouched), reports unknown children. A child-slug pass realigns every
  child to <parent-slug>/<slugified-title> (DataHandler does not cascade
  slug changes; this also repaired the third embedded-slash slug,
  rtcc-/-rtic). --publish unhides the tree (dev convenience; element
  shells stay hidden)
- sub-market import: all 24 agency workbooks applied (4 existing pages,
  20 created) - their Meta sheets were clones of Corporate Environments
  (record_uid 32) and were repaired beforehand (market/sub-market set,
  record_uid 0; the record taxonomy alignment is a separate step)
- SubmarketWorkbookImporter: page-properties parity (og/twitter titles
  and descriptions, canonical, abstract from the intro copy) and a
  placeholder upgrade pass - pages created in the same run only get the
  generic grey (their uid is still NEW), the re-run swaps it for the
  numbered per-page placeholder
- new market hero pipeline: frontend layout 23 "Market Hero Page"
  (zones hero=colPos1, main=colPos0), Import\MarketHeroPageImporter +
  vitec:import-market-hero-workbooks (inbox migrations/markethero_xlsx_in,
  gitignored). Hero H1 travels on the introparagraph header (markethero
  has no headline field), the hero CTA anchors #submarkets, sub-market
  cards link their child page via t3://page, customerlogos and
  usecaselist ship hidden until filled editorially, prefooter ctabanner;
  full page-properties set with 11 curated keyword sets
- applied end to end and live-verified (layout 23 heroes with linked
  cards, layout 21 sub pages with all six colPos groups, clean slugs)
- spec v1.25

Open: market RECORD taxonomy (story badges / Solr facets still use the
old 9 mains), featured product "ISR Video Mission Commander" has no
record, the /markets main page workbook (template 2) is a separate flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-23 13:52:43 +02:00
1 parent 115952a132
commit 303defe24d
9 files changed
+870 -5

No files matched your search

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Aligns the MARKET PAGE TREE below /markets with Joe's agreed structure
* (sitemap tracker + Dropbox delivery, decision Olli 2026-10-23):
*
* /markets -> exactly 11 market hero pages -> their sub-market pages
* (the use-case level stays SECTIONS on the sub-market pages, 14.10.)
*
* What it does (dry-run by default):
* - renames 8 of our 9 main market pages onto Joe's market names, slug
* included (the slug change makes EXT:redirects create the redirect)
* - creates the 3 new markets HIDDEN (Critical National Infrastructure,
* Government, Public Safety)
* - hides "Security & Surveillance" (market dropped by the agency - its
* themes live in the solutions branch; records and stories untouched)
* - reports every other child it does not recognise (e.g. the stray
* "Hospitality & Leisure" page) for an editorial decision
*
* DELIBERATELY NOT here: the market RECORD taxonomy (9 mains drive the
* success-story badges, Solr facets and the story mapping
* INDUSTRY_TO_MARKET) - aligning that is a separate, editorially decided
* step. Pages first, so the workbook imports have their tree.
*
* vendor/bin/typo3 vitec:align-market-pages
* vendor/bin/typo3 vitec:align-market-pages --apply
*/
#[AsCommand(
name: 'vitec:align-market-pages',
description: "Align the /markets page tree with Joe's 11-market structure (dry-run by default)"
)]
final class AlignMarketPagesCommand extends Command
{
/** Joe's market name => our current page title (null = create new). */
private const MARKETS = [
'Hospitality' => 'Retail Hospitality & Leisure',
'Transport' => 'Transport & Infrastructure',
'Sports & Entertainment' => 'Sports Venues & Entertainment',
'Broadcast and Telecoms' => 'Media Broadcast & Telecom',
'Education' => 'Healthcare & Education',
'Corporation' => 'Corporate & Enterprise',
'Industrial' => 'Energy Industrial & Resources',
'Critical National Infrastructure' => null,
'Government' => null,
'Public Safety' => null,
'Defense and Security' => 'Defense Government & Public Sector',
];
/** Dropped by the agency - page gets hidden, nothing is deleted. */
private const HIDE = ['Security & Surveillance'];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
$this->addOption('publish', null, InputOption::VALUE_NONE, 'Also unhide the market pages and their children (dev convenience; the dropped market and element shells stay hidden)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
if (!$root) {
$output->writeln('<error>Page /markets not found.</error>');
return Command::FAILURE;
}
$rootUid = (int)$root['uid'];
$children = $connection->fetchAllAssociative(
'SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
[$rootUid]
);
$byNorm = [];
foreach ($children as $child) {
$byNorm[$this->norm((string)$child['title'])] = $child;
}
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /markets uid ' . $rootUid . ' · ' . count($children) . ' direct children');
$datamap = [];
$claimed = [];
$targets = [];
$newIndex = 0;
foreach (self::MARKETS as $joeName => $oldName) {
$joeSlug = '/markets/' . $this->slugify($joeName);
$existingJoe = $byNorm[$this->norm($joeName)] ?? null;
if ($existingJoe !== null) {
$claimed[(int)$existingJoe['uid']] = true;
$targets[] = ['uid' => (int)$existingJoe['uid'], 'slug' => (string)$existingJoe['slug']];
$line = sprintf('= %-36s exists (uid %d%s)', $joeName, (int)$existingJoe['uid'], ((int)$existingJoe['hidden'] === 1) ? ', hidden' : '');
$output->writeln(' ' . $line);
continue;
}
$old = $oldName !== null ? ($byNorm[$this->norm($oldName)] ?? null) : null;
if ($old !== null) {
$claimed[(int)$old['uid']] = true;
$datamap['pages'][(int)$old['uid']] = [
'title' => $joeName,
'slug' => $joeSlug,
];
$targets[] = ['uid' => (int)$old['uid'], 'slug' => $joeSlug];
$output->writeln(sprintf(' ~ %-36s RENAME from "%s" (uid %d), slug %s -> %s (redirect follows)',
$joeName, $oldName, (int)$old['uid'], $old['slug'], $joeSlug));
continue;
}
if ($oldName !== null) {
$output->writeln(sprintf(' <error>? %-36s neither Joe name nor "%s" found - review</error>', $joeName, $oldName));
continue;
}
$newId = 'NEWMKT' . (++$newIndex);
$datamap['pages'][$newId] = [
'pid' => $rootUid,
'title' => $joeName,
'slug' => $joeSlug,
'doktype' => 1,
'hidden' => 1,
];
$output->writeln(sprintf(' + %-36s CREATE (hidden)', $joeName));
}
foreach (self::HIDE as $hideName) {
$page = $byNorm[$this->norm($hideName)] ?? null;
if ($page === null) {
$output->writeln(' <comment>hide: "' . $hideName . '" not found - nothing to do</comment>');
continue;
}
$claimed[(int)$page['uid']] = true;
if ((int)$page['hidden'] === 1) {
$output->writeln(' - ' . $hideName . ' already hidden (uid ' . (int)$page['uid'] . ')');
continue;
}
$datamap['pages'][(int)$page['uid']] = ['hidden' => 1];
$output->writeln(' - ' . $hideName . ' HIDE (uid ' . (int)$page['uid'] . ') - dropped by the agency, records/stories untouched');
}
foreach ($children as $child) {
if (!isset($claimed[(int)$child['uid']])) {
$output->writeln(sprintf(' <comment>! unexpected child "%s" (uid %d%s) - review editorially</comment>',
(string)$child['title'], (int)$child['uid'], ((int)$child['hidden'] === 1) ? ', hidden' : ''));
}
}
// --- optional: publish the tree (dev convenience, Olli 2026-10-23) -------
// Unhides the 11 market pages and their children. The dropped market
// ("Security & Surveillance") is no target and stays hidden; hidden
// ELEMENT shells (trusted-by, case studies) also stay hidden - an
// unhidden empty story list would render ALL stories.
if ((bool)$input->getOption('publish')) {
foreach ($targets as $target) {
$self = $connection->fetchAssociative('SELECT uid, title, hidden FROM pages WHERE uid = ?', [$target['uid']]);
$rows = $connection->fetchAllAssociative(
'SELECT uid, title, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
[$target['uid']]
);
if ($self) {
array_unshift($rows, $self);
}
foreach ($rows as $row) {
if ((int)$row['hidden'] === 1) {
$datamap['pages'][(int)$row['uid']]['hidden'] = 0;
$output->writeln(' * publish: "' . (string)$row['title'] . '" (uid ' . (int)$row['uid'] . ')');
}
}
}
}
// --- child slugs follow their (renamed) parent ---------------------------
// DataHandler does not cascade slug changes to subpages (only the
// backend UI offers that), so realign every child slug to
// <parent-slug>/<slugified-title>; EXT:redirects covers the old URLs.
// This also normalises slug accidents like embedded slashes from
// titles ("RTCC / RTIC").
foreach ($targets as $target) {
foreach ($connection->fetchAllAssociative(
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
[$target['uid']]
) as $child) {
$expected = rtrim((string)$target['slug'], '/') . '/' . $this->slugify((string)$child['title']);
if ((string)$child['slug'] !== $expected) {
$datamap['pages'][(int)$child['uid']]['slug'] = $expected;
$output->writeln(sprintf(' ~ child slug: "%s" (uid %d) %s -> %s (redirect follows)',
(string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected));
}
}
}
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
if ($datamap === []) {
$output->writeln('Nothing to change.');
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>Done. Flush the cache: vendor/bin/typo3 cache:flush</info>');
return Command::SUCCESS;
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
}
private function slugify(string $value): string
{
return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-');
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Import\MarketHeroPageImporter;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
/**
* CLI shell around MarketHeroPageImporter - imports the agency "Markets
* hero sub page" workbooks into the eleven market hero pages (layout 23).
* Run vitec:align-market-pages first, and ideally the sub-market import
* before this one so the sub-market cards can link their child pages.
*
* vendor/bin/typo3 vitec:import-market-hero-workbooks
* vendor/bin/typo3 vitec:import-market-hero-workbooks --only=hospitality --apply
*/
#[AsCommand(
name: 'vitec:import-market-hero-workbooks',
description: 'Import the agency market hero workbooks into the market pages (dry-run by default)'
)]
final class ImportMarketHeroWorkbooksCommand extends Command
{
public function __construct(private readonly MarketHeroPageImporter $importer)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/markethero_xlsx_in');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring');
$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');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$dir = rtrim((string)$input->getOption('dir'), '/');
if (!is_dir($dir)) {
$output->writeln('<error>Directory not found: ' . $dir . '</error>');
return Command::FAILURE;
}
$files = [];
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir));
foreach ($iterator as $fileInfo) {
$name = $fileInfo->getFilename();
if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) {
if ($only === '' || str_contains(mb_strtolower($name), $only)) {
$files[] = $fileInfo->getPathname();
}
}
}
sort($files);
if ($files === []) {
$output->writeln('No workbooks found in ' . $dir);
return Command::SUCCESS;
}
$output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN'));
$errors = 0;
foreach ($files as $file) {
try {
$result = $this->importer->importFile($file, $apply);
$output->writeln(sprintf("\n<info>%s</info> (market: %s)", $result['file'], $result['market']));
foreach ($result['report'] as $line) {
$output->writeln(' <comment>' . $line . '</comment>');
}
$output->writeln(sprintf(' page #%d · %d element(s) touched · %s',
$result['pageUid'], $result['elements'], $apply ? 'written.' : 'dry run'));
} catch (\Throwable $e) {
$errors++;
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
}