';
+ }
+
+ /** @return array{uid:int,layout:int}|null */
+ private function childByTitle(int $parentUid, string $title): ?array
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
+ foreach ($connection->fetchAllAssociative(
+ 'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid]
+ ) as $row) {
+ if ($this->norm((string)$row['title']) === $this->norm($title)) {
+ return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']];
+ }
+ }
+ return null;
+ }
+
+ // ---- element plumbing (pattern of the sibling importers) -----------------------
+
+ private function slotNext(int $pageUid, int $colPos, string $ctype): string
+ {
+ $key = $colPos . '/' . $ctype;
+ if (!isset($this->slotCursor[$key])) {
+ $this->slotCursor[$key] = $this->slotElements($pageUid, $colPos, $ctype);
+ }
+ $uid = array_shift($this->slotCursor[$key]);
+ if ($uid !== null) {
+ $this->sequence[$colPos][] = (string)$uid;
+ return (string)$uid;
+ }
+ $id = $this->newId();
+ $this->datamap['tt_content'][$id] = [
+ 'pid' => $pageUid,
+ 'colPos' => $colPos,
+ 'CType' => $ctype,
+ ];
+ $this->sequence[$colPos][] = $id;
+ return $id;
+ }
+
+ /** @return int[] uids in sorting order */
+ private function slotElements(int $pageUid, int $colPos, string $ctype): array
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
+ return array_map('intval', $connection->fetchFirstColumn(
+ 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
+ [$pageUid, $colPos, $ctype]
+ ));
+ }
+
+ /**
+ * Writes tt_content.sorting in page order (DataHandler puts NEW records
+ * with a positive pid at the TOP of their column - lesson 2026-10-07).
+ *
+ * @param array $substNEWwithIDs
+ */
+ private function applySequence(array $substNEWwithIDs): void
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
+ foreach ($this->sequence as $ids) {
+ $sorting = 0;
+ foreach ($ids as $id) {
+ $uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
+ if ($uid > 0) {
+ $sorting += 256;
+ $connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
+ }
+ }
+ }
+ }
+
+ /** @param array $fields */
+ private function setFields(string $table, string $id, array $fields): void
+ {
+ foreach ($fields as $field => $value) {
+ $this->datamap[$table][$id][$field] = $value;
+ }
+ }
+
+ /** Grey numbered stand-in, same rules as the sibling importers. */
+ private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int $pid, int $slot): void
+ {
+ if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
+ return;
+ }
+ $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
+ $identifier = null;
+ if ($slot > 0) {
+ $candidate = 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png';
+ if ($storage->hasFile($candidate)) {
+ $identifier = $candidate;
+ }
+ }
+ $identifier ??= 'placeholders/' . ($fieldName === 'vitec_bgimage' ? 'placeholder-grey-wide.png' : 'placeholder-grey.png');
+ if (!$storage->hasFile($identifier)) {
+ $this->report[] = $fieldName . ': empty in the workbook and fileadmin/' . $identifier . ' is missing - left empty';
+ return;
+ }
+ if (!$apply) {
+ $this->report[] = $fieldName . ': empty in the workbook - would attach ' . $identifier;
+ return;
+ }
+ if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($identifier), $pid) !== 'unchanged') {
+ $this->report[] = $fieldName . ': placeholder ' . basename($identifier) . ' attached - swap in the real asset later';
+ }
+ }
+
+ /** Content-idempotent FAL attach (lesson 2026-10-02), as in the siblings. */
+ private function attachFalFile(string $elementId, string $fieldName, \TYPO3\CMS\Core\Resource\File $file, int|string $pid): string
+ {
+ if (!str_starts_with($elementId, 'NEW')) {
+ $existingRefs = $this->liveReferences((int)$elementId, $fieldName);
+ if ($existingRefs !== []) {
+ $first = array_shift($existingRefs);
+ $state = 'unchanged';
+ if ((int)$first['uid_local'] !== $file->getUid()) {
+ $this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
+ $state = 'swapped';
+ }
+ foreach ($existingRefs as $surplus) {
+ $this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
+ $this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
+ }
+ return $state;
+ }
+ }
+ $refId = $this->newId();
+ // pid, tablenames and uid_foreign are REQUIRED - without them
+ // DataHandler silently rejects the reference (lesson 2026-10-02).
+ $this->datamap['sys_file_reference'][$refId] = [
+ 'pid' => $pid,
+ 'table_local' => 'sys_file',
+ 'uid_local' => $file->getUid(),
+ 'tablenames' => 'tt_content',
+ 'fieldname' => $fieldName,
+ 'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
+ ];
+ $this->datamap['tt_content'][$elementId][$fieldName] = $refId;
+ return 'attached';
+ }
+
+ /** @return array live refs on one field, oldest first */
+ private function liveReferences(int $elementUid, string $fieldName): array
+ {
+ $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
+ return array_map(
+ static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']],
+ $connection->fetchAllAssociative(
+ "SELECT uid, uid_local FROM sys_file_reference
+ WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content'
+ AND fieldname = ? AND uid_foreign = ? ORDER BY uid",
+ [$fieldName, $elementUid]
+ )
+ );
+ }
+
+ /** Abstract from the hero intro copy, word-boundary cut near 300 chars. */
+ private function abstractFrom(string $introHtml, string $fallback): string
+ {
+ $plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
+ if ($plain === '') {
+ return $fallback;
+ }
+ if (mb_strlen($plain) <= 300) {
+ return $plain;
+ }
+ $cut = mb_substr($plain, 0, 300);
+ $pos = mb_strrpos($cut, ' ');
+ return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
+ }
+
+ /**
+ * @param array $settings
+ * @return array
+ */
+ private function flex(array $settings): array
+ {
+ $fields = [];
+ foreach ($settings as $key => $value) {
+ $fields[$key] = ['vDEF' => $value];
+ }
+ return ['data' => ['sDEF' => ['lDEF' => $fields]]];
+ }
+
+ private function newId(): string
+ {
+ return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
+ }
+
+ /** '&' -> 'and' so "Broadcast & Telecoms" matches "Broadcast and Telecoms". */
+ private function norm(string $value): string
+ {
+ return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
+ }
+}
diff --git a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php
index 0be920a..162bd95 100644
--- a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php
+++ b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php
@@ -122,14 +122,29 @@ final class SubmarketWorkbookImporter
}
// --- SEO on the page --------------------------------------------------
+ // Full page-properties set (parity with the sub-solution importer,
+ // Olli 2026-10-19/23): OG/Twitter mirror the SEO fields, canonical
+ // points at the page itself, the abstract derives from the intro copy.
$seoTitle = trim((string)($values['seo_title'] ?? ''));
$seoDescription = trim((string)($values['seo_description'] ?? ''));
if ($seoTitle !== '') {
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
+ $this->datamap['pages'][$pageId]['og_title'] = $seoTitle;
+ $this->datamap['pages'][$pageId]['twitter_title'] = $seoTitle;
}
if ($seoDescription !== '') {
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
+ $this->datamap['pages'][$pageId]['og_description'] = $seoDescription;
+ $this->datamap['pages'][$pageId]['twitter_description'] = $seoDescription;
}
+ if (!str_starts_with($pageId, 'NEW')) {
+ $this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId;
+ }
+ $abstract = $this->abstractFrom((string)($values['intro_text'] ?? ''), $seoDescription);
+ if ($abstract !== '') {
+ $this->datamap['pages'][$pageId]['abstract'] = $abstract;
+ }
+ $this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars); keywords stay editorial for now';
// --- slot 110: hero ---------------------------------------------------
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
@@ -657,10 +672,33 @@ final class SubmarketWorkbookImporter
*/
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int|string $pid, int $slot = 0): void
{
- if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
- return;
- }
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
+ // A page CREATED by the previous run got the generic grey (its uid was
+ // still NEW); when the numbered file exists by now, UPGRADE the
+ // reference on re-run. Any other existing reference stays untouched.
+ if (!str_starts_with($elementId, 'NEW')) {
+ $existing = $this->liveReferences((int)$elementId, $fieldName);
+ if ($existing !== []) {
+ $numbered = ($slot > 0 && !str_starts_with((string)$pid, 'NEW'))
+ ? 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png'
+ : null;
+ $currentIdentifier = (string)GeneralUtility::makeInstance(ConnectionPool::class)
+ ->getConnectionForTable('sys_file')
+ ->fetchOne('SELECT identifier FROM sys_file WHERE uid = ?', [(int)$existing[0]['uid_local']]);
+ $isGeneric = str_contains($currentIdentifier, '/placeholders/placeholder-grey');
+ if ($numbered === null || !$isGeneric || !$storage->hasFile($numbered)) {
+ return;
+ }
+ if (!$apply) {
+ $this->report[] = $fieldName . ': would upgrade the generic placeholder to ' . $numbered;
+ return;
+ }
+ if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($numbered), $pid) === 'swapped') {
+ $this->report[] = $fieldName . ': generic placeholder upgraded to ' . basename($numbered);
+ }
+ return;
+ }
+ }
// Numbered per page and slot - "#501-02" is rendered INTO the image -
// so a review can name the exact asset to deliver. The generic grey
// file covers pages without a pre-rendered set (e.g. brand-new ones).
@@ -720,6 +758,24 @@ final class SubmarketWorkbookImporter
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
+ /**
+ * Page abstract from the intro copy: tags stripped, whitespace collapsed,
+ * word-boundary cut near 300 characters; SEO description as fallback.
+ */
+ private function abstractFrom(string $introHtml, string $fallback): string
+ {
+ $plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
+ if ($plain === '') {
+ return $fallback;
+ }
+ if (mb_strlen($plain) <= 300) {
+ return $plain;
+ }
+ $cut = mb_substr($plain, 0, 300);
+ $pos = mb_strrpos($cut, ' ');
+ return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
+ }
+
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
diff --git a/packages/vitec/Classes/View/BackendLayoutDataProvider.php b/packages/vitec/Classes/View/BackendLayoutDataProvider.php
index 40a375a..6d24ca4 100755
--- a/packages/vitec/Classes/View/BackendLayoutDataProvider.php
+++ b/packages/vitec/Classes/View/BackendLayoutDataProvider.php
@@ -65,6 +65,8 @@ final class BackendLayoutDataProvider implements DataProviderInterface
// Solution group hero pages (Joe's Template 06, import 2026-10-23) -
// same hero/main zones as layout 5, so the front end reuses parts.
22 => ['title' => 'Solution Group Page', 'zones' => ['hero', 'main']],
+ // Market hero pages (Joe's "Markets hero sub page", import 2026-10-23).
+ 23 => ['title' => 'Market Hero Page', 'zones' => ['hero', 'main']],
];
/** Display name for each zone. Shown as column header in the BE page module. */
diff --git a/packages/vitec/Configuration/TCA/Overrides/pages.php b/packages/vitec/Configuration/TCA/Overrides/pages.php
index 1574ae4..3849d14 100755
--- a/packages/vitec/Configuration/TCA/Overrides/pages.php
+++ b/packages/vitec/Configuration/TCA/Overrides/pages.php
@@ -33,6 +33,7 @@ defined('TYPO3') or die();
['label' => $l . 'layout.20', 'value' => '20'],
['label' => $l . 'layout.21', 'value' => '21'],
['label' => $l . 'layout.22', 'value' => '22'],
+ ['label' => $l . 'layout.23', 'value' => '23'],
];
$GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0';
diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md
index 9e0bd2a..3c34a7c 100755
--- a/packages/vitec/Documentation/Headless-JSON-Architecture.md
+++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md
@@ -3,7 +3,7 @@
| | |
|---|---|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
-| **Version** | 1.24 |
+| **Version** | 1.25 |
| **Status** | Released |
| **Date** | 2026‑10‑23 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
@@ -37,6 +37,7 @@
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). |
| 1.24 | 2026‑10‑23 | **Interface change (additive).** New frontend layout **22 Solution Group Page** for the eight solution group hero pages (zones hero = `colPos1`, main = `colPos0`). Element order: `vitec_markethero` + `vitec_introparagraph` in the hero zone; `vitec_marketintro` 2, `vitec_introparagraph` + 4 `text_only` cards (challenges), `vitec_introparagraph` + 4 alternating `horizontal`/`horizontal_reverse` cards (capabilities, bullets as `
`), `vitec_usecaselist` (testimonials, shipped hidden until stories are picked), `vitec_marketinsights`, `vitec_ctabanner` in main — imported from the agency Template‑06 workbooks by `vitec:import-solution-group-workbooks`; page properties as on the sub‑solution pages. |
+| 1.25 | 2026‑10‑23 | **Interface change (additive).** The markets branch is aligned with the agreed agency structure: `/markets` eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout **23 Market Hero Page** (zones hero = `colPos1`, main = `colPos0`): `vitec_markethero` (CTA anchors `#submarkets`) + `vitec_introparagraph` (header carries the hero H1) in the hero zone; `vitec_marketintro`, `vitec_introparagraph`, sub‑market `vitec_card`s whose `primary_cta` links the child page, `vitec_ctabanner` in main (`vitec_customerlogos` and `vitec_usecaselist` ship hidden until filled editorially). Imported by `vitec:import-market-hero-workbooks`; the tree itself by `vitec:align-market-pages` (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set. |
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
@@ -1613,4 +1614,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.24.*
+*End of document EVO‑VITEC‑HL‑001 v1.25.*
diff --git a/packages/vitec/Resources/Private/Language/locallang_pages.xlf b/packages/vitec/Resources/Private/Language/locallang_pages.xlf
index 81379a6..9af865f 100755
--- a/packages/vitec/Resources/Private/Language/locallang_pages.xlf
+++ b/packages/vitec/Resources/Private/Language/locallang_pages.xlf
@@ -72,6 +72,9 @@
Solution Group Page
+
+ Market Hero Page
+