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
+626 -78

No files matched your search

@@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* below /products:
*
* /products
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
* [folder] sub-category (B, menu label) e.g. "End-Points"
* category page (A, /products/<slug>) e.g. "Platforms and End-Points"
* sub-category page (B, /products/<slug>) e.g. "End-Points"
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
*
* The page tree is the single source of the products menu (ProvideProduct
* Catalog reads it): editors rename, reorder or move overview pages there.
* A and B are sysfolders - never routed, never in navigation menus. The 20
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
* are new or still empty, generated content: hero (page title, placeholder
* All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when
* new or still empty, get generated content: hero (page title, placeholder
* background - 0026-FE), one intro paragraph per grouping title (or
* "Product range"), a product card per sub-product (record teaser, link
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
* no image get the placeholder backfilled; any existing reference wins.
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
* Afterwards run vitec:wrap-cards-grid per page.
*
* A and B were sysfolders (menu labels) until 2026-11-05; Olli: every
* category and sub-category must be a clickable page. Decisions 2026-11-05:
* - ADOPT: six sub-categories ARE a 2026-10 category page (Joe's category
* workbooks; their "Why VITEC" cards match the sub-category icon sets).
* The page moves into the folder's place, keeps content, slug and SEO,
* gets the sitemap name as nav_title; the folder's children move below
* it and the emptied folder is deleted.
* - MERGE: "Capture & Graphics" gets the main column of BOTH matching
* pages (Graphics & Multi-Display, Video Capture) copied below each other.
* - the rest (3 categories, End-Points, RF Gateway) are converted folders
* with generated content: hero, "Product range", one card per child page
* (title, child list, the child's page media, link), contact pre-footer.
* - the 2026-10 pages without a place (Graphics & Multi-Display, Video
* Capture, Infrastructure & Accessories) leave the menus (nav_hide) but
* stay reachable.
* Folders become pages and pages move by direct UPDATE - not through the
* DataHandler, whose slug hook (EXT:redirects) would rewrite the flat slugs.
*
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
@@ -152,6 +169,43 @@ final class BuildProductOverviewsCommand extends Command
]],
];
/** category (A) and sub-category (B) pages built here: flat slugs like the overview pages */
private const SECTION_SLUGS = [
'Platforms and End-Points' => 'platforms-and-end-points',
'End-Points' => 'end-points',
'IP Video Streaming, Distribution & KVM' => 'ip-video-streaming-distribution-and-kvm',
'RF Gateway' => 'rf-gateway',
'Video Wall Controllers & Management' => 'video-wall-controllers-and-management',
'Capture & Graphics' => 'capture-and-graphics',
];
/**
* Sub-categories that ARE a 2026-10 category page (Joe's category
* workbooks, imported 2026-10-27 - their "Why VITEC" cards match the
* agency's sub-category icon sets 1:1). Decision Olli 2026-11-05: the
* page moves into the tree in place of the folder and keeps content,
* slug and SEO; the sitemap name becomes its nav_title (menu label).
*/
private const ADOPT = [
'IPTV Distribution & Signage Platform' => '/products/iptv-and-digital-signage',
'Control Room Platform' => '/products/control-room-platforms',
'Encoders & Decoders' => '/products/video-encoding-and-streaming',
'IP Gateway & Transcoder' => '/products/gateways-and-transcoding',
'AVoIP & KVM' => '/products/av-over-ip-and-kvm',
'Video Walls' => '/products/video-wall-and-display-processing',
];
/** sub-category built from the content of two category pages (main column of both, in order) */
private const MERGE = [
'Capture & Graphics' => ['/products/graphics-and-multi-display', '/products/video-capture'],
];
/** 2026-10 category pages without a place in the tree: stay reachable, leave the menus */
private const LEGACY_CATEGORY_SLUGS = [
'/products/graphics-and-multi-display', '/products/video-capture',
'/products/infrastructure-and-accessories',
];
/**
* Sitemap names that do not exist as records - mapped onto the existing
* record by slug. The Vision "+1" names are almost certainly an Excel
@@ -179,6 +233,24 @@ final class BuildProductOverviewsCommand extends Command
private array $report = [];
private int $newCounter = 0;
private bool $apply = false;
/** @var array<string,string> section page id => title */
private array $sectionTitles = [];
/** @var array<string,array<int,array<string,mixed>>> section page id => child specs (card order) */
private array $sectionChildren = [];
/** @var string[] section page ids that get generated content (new or empty) */
private array $sectionsToFill = [];
/** @var array<int,array<string,mixed>> folder uid => page fields, written by direct UPDATE */
private array $conversions = [];
/** @var int[] legacy category pages that leave the menus */
private array $legacyToHide = [];
/** @var array<int,array{page:int,folder:int,parent:int,navTitle:string}> adopted 2026-10 pages, applied by direct UPDATE */
private array $adoptions = [];
/** @var array<string,string> sub-category id => id of the node its children currently hang on (the old folder) */
private array $lookupParent = [];
/** @var array<string,int[]> merge target id => source page uids */
private array $merges = [];
/** @var array<string,string> overview slug => product names (card text on the sub-category page) */
private array $overviewNames = [];
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
private ?array $products = null;
@@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command
$shortcuts = []; // [id, spec]
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
$categoryId = $this->section($rootId, $categoryTitle, 'category');
$categoryChildren = [];
foreach ($groups as [$groupTitle, $entries]) {
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
$groupId = $this->section($categoryId, $groupTitle, 'sub-category');
$groupFolderIds[$groupTitle] = $groupId;
$groupChildren = [];
foreach ($entries as $entry) {
if (isset($entry['shortcut'])) {
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
$groupChildren[] = ['shortcut' => $entry];
continue;
}
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
$groupChildren[] = [
'id' => $overviewBySlug[$entry['slug']],
'title' => (string)$entry['page'],
'text' => $this->overviewNames[$entry['slug']] ?? '',
];
}
$this->sectionChildren[$groupId] = $groupChildren;
$categoryChildren[] = [
'id' => $groupId,
'title' => $groupTitle,
'text' => implode(', ', array_column(array_filter($groupChildren, static fn(array $c): bool => isset($c['id'])), 'title')),
];
}
$this->sectionChildren[$categoryId] = $categoryChildren;
}
// shortcut targets (a group folder or an overview page)
// shortcut targets (a sub-category page or an overview page); on the
// sub-category page the shortcut becomes a card linking its target
$targetOf = function (array $spec) use ($groupFolderIds, $overviewBySlug): ?string {
return isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
};
foreach ($shortcuts as [$shortcutId, $spec]) {
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
$target = $targetOf($spec);
if ($target === null) {
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
continue;
@@ -229,6 +320,25 @@ final class BuildProductOverviewsCommand extends Command
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
}
foreach ($this->sectionChildren as $sectionId => $children) {
foreach ($children as $i => $child) {
if (isset($child['shortcut'])) {
$target = $targetOf($child['shortcut']);
$this->sectionChildren[$sectionId][$i] = $target === null ? null : [
'id' => $target,
'title' => (string)$child['shortcut']['shortcut'],
'text' => (string)($child['shortcut']['toGroup'] ?? ''),
];
}
}
$this->sectionChildren[$sectionId] = array_values(array_filter($this->sectionChildren[$sectionId]));
}
$this->sectionMeta();
foreach ($this->sectionsToFill as $sectionId) {
$this->sectionContent($sectionId);
}
$this->legacyCategoryPages();
foreach ($this->report as $line) {
$output->writeln($line);
@@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command
return Command::SUCCESS;
}
// folders -> pages and legacy nav_hide by direct UPDATE (see class
// comment: the DataHandler slug hook would rewrite child slugs)
$pageConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach ($this->conversions as $uid => $fields) {
$pageConnection->update('pages', $fields, ['uid' => $uid]);
}
foreach ($this->adoptions as $adoption) {
$pageConnection->update('pages', ['pid' => $adoption['parent'], 'nav_hide' => 0, 'hidden' => 0, 'nav_title' => $adoption['navTitle']], ['uid' => $adoption['page']]);
$pageConnection->update('pages', ['pid' => $adoption['parent']], ['l10n_parent' => $adoption['page']]);
if ($adoption['folder'] > 0) {
$pageConnection->update('pages', ['pid' => $adoption['page']], ['pid' => $adoption['folder'], 'deleted' => 0]);
$pageConnection->update('pages', ['deleted' => 1], ['uid' => $adoption['folder']]);
}
}
foreach ($this->legacyToHide as $uid) {
$pageConnection->update('pages', ['nav_hide' => 1], ['uid' => $uid]);
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
@@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command
return Command::FAILURE;
}
$this->applyOrder($dataHandler->substNEWwithIDs);
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
$output->writeln("\n<info>Written.</info> Next, per overview page:");
foreach ($overviewBySlug as $slug => $id) {
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
$output->writeln("\n<info>Written.</info> Next, per page with generated cards:");
foreach ($this->sectionsToFill as $id) {
$slug = $this->conversions[(int)$id]['slug'] ?? ('/products/' . self::SECTION_SLUGS[$this->sectionTitles[$id]]);
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=' . $slug . ' --apply');
}
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
$output->writeln('(overview pages as needed: --page=/products/<slug>), then vitec:build-why-vitec-bands and cache:flush');
return Command::SUCCESS;
}
// ---- structure -------------------------------------------------------------------
private function folder(string $parentId, string $title, string $kind): string
/**
* A category (A) or sub-category (B) page, found by title below its
* parent. A sysfolder from the 2026-11-02 build is queued for conversion
* into a page; an empty page is queued for generated content.
*/
private function section(string $parentId, string $title, string $kind): string
{
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null;
if ($existing !== null) {
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
$this->pageOrder[$parentId][] = (string)$existing;
return (string)$existing;
$existing = ctype_digit($parentId)
? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null;
if (isset(self::ADOPT[$title])) {
return $this->adopt($parentId, $title, $existing);
}
$slug = '/products/' . self::SECTION_SLUGS[$title];
if ($existing === null) {
$id = $this->newId();
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
$this->datamap['pages'][$id] = [
'pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
];
$this->pageOrder[$parentId][] = $id;
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
$this->sectionTitles[$id] = $title;
$this->sectionsToFill[] = $id;
$this->report[] = sprintf('%s page "%s" %s CREATE', $kind, $title, $slug);
return $id;
}
$uid = $existing['uid'];
$id = (string)$uid;
$this->pageOrder[$parentId][] = $id;
$this->sectionTitles[$id] = $title;
if ($existing['doktype'] === self::DOKTYPE_FOLDER) {
$taken = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND uid <> ? AND sys_language_uid IN (0,-1)',
[$slug, $uid]
);
if ($taken !== false) {
$this->report[] = sprintf('%s folder "%s" #%d: <error>slug %s taken by #%d - NOT converted</error>', $kind, $title, $uid, $slug, (int)$taken);
return $id;
}
$this->conversions[$uid] = [
'doktype' => self::DOKTYPE_PAGE, 'slug' => $slug, 'layout' => self::LAYOUT,
'nav_hide' => 0, 'hidden' => 0,
];
$this->report[] = sprintf('%s folder "%s" #%d -> page %s', $kind, $title, $uid, $slug);
// a redirect on the slug wins over the page (2026-11-05: the 2026-10
// stub redirect /products/platforms-and-end-points hid the new page)
$redirect = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect')->fetchAssociative(
'SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND disabled = 0 AND source_path IN (?, ?)',
[$slug, $slug . '/']
);
if ($redirect) {
$this->report[] = sprintf(' <error>! redirect #%d %s -> %s overrides this page - disable it (Redirects module)</error>', (int)$redirect['uid'], $slug, $redirect['target']);
}
} else {
$this->report[] = sprintf('%s page "%s" #%d (exists)', $kind, $title, $uid);
}
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$uid]);
if ($contentCount === 0) {
$this->sectionsToFill[] = $id;
} else {
$this->report[] = sprintf(' (%d elements - content left untouched)', $contentCount);
}
if (isset(self::MERGE[$title])) {
$this->merges[$id] = [];
foreach (self::MERGE[$title] as $sourceSlug) {
$source = $this->pageBySlug($sourceSlug);
if ($source === null) {
$this->report[] = ' ! merge source ' . $sourceSlug . ' not found';
continue;
}
$this->merges[$id][] = $source;
}
}
return $id;
}
/**
* A 2026-10 category page takes the sub-category's place (ADOPT): it
* moves below the category at the folder's position, the folder's
* children move below it, the emptied folder is deleted. Content, slug
* and SEO of the page stay; nav_title = sitemap name.
*
* @param array{uid:int,doktype:int}|null $folder
*/
private function adopt(string $parentId, string $title, ?array $folder): string
{
$page = $this->pageBySlug(self::ADOPT[$title]);
if ($page === null) {
$this->report[] = sprintf('sub-category "%s": <error>page %s not found - NOT adopted</error>', $title, self::ADOPT[$title]);
return $folder !== null ? (string)$folder['uid'] : $this->newId();
}
$id = (string)$page;
$this->pageOrder[$parentId][] = $id;
$this->lookupParent[$id] = $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? (string)$folder['uid'] : $id;
if (!ctype_digit($parentId)) {
$this->report[] = sprintf('sub-category "%s": <error>category not built yet - run again</error>', $title);
return $id;
}
$this->adoptions[] = [
'page' => $page,
'folder' => $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? $folder['uid'] : 0,
'parent' => (int)$parentId,
'navTitle' => $title,
];
$this->report[] = sprintf(
'sub-category "%s" = %s #%d (content, slug, SEO stay; menu title "%s")%s',
$title, self::ADOPT[$title], $page, $title,
$folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? '; folder #' . $folder['uid'] . ' -> children move, folder deleted' : ''
);
return $id;
}
private function pageBySlug(string $slug): ?int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$slug]
);
return $uid !== false ? (int)$uid : null;
}
/** SEO/OG title, description and abstract of the section pages, from their children. */
private function sectionMeta(): void
{
foreach ($this->sectionTitles as $id => $title) {
$names = array_column($this->sectionChildren[$id] ?? [], 'title');
$description = 'VITEC ' . $title . ($names !== [] ? ': ' . implode(', ', $names) : '') . '.';
$meta = [
'seo_title' => $title . ' | VITEC', 'description' => $description,
'og_title' => $title . ' | VITEC', 'og_description' => $description,
'abstract' => implode(', ', $names),
];
if (isset($this->conversions[(int)$id])) {
$this->conversions[(int)$id] += $meta;
} elseif (isset($this->datamap['pages'][$id])) {
$this->datamap['pages'][$id] += $meta;
}
}
}
/**
* Generated content of a new or empty category / sub-category page:
* hero, "Product range", one card per child page (title, child list as
* text, the child's first page media as image, link), contact pre-footer.
*/
private function sectionContent(string $pageId): void
{
$title = $this->sectionTitles[$pageId];
$this->report[] = sprintf('content for "%s" (%s):', $title, ctype_digit($pageId) ? '#' . $pageId : 'new page');
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', ['header' => $title, 'header_layout' => 1, 'bodytext' => '']);
$placeholder = $this->heroPlaceholder($pageId);
if ($placeholder !== null) {
$this->report[] = ' hero: ' . ($this->apply ? 'attach ' : 'would attach ') . $placeholder;
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $this->pid($pageId),
'table_local' => 'sys_file',
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
->getDefaultStorage()->getFile($placeholder)->getUid(),
];
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
}
}
if (isset($this->merges[$pageId])) {
// MERGE target: the main column is copied from the source pages after the run
$this->report[] = ' main column: copied from ' . count($this->merges[$pageId]) . ' page(s) #' . implode(', #', $this->merges[$pageId]) . ' (after the write)';
} else {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
}
foreach (isset($this->merges[$pageId]) ? [] : ($this->sectionChildren[$pageId] ?? []) as $child) {
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => (string)$child['title'],
'bodytext' => $this->p((string)$child['text']),
'vitec_primary_cta_label' => 'Learn more',
'vitec_primary_cta' => ctype_digit((string)$child['id']) ? 't3://page?uid=' . $child['id'] : '',
];
$fileUid = ctype_digit((string)$child['id']) ? $this->pageMediaFile((int)$child['id']) : 0;
$cardId = $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
if ($fileUid > 0) {
$this->datamap['tt_content'][$cardId]['vitec_layout_variant'] = 'vertical';
if ($this->apply) {
$refId = $this->newId();
$this->datamap['sys_file_reference'][$refId] = ['pid' => $this->pid($pageId), 'table_local' => 'sys_file', 'uid_local' => $fileUid];
$this->datamap['tt_content'][$cardId]['vitec_image'] = $refId;
}
}
$this->report[] = sprintf(' card "%s"%s%s', $child['title'], $fields['vitec_primary_cta'] !== '' ? ' -> ' . $fields['vitec_primary_cta'] : ' (link after the first run)', $fileUid > 0 ? ' + page image' : '');
}
$this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [
'header' => 'Talk to a VITEC expert',
'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'),
'vitec_primary_cta_label' => 'Contact us',
'vitec_primary_cta' => $this->contactLink(),
'vitec_layout_variant' => 'centered',
'vitec_background_variant' => 'orange',
]);
}
/** The 2026-10 category pages below /products leave the menus (nav_hide), stay reachable. */
private function legacyCategoryPages(): void
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
foreach (self::LEGACY_CATEGORY_SLUGS as $slug) {
$row = $pages->fetchAssociative('SELECT uid, nav_hide FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
if (!$row) {
$this->report[] = 'legacy ' . $slug . ': not found';
} elseif ((int)$row['nav_hide'] === 0) {
$this->legacyToHide[] = (int)$row['uid'];
$this->report[] = sprintf('legacy %s #%d: hide in menus (stays reachable)', $slug, (int)$row['uid']);
}
}
}
/**
* MERGE: copies the visible top-level main-column elements of the source
* pages, in order, below each other onto the (new, previously empty)
* target page. One DataHandler copy per element, each placed after the
* previous copy; EXT:container copies a grid's children along. Re-runs
* do nothing - the target is only filled while it is empty.
*
* @param array<string,int> $subst
*/
private function copyMergedContent(array $subst, OutputInterface $output): void
{
$content = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
foreach ($this->merges as $targetId => $sources) {
$targetId = (string)$targetId;
if (!in_array($targetId, $this->sectionsToFill, true)) {
continue;
}
$targetUid = ctype_digit($targetId) ? (int)$targetId : (int)($subst[$targetId] ?? 0);
if ($targetUid === 0) {
continue;
}
$after = 0;
$copied = 0;
foreach ($sources as $sourceUid) {
$elements = $content->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0) ORDER BY sorting, uid',
[$sourceUid, self::COLPOS_MAIN]
);
foreach ($elements as $elementUid) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], ['tt_content' => [(int)$elementUid => ['copy' => $after > 0 ? -$after : $targetUid]]]);
$dataHandler->process_cmdmap();
$newUid = (int)($dataHandler->copyMappingArray_merged['tt_content'][(int)$elementUid] ?? 0);
if ($newUid > 0) {
$after = $newUid;
$copied++;
}
}
}
$output->writeln(sprintf('merged %d element(s) onto #%d', $copied, $targetUid));
}
}
/** sys_file uid of a page's first media reference, 0 when none */
private function pageMediaFile(int $pageUid): int
{
return (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'pages' AND fieldname = 'media' AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1",
[$pageUid]
);
}
private function shortcutPage(string $groupId, string $title): string
{
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
$lookup = $this->lookupParent[$groupId] ?? $groupId; // an adopted page's shortcuts still hang on the old folder
$existing = ctype_digit($lookup) ? ($this->childByTitle((int)$lookup, $title, [self::DOKTYPE_SHORTCUT])['uid'] ?? null) : null;
if ($existing !== null) {
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
$this->pageOrder[$groupId][] = (string)$existing;
@@ -296,13 +680,14 @@ final class BuildProductOverviewsCommand extends Command
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
);
[$cards, $names] = $this->resolveItems($entry['items']);
$this->overviewNames[$entry['slug']] = implode(', ', $names);
if ($existing) {
$pageId = (string)(int)$existing['uid'];
$this->pageOrder[$groupId][] = $pageId;
$updates = [];
if ((string)(int)$existing['pid'] !== $groupId) {
$updates['pid'] = $this->pid($groupId); // moved into its folder
if (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) {
$updates['pid'] = $this->pid($groupId); // moved into its sub-category
}
if ($updates !== []) {
$this->datamap['pages'][$pageId] = $updates;
@@ -509,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command
// ---- lookups -----------------------------------------------------------------------
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
/**
* @param int[] $doktypes
* @return array{uid:int,doktype:int}|null
*/
private function childByTitle(int $parentUid, string $title, array $doktypes): ?array
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
[$parentUid, $title, $doktype]
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ')
AND sys_language_uid IN (0,-1) ORDER BY uid',
[$parentUid, $title]
);
return $uid !== false ? (int)$uid : null;
return $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null;
}
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
@@ -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
{
@@ -16,8 +16,8 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
* the menu ends at the product OVERVIEW pages.
*
* categories[] (A, menu label) = sysfolders directly below /products
* groups[] (B, menu label) = sysfolders below those
* categories[] (A) = category pages directly below /products
* groups[] (B) = sub-category pages below those
* items[] type "page" = overview pages (/products/<slug>)
* type "shortcut" = shortcut pages, resolved to a target
* group or overview page of this tree
@@ -29,6 +29,13 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* pages the visitor reaches from the overview page; the menu does not show
* them, the count and the list serve the "N sub-products" line and search.
*
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
* category and sub-category is a clickable page. Both levels now carry
* `link` (additive; '' while a level is still a folder). Folders are still
* read, so the menu survives a half-converted tree; only groups with items
* and categories with such groups count (keeps other pages below /products,
* e.g. the 2026-10 category pages, out of the menu).
*
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The
* boxes of the left column ("Product highlight", "Product news") are the
* entry's editorial teaser cards, not data from here (decision Olli
@@ -81,8 +88,8 @@ final class ProvideProductCatalog
if ($root === 0) {
return [];
}
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]);
$categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
$overviewUids = array_map('intval', array_column(
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
@@ -92,6 +99,12 @@ final class ProvideProductCatalog
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
$groupPos = [];
$pagePos = [];
// only the products tree: groups with items, categories with such groups
$filledGroups = array_map('intval', array_column($itemRows, 'pid'));
$groupRows = array_values(array_filter($groupRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledGroups, true)));
$filledCategories = array_map('intval', array_column($groupRows, 'pid'));
$categoryRows = array_values(array_filter($categoryRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledCategories, true)));
$categories = [];
foreach ($categoryRows as $c => $categoryRow) {
$groups = [];
@@ -119,9 +132,9 @@ final class ProvideProductCatalog
}
$items[] = $item;
}
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items];
$groups[] = ['title' => (string)$groupRow['title'], 'link' => $this->sectionLink($groupRow), 'items' => $items];
}
$categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups];
$categories[] = ['title' => (string)$categoryRow['title'], 'link' => $this->sectionLink($categoryRow), 'groups' => $groups];
}
// shortcuts: target = a group of this tree (opens that group) or an
@@ -159,6 +172,12 @@ final class ProvideProductCatalog
return $categories;
}
/** @param array<string,mixed> $row category or sub-category page row; '' while it is a folder */
private function sectionLink(array $row): string
{
return (int)$row['doktype'] === self::DOKTYPE_PAGE ? (string)$row['slug'] : '';
}
private function productsRootUid(): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
@@ -173,7 +192,9 @@ final class ProvideProductCatalog
/**
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
* nav_hide respected, in page tree order.
* nav_hide respected, in page tree order. `title` is the menu label:
* nav_title when set (the adopted 2026-10 category pages carry the
* sitemap name there, their page title stays the SEO/H1 one).
*
* @param int[] $parents
* @param int[] $doktypes
@@ -185,7 +206,7 @@ final class ProvideProductCatalog
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages')
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug', 'doktype', 'shortcut')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
@@ -194,6 +215,12 @@ final class ProvideProductCatalog
)
->orderBy('pid')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
}
/**
@@ -77,8 +77,10 @@ final class ProductMenuSearchJsonRenderer
}
/**
* The overview pages of the products menu tree (/products > folder >
* folder > page, see ProvideProductCatalog) with their menu path.
* The overview pages of the products menu tree (/products > category >
* sub-category > page, see ProvideProductCatalog) with their menu path.
* Category and sub-category are pages since 2026-11-05 (folders before -
* both are read).
*
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
*/
@@ -96,7 +98,7 @@ final class ProductMenuSearchJsonRenderer
return [];
}
$queryBuilder = $pool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages')
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
@@ -104,9 +106,16 @@ final class ProductMenuSearchJsonRenderer
)
->orderBy('sorting')
->executeQuery()->fetchAllAssociative();
// menu label = nav_title when set (same rule as ProvideProductCatalog)
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
};
$categories = array_column($level([$root], [254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
$categories = array_column($level([$root], [1, 254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid');
$overviews = [];
foreach ($level(array_keys($groups), [1]) as $page) {
$group = $groups[(int)$page['pid']];
@@ -331,6 +331,17 @@ tt_content {
page.10.fields.jsonLd = USER
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
# =============================================================================
# No browser caching of the page JSON (2026-11-05). EXT:headless sets
# page.config.sendCacheHeaders = 1, and TYPO3 14 caches a page for ONE YEAR
# by default (CacheLifetimeCalculator::defaultCacheTimeout) - every /_json/
# response went out with "Cache-Control: max-age=~31536000", so browsers
# kept an old menu/page for up to a year (found when the clickable product
# categories did not show up). The server-side page cache is unaffected;
# without the header, .htaccess sends application/json "access plus 0 seconds".
# =============================================================================
page.config.sendCacheHeaders = 0
############################################################
# Frontend resolution for the record links from the link
# browser (page.tsconfig TCEMAIN.linkHandler):
@@ -3,7 +3,7 @@
| | |
|---|---|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
| **Version** | 1.31 |
| **Version** | 1.32 |
| **Status** | Released |
| **Date** | 2026‑11‑05 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
@@ -44,6 +44,7 @@
| 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. |
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
This document is drafted in the style of, and adopts the terminology conventions of,
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
@@ -1211,20 +1212,21 @@ tile or `null`.
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
The catalog payload is produced by the `ProvideProductCatalog` listener of
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
`/products`: sysfolders on the first level are the menu **categories**,
sysfolders below them the **groups** (both labels, never pages), and the
pages below the groups the **overview pages** (`/products/<slug>`, built by
`/products`: the pages on the first level are the menu **categories**, the
pages below them the **groups** (sub-categories), and the pages below the
groups the **overview pages** (`/products/<slug>`, built by
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
**shortcut** pages. The menu ends at the overview pages; product detail
pages are reached from there.
**shortcut** pages. Since v1.32 categories and groups are clickable pages
with `link` (they were sysfolders/labels before); product detail pages are
reached from the overview pages.
```json
"catalog": {
"version": 2,
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
"viewAll": { "title": "View all products", "link": "/products" },
"categories": [ { "title": "IP Video Streaming, Distribution & KVM",
"groups": [ { "title": "Encoders & Decoders",
"categories": [ { "title": "IP Video Streaming, Distribution & KVM", "link": "/products/ip-video-streaming-distribution-and-kvm",
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
"items": [
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
@@ -1234,10 +1236,18 @@ pages are reached from there.
}
```
- `categories[].link` / `groups[].link` (v1.32, additive): the category /
sub-category page; `""` only while a level is still a folder. `title` is
the menu label = the page's *nav_title* when set: six sub-categories are
the 2026-10 category pages (Joe's workbooks - content, slug and SEO kept,
e.g. "Encoders & Decoders" → `/products/video-encoding-and-streaming`,
H1 "Video Encoding & Streaming"). Breadcrumbs run
Home › Products › category › sub-category › overview page.
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
`target` holds zero-based indices into `categories` / `groups` / `items`:
with `page` it points to an overview page (and `link` is that page),
without `page` it opens a whole group (`link` is `""`).
without `page` it opens a whole group (`link` is `""`; the group's own
page is `groups[].link` of the target).
- `products` are the product cards of the overview page that link a detail
page (container children included, creation order); `productCount` is
their number - a card without a resolvable product is not counted.
@@ -1251,15 +1261,9 @@ pages are reached from there.
- Hidden pages and pages with *Hide in menu* drop out; order = page tree
order. Version 1 (2026-10-09, sys_category groups with products) is no
longer the source.
- **Transitional v1 fields (deprecated, removed once the front end reads
`items`).** The deployed front end still evaluated the v1 shape for the
active state of the Products trigger and crashed the whole site without
it. Until it has switched, v2 additionally carries: `categories[].uid`
(folder uid) and `categories[].link` (`/products`); `groups[].uid` (`0`),
`groups[].link` (link of the group's first overview page) and
`groups[].products` (the products of all its overview pages, deduplicated
by link); and a `teaser` (card text, plain) on every product. Do not build
on these fields.
- The transitional v1 fields of v1.28 (`groups[].products`, `uid`s,
product `teaser`) were removed on 2026-11-03 after the front end switched
(0024-FE / 0029-BE). The `link` fields of v1.32 are new and permanent.
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
since v1.30).** Page-tree entries build their `columns` live from the
@@ -1706,4 +1710,4 @@ remediation.
- Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVO‑VITEC‑HL‑001 v1.31.*
*End of document EVO‑VITEC‑HL‑001 v1.32.*