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:
1 parent
73e4298b75
commit
bd7a145afa
6 files changed
+627
-79
No files matched your search
@@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
* below /products:
|
* below /products:
|
||||||
*
|
*
|
||||||
* /products
|
* /products
|
||||||
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
|
* category page (A, /products/<slug>) e.g. "Platforms and End-Points"
|
||||||
* [folder] sub-category (B, menu label) e.g. "End-Points"
|
* sub-category page (B, /products/<slug>) e.g. "End-Points"
|
||||||
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
|
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
|
||||||
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
|
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
|
||||||
*
|
*
|
||||||
* The page tree is the single source of the products menu (ProvideProduct
|
* The page tree is the single source of the products menu (ProvideProduct
|
||||||
* Catalog reads it): editors rename, reorder or move overview pages there.
|
* Catalog reads it): editors rename, reorder or move overview pages there.
|
||||||
* A and B are sysfolders - never routed, never in navigation menus. The 20
|
* All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when
|
||||||
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
|
* new or still empty, get generated content: hero (page title, placeholder
|
||||||
* are new or still empty, generated content: hero (page title, placeholder
|
|
||||||
* background - 0026-FE), one intro paragraph per grouping title (or
|
* background - 0026-FE), one intro paragraph per grouping title (or
|
||||||
* "Product range"), a product card per sub-product (record teaser, link
|
* "Product range"), a product card per sub-product (record teaser, link
|
||||||
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
|
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
|
||||||
* no image get the placeholder backfilled; any existing reference wins.
|
* 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):
|
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
|
||||||
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
|
* - 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
|
* Sitemap names that do not exist as records - mapped onto the existing
|
||||||
* record by slug. The Vision "+1" names are almost certainly an Excel
|
* 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 array $report = [];
|
||||||
private int $newCounter = 0;
|
private int $newCounter = 0;
|
||||||
private bool $apply = false;
|
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 */
|
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
|
||||||
private ?array $products = null;
|
private ?array $products = null;
|
||||||
|
|
||||||
@@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command
|
|||||||
$shortcuts = []; // [id, spec]
|
$shortcuts = []; // [id, spec]
|
||||||
|
|
||||||
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
|
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
|
||||||
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
|
$categoryId = $this->section($rootId, $categoryTitle, 'category');
|
||||||
|
$categoryChildren = [];
|
||||||
foreach ($groups as [$groupTitle, $entries]) {
|
foreach ($groups as [$groupTitle, $entries]) {
|
||||||
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
|
$groupId = $this->section($categoryId, $groupTitle, 'sub-category');
|
||||||
$groupFolderIds[$groupTitle] = $groupId;
|
$groupFolderIds[$groupTitle] = $groupId;
|
||||||
|
$groupChildren = [];
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if (isset($entry['shortcut'])) {
|
if (isset($entry['shortcut'])) {
|
||||||
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
|
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
|
||||||
|
$groupChildren[] = ['shortcut' => $entry];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
|
$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]) {
|
foreach ($shortcuts as [$shortcutId, $spec]) {
|
||||||
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
|
$target = $targetOf($spec);
|
||||||
if ($target === null) {
|
if ($target === null) {
|
||||||
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
|
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
|
||||||
continue;
|
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'] = ctype_digit($target) ? (int)$target : $target;
|
||||||
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
|
$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) {
|
foreach ($this->report as $line) {
|
||||||
$output->writeln($line);
|
$output->writeln($line);
|
||||||
@@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command
|
|||||||
return Command::SUCCESS;
|
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 = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
$dataHandler->start($this->datamap, []);
|
$dataHandler->start($this->datamap, []);
|
||||||
$dataHandler->process_datamap();
|
$dataHandler->process_datamap();
|
||||||
@@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
$this->applyOrder($dataHandler->substNEWwithIDs);
|
$this->applyOrder($dataHandler->substNEWwithIDs);
|
||||||
|
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
|
||||||
|
|
||||||
$output->writeln("\n<info>Written.</info> Next, per overview page:");
|
$output->writeln("\n<info>Written.</info> Next, per page with generated cards:");
|
||||||
foreach ($overviewBySlug as $slug => $id) {
|
foreach ($this->sectionsToFill as $id) {
|
||||||
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
|
$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;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- structure -------------------------------------------------------------------
|
// ---- 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;
|
$existing = ctype_digit($parentId)
|
||||||
if ($existing !== null) {
|
? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null;
|
||||||
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
|
if (isset(self::ADOPT[$title])) {
|
||||||
$this->pageOrder[$parentId][] = (string)$existing;
|
return $this->adopt($parentId, $title, $existing);
|
||||||
return (string)$existing;
|
|
||||||
}
|
}
|
||||||
$id = $this->newId();
|
$slug = '/products/' . self::SECTION_SLUGS[$title];
|
||||||
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
|
if ($existing === null) {
|
||||||
|
$id = $this->newId();
|
||||||
|
$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->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->pageOrder[$parentId][] = $id;
|
||||||
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
|
$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;
|
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
|
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) {
|
if ($existing !== null) {
|
||||||
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
|
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
|
||||||
$this->pageOrder[$groupId][] = (string)$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]
|
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
|
||||||
);
|
);
|
||||||
[$cards, $names] = $this->resolveItems($entry['items']);
|
[$cards, $names] = $this->resolveItems($entry['items']);
|
||||||
|
$this->overviewNames[$entry['slug']] = implode(', ', $names);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$pageId = (string)(int)$existing['uid'];
|
$pageId = (string)(int)$existing['uid'];
|
||||||
$this->pageOrder[$groupId][] = $pageId;
|
$this->pageOrder[$groupId][] = $pageId;
|
||||||
$updates = [];
|
$updates = [];
|
||||||
if ((string)(int)$existing['pid'] !== $groupId) {
|
if (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) {
|
||||||
$updates['pid'] = $this->pid($groupId); // moved into its folder
|
$updates['pid'] = $this->pid($groupId); // moved into its sub-category
|
||||||
}
|
}
|
||||||
if ($updates !== []) {
|
if ($updates !== []) {
|
||||||
$this->datamap['pages'][$pageId] = $updates;
|
$this->datamap['pages'][$pageId] = $updates;
|
||||||
@@ -509,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command
|
|||||||
|
|
||||||
// ---- lookups -----------------------------------------------------------------------
|
// ---- 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(
|
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative(
|
||||||
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
|
'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ')
|
||||||
[$parentUid, $title, $doktype]
|
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}> */
|
/** @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
|
* -> "Scalable architecture"); bodytext stays empty until the agency
|
||||||
* delivers copy (0036: a card without text is fine).
|
* delivers copy (0036: a card without text is fine).
|
||||||
*
|
*
|
||||||
* The band lands directly AFTER the first element of the main column (the
|
* The band lands at the TOP of the main column, before the "Product range"
|
||||||
* intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph
|
* intro and its cards (0042-FE: placed after the intro it split heading and
|
||||||
* is skipped - editors own the band from then on. The Vision sub-group icon
|
* 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
|
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
|
||||||
* decision, reported only.
|
* decision, reported only.
|
||||||
*
|
*
|
||||||
@@ -44,7 +48,7 @@ final class BuildWhyVitecBandsCommand extends Command
|
|||||||
private const HEADER = 'Why VITEC?';
|
private const HEADER = 'Why VITEC?';
|
||||||
private const COLPOS = 0;
|
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 = [
|
private const BANDS = [
|
||||||
'activesqx' => '/products/activesqx',
|
'activesqx' => '/products/activesqx',
|
||||||
'aetria' => '/products/aetria',
|
'aetria' => '/products/aetria',
|
||||||
@@ -53,8 +57,18 @@ final class BuildWhyVitecBandsCommand extends Command
|
|||||||
'image-graphics-cards' => '/products/image-graphics-cards',
|
'image-graphics-cards' => '/products/image-graphics-cards',
|
||||||
'operator-workstations' => '/products/operator-workstations',
|
'operator-workstations' => '/products/operator-workstations',
|
||||||
'prism-transcoder' => '/products/prism-transcoder',
|
'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 */
|
/** 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'];
|
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'];
|
$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;
|
$folder = self::ICON_BASE . $context;
|
||||||
$icons = [];
|
$icons = [];
|
||||||
if ($storage->hasFolder($folder)) {
|
if ($storage->hasFolder($folder)) {
|
||||||
@@ -108,6 +113,18 @@ final class BuildWhyVitecBandsCommand extends Command
|
|||||||
continue;
|
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)));
|
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
|
||||||
foreach ($icons as $icon) {
|
foreach ($icons as $icon) {
|
||||||
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
|
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
|
||||||
@@ -160,11 +177,10 @@ final class BuildWhyVitecBandsCommand extends Command
|
|||||||
return Command::FAILURE;
|
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(
|
$order = array_merge(
|
||||||
array_slice($existingUids, 0, 1),
|
|
||||||
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
|
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
|
||||||
array_slice($existingUids, 1)
|
$existingUids
|
||||||
);
|
);
|
||||||
$sorting = 0;
|
$sorting = 0;
|
||||||
foreach ($order as $uid) {
|
foreach ($order as $uid) {
|
||||||
@@ -182,6 +198,97 @@ final class BuildWhyVitecBandsCommand extends Command
|
|||||||
return Command::SUCCESS;
|
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") */
|
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
|
||||||
private function themeTitle(string $theme): string
|
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,
|
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
|
||||||
* the menu ends at the product OVERVIEW pages.
|
* the menu ends at the product OVERVIEW pages.
|
||||||
*
|
*
|
||||||
* categories[] (A, menu label) = sysfolders directly below /products
|
* categories[] (A) = category pages directly below /products
|
||||||
* groups[] (B, menu label) = sysfolders below those
|
* groups[] (B) = sub-category pages below those
|
||||||
* items[] type "page" = overview pages (/products/<slug>)
|
* items[] type "page" = overview pages (/products/<slug>)
|
||||||
* type "shortcut" = shortcut pages, resolved to a target
|
* type "shortcut" = shortcut pages, resolved to a target
|
||||||
* group or overview page of this tree
|
* 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
|
* 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.
|
* 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
|
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The
|
||||||
* boxes of the left column ("Product highlight", "Product news") are the
|
* boxes of the left column ("Product highlight", "Product news") are the
|
||||||
* entry's editorial teaser cards, not data from here (decision Olli
|
* entry's editorial teaser cards, not data from here (decision Olli
|
||||||
@@ -81,8 +88,8 @@ final class ProvideProductCatalog
|
|||||||
if ($root === 0) {
|
if ($root === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
|
$categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
|
||||||
$groupRows = $this->children(array_column($categoryRows, 'uid'), [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]);
|
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
|
||||||
$overviewUids = array_map('intval', array_column(
|
$overviewUids = array_map('intval', array_column(
|
||||||
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
|
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]
|
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
|
||||||
$groupPos = [];
|
$groupPos = [];
|
||||||
$pagePos = [];
|
$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 = [];
|
$categories = [];
|
||||||
foreach ($categoryRows as $c => $categoryRow) {
|
foreach ($categoryRows as $c => $categoryRow) {
|
||||||
$groups = [];
|
$groups = [];
|
||||||
@@ -119,9 +132,9 @@ final class ProvideProductCatalog
|
|||||||
}
|
}
|
||||||
$items[] = $item;
|
$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
|
// shortcuts: target = a group of this tree (opens that group) or an
|
||||||
@@ -159,6 +172,12 @@ final class ProvideProductCatalog
|
|||||||
return $categories;
|
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
|
private function productsRootUid(): int
|
||||||
{
|
{
|
||||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||||
@@ -173,7 +192,9 @@ final class ProvideProductCatalog
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
|
* 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[] $parents
|
||||||
* @param int[] $doktypes
|
* @param int[] $doktypes
|
||||||
@@ -185,7 +206,7 @@ final class ProvideProductCatalog
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
$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(
|
->where(
|
||||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
|
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
|
||||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||||
@@ -194,6 +215,12 @@ final class ProvideProductCatalog
|
|||||||
)
|
)
|
||||||
->orderBy('pid')->addOrderBy('sorting')
|
->orderBy('pid')->addOrderBy('sorting')
|
||||||
->executeQuery()->fetchAllAssociative();
|
->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 >
|
* The overview pages of the products menu tree (/products > category >
|
||||||
* folder > page, see ProvideProductCatalog) with their menu path.
|
* 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
|
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
|
||||||
*/
|
*/
|
||||||
@@ -96,7 +98,7 @@ final class ProductMenuSearchJsonRenderer
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$queryBuilder = $pool->getQueryBuilderForTable('pages');
|
$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(
|
->where(
|
||||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
|
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
|
||||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||||
@@ -104,9 +106,16 @@ final class ProductMenuSearchJsonRenderer
|
|||||||
)
|
)
|
||||||
->orderBy('sorting')
|
->orderBy('sorting')
|
||||||
->executeQuery()->fetchAllAssociative();
|
->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');
|
$categories = array_column($level([$root], [1, 254]), null, 'uid');
|
||||||
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
|
$groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid');
|
||||||
$overviews = [];
|
$overviews = [];
|
||||||
foreach ($level(array_keys($groups), [1]) as $page) {
|
foreach ($level(array_keys($groups), [1]) as $page) {
|
||||||
$group = $groups[(int)$page['pid']];
|
$group = $groups[(int)$page['pid']];
|
||||||
|
|||||||
@@ -331,6 +331,17 @@ tt_content {
|
|||||||
page.10.fields.jsonLd = USER
|
page.10.fields.jsonLd = USER
|
||||||
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
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
|
# Frontend resolution for the record links from the link
|
||||||
# browser (page.tsconfig TCEMAIN.linkHandler):
|
# browser (page.tsconfig TCEMAIN.linkHandler):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||||
| **Version** | 1.31 |
|
| **Version** | 1.32 |
|
||||||
| **Status** | Released |
|
| **Status** | Released |
|
||||||
| **Date** | 2026‑11‑05 |
|
| **Date** | 2026‑11‑05 |
|
||||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
| **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.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.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.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,
|
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
|
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).**
|
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
|
||||||
The catalog payload is produced by the `ProvideProductCatalog` listener of
|
The catalog payload is produced by the `ProvideProductCatalog` listener of
|
||||||
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
|
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
|
||||||
`/products`: sysfolders on the first level are the menu **categories**,
|
`/products`: the pages on the first level are the menu **categories**, the
|
||||||
sysfolders below them the **groups** (both labels, never pages), and the
|
pages below them the **groups** (sub-categories), and the pages below the
|
||||||
pages below the groups the **overview pages** (`/products/<slug>`, built by
|
groups the **overview pages** (`/products/<slug>`, built by
|
||||||
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
|
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
|
||||||
**shortcut** pages. The menu ends at the overview pages; product detail
|
**shortcut** pages. Since v1.32 categories and groups are clickable pages
|
||||||
pages are reached from there.
|
with `link` (they were sysfolders/labels before); product detail pages are
|
||||||
|
reached from the overview pages.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
|
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
|
||||||
"viewAll": { "title": "View all products", "link": "/products" },
|
"viewAll": { "title": "View all products", "link": "/products" },
|
||||||
"categories": [ { "title": "IP Video Streaming, Distribution & KVM",
|
"categories": [ { "title": "IP Video Streaming, Distribution & KVM", "link": "/products/ip-video-streaming-distribution-and-kvm",
|
||||||
"groups": [ { "title": "Encoders & Decoders",
|
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
||||||
"items": [
|
"items": [
|
||||||
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
||||||
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
"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
|
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
|
||||||
`target` holds zero-based indices into `categories` / `groups` / `items`:
|
`target` holds zero-based indices into `categories` / `groups` / `items`:
|
||||||
with `page` it points to an overview page (and `link` is that page),
|
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
|
- `products` are the product cards of the overview page that link a detail
|
||||||
page (container children included, creation order); `productCount` is
|
page (container children included, creation order); `productCount` is
|
||||||
their number - a card without a resolvable product is not counted.
|
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
|
- 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
|
order. Version 1 (2026-10-09, sys_category groups with products) is no
|
||||||
longer the source.
|
longer the source.
|
||||||
- **Transitional v1 fields (deprecated, removed once the front end reads
|
- The transitional v1 fields of v1.28 (`groups[].products`, `uid`s,
|
||||||
`items`).** The deployed front end still evaluated the v1 shape for the
|
product `teaser`) were removed on 2026-11-03 after the front end switched
|
||||||
active state of the Products trigger and crashed the whole site without
|
(0024-FE / 0029-BE). The `link` fields of v1.32 are new and permanent.
|
||||||
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.
|
|
||||||
|
|
||||||
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
|
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
|
||||||
since v1.30).** Page-tree entries build their `columns` live from the
|
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.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `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.*
|
||||||
Reference in new issue
Block a user