Products: categories and sub-categories become clickable pages

The 2026-11-02 menu build created the 3 categories and 9 sub-categories
as sysfolders (labels); every level must be a clickable page.

- build-product-overviews: six sub-categories adopt the 2026-10 category
  pages (Joe's workbooks; content, slug and SEO kept, sitemap name as
  nav_title), the remaining folders become pages with generated content,
  Capture & Graphics gets the main column of Graphics & Multi-Display and
  Video Capture copied in. Pages move/convert by direct UPDATE (the
  redirects slug hook would rewrite the flat child slugs); three 2026-10
  pages leave the menus but stay reachable. Warns about a sys_redirect
  on a target slug.
- ProvideProductCatalog: categories[].link and groups[].link (additive),
  menu title = nav_title when set, only filled groups/categories count.
  The menu search reads both levels as pages too.
- build-why-vitec-bands: band at the top of the main column; on pages
  with an existing "Why VITEC?" section the context's icons go onto the
  cards whose title matches the icon theme (16 icons on 4 pages).
- No browser cache headers on the page JSON: headless enables
  sendCacheHeaders and TYPO3 14 caches pages for a year by default, so
  every /_json/ response carried max-age=~31536000.
- Spec v1.32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-05 12:05:04 +01:00
1 parent 73e4298b75
commit bd7a145afa
6 files changed
+627 -79

No files matched your search

@@ -25,9 +25,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* -> "Scalable architecture"); bodytext stays empty until the agency
* delivers copy (0036: a card without text is fine).
*
* The band lands directly AFTER the first element of the main column (the
* intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph
* is skipped - editors own the band from then on. The Vision sub-group icon
* The band lands at the TOP of the main column, before the "Product range"
* intro and its cards (0042-FE: placed after the intro it split heading and
* cards; fix_why_band_position.php corrected the first seven pages).
* Idempotent: a page already carrying a "Why VITEC?" introparagraph gets
* no second band; unused icons of its context go onto existing cards whose
* title matches the icon theme (2026-11-05: the adopted category pages'
* workbook cards) - nothing else is touched. The Vision sub-group icon
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
* decision, reported only.
*
@@ -44,7 +48,7 @@ final class BuildWhyVitecBandsCommand extends Command
private const HEADER = 'Why VITEC?';
private const COLPOS = 0;
/** staged icon context => overview page slug */
/** staged icon context => overview (or, since 2026-11-05, sub-category) page slug */
private const BANDS = [
'activesqx' => '/products/activesqx',
'aetria' => '/products/aetria',
@@ -53,8 +57,18 @@ final class BuildWhyVitecBandsCommand extends Command
'image-graphics-cards' => '/products/image-graphics-cards',
'operator-workstations' => '/products/operator-workstations',
'prism-transcoder' => '/products/prism-transcoder',
// sub-category pages = the adopted 2026-10 category pages (2026-11-05);
// they already carry a "Why VITEC?" section from Joe's workbooks whose
// cards ARE these icon themes -> icons go onto the existing cards
'avoip-and-kvm' => '/products/av-over-ip-and-kvm',
'control-room-platform' => '/products/control-room-platforms',
'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding',
'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage',
];
/** words ignored when an icon theme is matched against a card title */
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
/** acronyms kept upper-case when the filename theme becomes a card title */
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
@@ -85,15 +99,6 @@ final class BuildWhyVitecBandsCommand extends Command
}
$pageUid = (int)$page['uid'];
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
[$pageUid, self::HEADER]
);
if ($existing > 0) {
$output->writeln($slug . ': band exists - untouched');
continue;
}
$folder = self::ICON_BASE . $context;
$icons = [];
if ($storage->hasFolder($folder)) {
@@ -108,6 +113,18 @@ final class BuildWhyVitecBandsCommand extends Command
continue;
}
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
[$pageUid, self::HEADER]
);
if ($existing > 0) {
// the page has its "Why VITEC?" section already (own band or
// a workbook import): put the icons onto matching cards
// without one; everything else stays untouched
$built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output);
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
foreach ($icons as $icon) {
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
@@ -160,11 +177,10 @@ final class BuildWhyVitecBandsCommand extends Command
return Command::FAILURE;
}
// final order: first element (intro), then the band, then the rest
// final order: the band first, then the existing main column
$order = array_merge(
array_slice($existingUids, 0, 1),
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
array_slice($existingUids, 1)
$existingUids
);
$sorting = 0;
foreach ($order as $uid) {
@@ -182,6 +198,97 @@ final class BuildWhyVitecBandsCommand extends Command
return Command::SUCCESS;
}
/**
* Icons onto the existing cards of a page whose title matches an icon
* theme ("Centralized management" ~ CentralManagement: every word of
* the theme is a prefix of a card-title word or vice versa, same word
* count). Only cards without an icon; they become icon_card like the
* generated bands. Returns 1 when anything was (or would be) attached.
*
* @param array<int,array{identifier:string,title:string}> $icons
*/
private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
"SELECT c.uid, c.header FROM tt_content c
WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card'
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid)
ORDER BY c.uid",
[$pageUid]
);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
// icons already on a card of this page (e.g. the generated bands)
$used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?",
[$pageUid]
));
$datamap = [];
$lines = [];
foreach ($icons as $icon) {
if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) {
continue;
}
foreach ($cards as $k => $card) {
if (!$this->sameTheme($icon['title'], (string)$card['header'])) {
continue;
}
$lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier']));
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card'];
unset($cards[$k]);
continue 2;
}
$lines[] = ' <comment>no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')</comment>';
}
if ($lines === []) {
$output->writeln($slug . ': band complete - untouched');
return 0;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid));
foreach ($lines as $line) {
$output->writeln($line);
}
if ($datamap === []) {
return 0;
}
if ($apply) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
}
}
return 1;
}
private function sameTheme(string $iconTitle, string $cardTitle): bool
{
$words = fn(string $text): array => array_values(array_diff(
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
self::STOPWORDS
));
$a = $words($iconTitle);
$b = $words($cardTitle);
if ($a === [] || count($a) !== count($b)) {
return false;
}
foreach ($a as $i => $word) {
if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) {
return false;
}
}
return true;
}
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
private function themeTitle(string $theme): string
{