Compare commits

...
3 Commits
Author SHA1 Message Date
orascheandClaude Fable 5 2c0da281bd SSR switch: site setting vitec.ssr.enabled gates .htaccess proxy to SSR node
- Site set Vitecset: new bool setting vitec.ssr.enabled (default off,
  backend switch under Site Management > Settings > VITEC Settings)
- SsrFlagService derives var/vitec-ssr-on.flag from the setting;
  SyncSsrFlag middleware (frontend + backend) re-syncs per request
  because TYPO3 v14 fires no event on site settings save;
  vitec:ssr-flag CLI command for status/--sync
- public/.htaccess: flag-gated block proxies page requests [P] to
  https://vitec-ssr.evomedien.de (mod_proxy incl. https target verified
  on this host 2026-11-09), same exclusions as the SPA fallback block;
  completely inert while the flag file is absent
- Emergency off without TYPO3: delete var/vitec-ssr-on.flag
- Tested end to end: flag on -> pages proxied to VPS Caddy (502 while
  the SSR node is not deployed yet), /_json untouched; flag off ->
  SPA shell exactly as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 13:35:27 +01:00
orascheandClaude Opus 5.5 bd7a145afa 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>
2026-11-05 12:05:04 +01:00
orascheandClaude Opus 5.5 73e4298b75 Insights spotlight: news from folders 11 + 442, source editable in the menu
- Megamenu entries with the catalog or spotlight layout get two editor
  fields, "Data source folders" (source_pages) and "Data source
  categories" (source_categories); the extension stores them, the site
  package's listener reads them from the entry row.
- ProvideInsightsSpotlight reads its news from those folders (empty =
  11 + 442; the newer posts live in 442, the query only read 11) and,
  when set, only from those categories (Insights: Blog Post + Press
  Release). Newest datetime first, three cards.
- Pages carrying the menu are tagged with the news and event tables, so
  saving such a record refreshes the menu without a cache flush.
- VITEC field descriptions via a TCA override; spec v1.31, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:59:56 +01:00
18 changed files with 1008 additions and 94 deletions

No files matched your search

