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:
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))), '-');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user