@@ -43,7 +43,7 @@ return [
'1' => [
'showitem' => '
--div--;' . $ll . 'tab.general,
hidden, title, link, layout, tree_display,
hidden, title, link, layout, tree_display, source_pages, source_categories,
--div--;' . $ll . 'tab.columns,
menu_columns,
--div--;' . $ll . 'tab.teasers,
@@ -131,6 +131,27 @@ return [
],
],
],
// data-driven layouts: where the site package's listener reads its
// records from - the extension only passes the entry row on
'source_pages' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_pages',
'description' => $ll . 'source_pages.description',
'config' => [
'type' => 'group',
'allowed' => 'pages',
'size' => 3,
'maxitems' => 20,
],
],
'source_categories' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_categories',
'description' => $ll . 'source_categories.description',
'config' => [
'type' => 'category',
],
],
'menu_columns' => [
'label' => $ll . 'menu_columns',
+14 -3
View File
@@ -117,6 +117,13 @@ final class ProvideCatalog
`getEntry()` hands the listener the raw entry row (to tell several catalog
entries apart), `getStage()` the content stage.
**Data source fields** (catalog and spotlight entries): *Data source
folders* (`source_pages`, comma list of page uids) and *Data source
categories* (`source_categories`, sys_category MM with
`tablenames = tx_evomegamenujson_entry`, `fieldname = source_categories`).
The extension only stores them; the listener reads them from the entry row
and decides what they mean (empty = its own default).
### Layout `pagetree`
A structure panel that can never age against the site: the columns mirror
@@ -156,9 +163,13 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
}
```
- `news`: the three latest news records (storage pid 11, type 0 - the Solr
convention), `link` = `/news/article/<path_segment>` per the NewsDetail
route enhancer, image from `fal_media` as processed 640px webp.
- `news`: the three latest news records (type 0 - the Solr convention)
from the entry's *Data source folders* (empty = 11 + 442) and, when set,
in one of its *Data source categories*; `link` =
`/news/article/<path_segment>` per the NewsDetail route enhancer, image
from `fal_media` as processed 640px webp. Pages carrying the menu are
tagged with the news and event tables, so saving such a record refreshes
the menu.
- `events`: the next two events (`hideonwebsite` = 0, upcoming by
eventstart/eventend), `link` = the record's `eventurl` or the events
page, image processed the same way.
@@ -27,6 +27,10 @@
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages"><source>Data source folders</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages.description"><source>For data-driven panels: the folders the site package reads its records from (e.g. news folders). Empty = the site package's default.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories"><source>Data source categories</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories.description"><source>For data-driven panels: only records in one of these categories. Empty = no category restriction.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
@@ -9,6 +9,8 @@ CREATE TABLE tx_evomegamenujson_entry (
link varchar(1024) DEFAULT '' NOT NULL,
layout varchar(30) DEFAULT 'columns' NOT NULL,
tree_display varchar(30) DEFAULT 'columns' NOT NULL,
source_pages text,
source_categories int(11) unsigned DEFAULT '0' NOT NULL,
menu_columns int(11) DEFAULT '0' NOT NULL,
menu_teasers int(11) DEFAULT '0' NOT NULL,
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
@@ -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
{
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Shows (and on --sync repairs) the SSR routing state: the Site Set
* switch "vitec.ssr.enabled" versus the flag file that .htaccess
* evaluates. The SyncSsrFlag middleware does the same sync on every
* request, so this command is mainly for diagnostics and scripting.
*
* vendor/bin/typo3 vitec:ssr-flag # status only
* vendor/bin/typo3 vitec:ssr-flag --sync # align flag with setting
*/
#[AsCommand(name: 'vitec:ssr-flag', description: 'Show/sync the SSR routing flag against the vitec.ssr.enabled site setting')]
final class SsrFlagCommand extends Command
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('sync', null, InputOption::VALUE_NONE, 'Create/remove the flag file so it matches the setting');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$setting = $this->ssrFlagService->isEnabledInSettings();
$flag = $this->ssrFlagService->isFlagSet();
$output->writeln('Setting vitec.ssr.enabled: ' . ($setting ? 'ON' : 'off'));
$output->writeln('Flag ' . $this->ssrFlagService->getFlagPath() . ': ' . ($flag ? 'EXISTS (SSR routing active)' : 'absent (SPA as before)'));
if ($setting === $flag) {
$output->writeln('<info>In sync.</info>');
return Command::SUCCESS;
}
if (!$input->getOption('sync')) {
$output->writeln('<comment>OUT OF SYNC - run with --sync (or just hit any page: the middleware repairs it).</comment>');
return Command::FAILURE;
}
$new = $this->ssrFlagService->sync();
$output->writeln('<info>Synced: flag is now ' . ($new ? 'SET - SSR routing active.' : 'removed - SPA as before.') . '</info>');
return Command::SUCCESS;
}
}
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Resource\FileRepository;
@@ -19,17 +22,23 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* news and the next events as image cards, queried live - editors touch
* news and event records, never the menu.
*
* Conventions mirrored from the site set: news live on pid 11 (type 0 only,
* same filter as the Solr indexing TS), their details render on page 21
* through the NewsDetail route enhancer (/news/article/<path_segment>).
* Conventions mirrored from the site set: news are type 0 only (same filter
* as the Solr indexing TS), their details render on page 21 through the
* NewsDetail route enhancer (/news/article/<path_segment>).
* Events come from the vitec event table; an event links to its own
* eventurl when set, otherwise to the events page.
*
* News sources (2026-11-05, ticket 0046-FE: the newer posts sit in folder
* 442, the query only read 11): the entry's "Data source folders" and
* "Data source categories" decide, editable in the menu record. No folder
* set = 11 + 442 (like the /insights/news plugin), no category = all.
*/
#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')]
final class ProvideInsightsSpotlight
{
private const NEWS_TABLE = 'tx_news_domain_model_news';
private const NEWS_STORAGE_PID = 11;
private const DEFAULT_NEWS_PIDS = [11, 442];
private const ENTRY_TABLE = 'tx_evomegamenujson_entry';
private const NEWS_DETAIL_PATH = '/news/article/';
private const EVENT_TABLE = 'tx_vitec_domain_model_event';
private const EVENTS_PAGE = '/insights/events';
@@ -44,25 +53,83 @@ final class ProvideInsightsSpotlight
public function __invoke(BuildSpotlightEvent $event): void
{
$stage = $event->getStage();
$entry = $event->getEntry();
// group field: "11,442" (older notation "pages_11" tolerated)
$pids = array_values(array_filter(array_map(
static fn(string $value): int => (int)preg_replace('/^\D+_/', '', trim($value)),
explode(',', (string)($entry['source_pages'] ?? ''))
)));
$event->setSpotlight([
'news' => $this->latestNews($stage),
'news' => $this->latestNews($stage, $pids ?: self::DEFAULT_NEWS_PIDS, $this->entryCategories((int)($entry['uid'] ?? 0))),
'events' => $this->upcomingEvents($stage),
]);
$this->tagPageCache();
}
/** @return array<int,array<string,mixed>> */
private function latestNews(int $stage): array
/**
* The menu sits in every cached page; tagging the page with the news and
* event tables lets the DataHandler refresh those pages as soon as an
* editor saves a news or event record (it flushes the tag `<table>`).
*/
private function tagPageCache(): void
{
try {
$collector = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('frontend.cache.collector');
if ($collector instanceof CacheDataCollectorInterface) {
$collector->addCacheTags(new CacheTag(self::NEWS_TABLE), new CacheTag(self::EVENT_TABLE));
}
} catch (\Throwable) {
// untagged, the menu only ages until the next flush
}
}
/**
* Categories picked in the entry's "Data source categories".
*
* @return int[]
*/
private function entryCategories(int $entryUid): array
{
if ($entryUid <= 0) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
return array_map('intval', $queryBuilder->select('uid_local')->from('sys_category_record_mm')
->where(
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::ENTRY_TABLE)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('source_categories')),
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($entryUid, ParameterType::INTEGER))
)
->executeQuery()->fetchFirstColumn());
}
/**
* @param int[] $pids news folders
* @param int[] $categories only news in one of these; empty = all
* @return array<int,array<string,mixed>>
*/
private function latestNews(int $stage, array $pids, array $categories): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
try {
$rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
$queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
->from(self::NEWS_TABLE)
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)),
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
)
->orderBy('datetime', 'DESC')
);
if ($categories !== []) {
$mm = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
$mm->select('uid_foreign')->from('sys_category_record_mm')
->where(
$mm->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::NEWS_TABLE)),
$mm->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories')),
$mm->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
);
$queryBuilder->andWhere($queryBuilder->expr()->in('uid', $mm->getSQL()));
}
$rows = $queryBuilder->orderBy('datetime', 'DESC')
->setMaxResults(self::NEWS_COUNT)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
@@ -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;
}
/**
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Service\SsrFlagService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Self-healing sync of the SSR routing flag (see SsrFlagService).
*
* Registered for BOTH stacks: backend, so flipping the Site Set switch
* takes effect with the very next backend request (the module reload
* right after saving), and frontend, so /_json traffic repairs the flag
* even if nobody is logged into the backend. When flag and setting
* agree - the normal case - this costs a single file_exists() per
* request.
*/
final class SyncSsrFlag implements MiddlewareInterface
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->ssrFlagService->syncQuietly();
return $handler->handle($request);
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Keeps the SSR routing flag file in sync with the site setting
* "vitec.ssr.enabled" (Site Set switch, editable in the backend under
* Site Management > Settings).
*
* The flag file is what public/.htaccess checks per request (RewriteCond
* ... -f): if it exists, page requests are proxied to the SSR node on the
* Solr VPS; if not, the SPA fallback serves _frontend/index.html as
* before. TYPO3 itself never routes differently - the flag only steers
* Apache, which is why a plain file is used: zero runtime cost in the
* hot path and trivially inspectable on the server.
*
* Saving site settings in the TYPO3 v14 backend fires NO event (core
* SiteWriter::writeSettings dispatches nothing, checked 2026-11-09), so
* the sync runs self-healingly from the SyncSsrFlag middleware on every
* frontend and backend request - a mismatch costs one file operation
* once, agreement costs a file_exists(). The vitec:ssr-flag CLI command
* offers the same sync plus a status view.
*/
final class SsrFlagService
{
public function __construct(private readonly SiteFinder $siteFinder)
{
}
public function getFlagPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-on.flag';
}
/**
* True as soon as ANY site enables the setting (there is only the
* "vitec" site; iterating keeps us independent of the identifier).
*/
public function isEnabledInSettings(): bool
{
try {
foreach ($this->siteFinder->getAllSites() as $site) {
if ((bool)$site->getSettings()->get('vitec.ssr.enabled', false)) {
return true;
}
}
} catch (SiteNotFoundException) {
// No sites configured - treat as disabled.
}
return false;
}
public function isFlagSet(): bool
{
return is_file($this->getFlagPath());
}
/**
* Creates or removes the flag file so it matches the setting.
*
* @return bool|null The new state if something changed, null if the
* flag already matched the setting.
*/
public function sync(): ?bool
{
$desired = $this->isEnabledInSettings();
if ($desired === $this->isFlagSet()) {
return null;
}
if ($desired) {
file_put_contents(
$this->getFlagPath(),
"SSR routing is ON (site setting vitec.ssr.enabled).\n"
. "Managed by SsrFlagService - do not create/delete manually,\n"
. "flip the switch in Site Management > Settings instead.\n"
. 'Last sync: ' . date('c') . "\n"
);
} else {
@unlink($this->getFlagPath());
}
return $desired;
}
/**
* Sync variant for the request hot path: never lets an IO hiccup
* (read-only FS, concurrent unlink ...) break request handling.
*/
public function syncQuietly(): void
{
try {
$this->sync();
} catch (\Throwable) {
// Intentionally silent - next request retries.
}
}
}
@@ -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']];
@@ -46,5 +46,23 @@ return [
'typo3/cms-frontend/site',
],
],
// Self-healing sync of the SSR routing flag file against the
// Site Set switch vitec.ssr.enabled (no core event fires on
// settings save, hence per-request sync; cheap file_exists when
// nothing changed). Also registered for the backend below.
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
],
'backend' => [
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
],
];
@@ -132,3 +132,10 @@ settings:
category: seo
type: string
default: ''
vitec.ssr.enabled:
label: 'SSR delivery (serve pages from the SSR node)'
description: 'ON: page requests are proxied by .htaccess to the SSR server (React Router SSR on the Solr VPS) - the flag file var/vitec-ssr-on.flag is kept in sync automatically within seconds. OFF: pages are served as today (SPA shell from _frontend/). JSON under /_json, /typo3, /api etc. are never affected. Safe rollback switch.'
category: VITEC
type: bool
default: false
@@ -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):
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* VITEC meaning of the megamenu's data-source fields (2026-11-05, ticket
* 0046-FE): the Insights spotlight (ProvideInsightsSpotlight) reads its
* "Latest news" cards from these folders and categories.
*/
defined('TYPO3') or die();
if (isset($GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages'])) {
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages']['description']
= 'Insights: news folders for the "Latest news" cards. Empty = 11 + 442 (like the /insights/news list).';
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_categories']['description']
= 'Insights: only news in one of these categories (e.g. Blog Post, Press Release). Empty = all categories.';
}
@@ -3,9 +3,9 @@
| | |
|---|---|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
| **Version** | 1.30 |
| **Version** | 1.32 |
| **Status** | Released |
| **Date** | 2026‑11‑02 |
| **Date** | 2026‑11‑05 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch |
@@ -43,6 +43,8 @@
| 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. |
| 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
@@ -1210,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/…" } ],
@@ -1233,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.
@@ -1250,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
@@ -1705,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.30.*
*End of document EVO‑VITEC‑HL‑001 v1.32.*
+23
View File
@@ -345,6 +345,29 @@ AddDefaultCharset utf-8
RewriteCond %{QUERY_STRING} __typo3_install
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L]
# --- VITEC SSR routing (flag-gated) -----------------------------------
# Active ONLY while the flag file below exists. Mirrors the SPA
# fallback's exclusions but PROXIES page requests to the SSR node on
# the Solr VPS instead of serving the React shell (mod_proxy incl.
# https target verified on this host 2026-11-09, see Nextcloud
# vitec-solr/ssr/README-SSR-Setup.md). The flag is written/removed by
# EXT:vitec (Site Set switch "vitec.ssr.enabled" -> SsrFlagService,
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
# Emergency off without TYPO3: delete the flag file.
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L]
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-l
RewriteCond %{REQUEST_URI} !^/_json(/|$)
RewriteCond %{REQUEST_URI} !^/typo3(/|$)
RewriteCond %{REQUEST_URI} !^/download/
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/_frontend/
RewriteCond %{REQUEST_URI} !^/sitemap
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L]
# ----------------------------------------------------------------------
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
# would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/.