Checkpoint: headless JSON architecture documented + cleanup (Stufe 1+2)

Restore point before Stufe 3 (central resolver-service refactoring).

Includes this session's work:
- Success Story (usecase) rebuild: new fields/tabs, UsecaseSerializer,
  thin List/Show renderers, MM relations, inline content elements.
- vitec_columns content block (two-column layout with per-item content)
  incl. unified header section; special-cased JSON resolution.
- Container per-column flex (items[].config align/justify) + gap on parent.
- Unified header section across CEs/plugins/containers; header fields in JSON.
- ISO-style architecture spec: Documentation/Headless-JSON-Architecture.md.
- Cleanup: removed local scratch + verified .bak backups.
- Fixes: B-6 (undefined thumbnail variable in Downloadcardcollection image
  resolver), B-7 (unsatisfiable legacy CType OR branch in Downloadcard/Datasheets).

Rollback: git reset --hard snapshot-2026-07-09-pre-stufe3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
o-rascheandClaude Opus 4.8 committed 2026-07-09 10:01:56 +02:00
1 parent c6759186cb
commit bb7c03235d
237 files changed
+6698 -1749

No files matched your search

View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+12
View File
@@ -14,6 +14,8 @@ use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -73,6 +75,16 @@ final class ContentElementResolver
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
'news_newsdetail' => [NewsJsonRenderer::class, 'news'],
'news_newsdatemenu' => [NewsJsonRenderer::class, 'news'],
'news_categorylist' => [NewsJsonRenderer::class, 'news'],
'news_newssearchform' => [NewsJsonRenderer::class, 'news'],
'news_newssearchresult' => [NewsJsonRenderer::class, 'news'],
'news_taglist' => [NewsJsonRenderer::class, 'news'],
];
/**
+142
View File
@@ -305,6 +305,148 @@ final class StructuredDataService
return $video;
}
/**
* schema.org FAQPage from a list of question/answer pairs.
* Answers may contain RTE HTML — tags are stripped for the JSON-LD text.
*
* @param list<array{question:string,answer:string}> $items
*/
public function buildFaqPage(array $items, string $base): ?array
{
$mainEntity = [];
foreach ($items as $item) {
$question = trim((string)($item['question'] ?? ''));
$answer = trim(strip_tags((string)($item['answer'] ?? '')));
if ($question === '' || $answer === '') {
continue;
}
$mainEntity[] = [
'@type' => 'Question',
'name' => $question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => $answer,
],
];
}
if ($mainEntity === []) {
return null;
}
return [
'@type' => 'FAQPage',
'mainEntity' => $mainEntity,
];
}
/**
* schema.org ExhibitionEvent (trade-show) node from a
* tx_vitec_domain_model_event row.
*
* Google rich-result requirements: name, startDate, location.
* Recommended extras handled here: endDate, description, image, url,
* eventStatus, eventAttendanceMode, organizer.
*
* @param array<string,mixed> $e Event DB row
* @param list<string> $imageUrls Absolute or root-relative logo URLs
*/
public function buildEvent(array $e, string $base, array $imageUrls, bool $hasOrganization): ?array
{
$name = trim((string)($e['title'] ?? ''));
$start = (int)($e['eventstart'] ?? 0);
if ($name === '' || $start === 0) {
return null;
}
$event = [
'@type' => 'ExhibitionEvent',
'name' => $name,
'startDate' => date('Y-m-d', $start),
];
$end = (int)($e['eventend'] ?? 0);
if ($end > 0) {
$event['endDate'] = date('Y-m-d', $end);
}
$description = trim(strip_tags((string)($e['description'] ?? '')));
if ($description === '') {
$description = trim((string)($e['teaser'] ?? ''));
}
if ($description !== '') {
$event['description'] = $description;
}
// Location: Place with venue + city/country, or VirtualLocation for online.
$mode = (string)($e['attendancemode'] ?? 'offline');
$venue = trim((string)($e['venue'] ?? ''));
$city = trim((string)($e['city'] ?? ''));
$country = trim((string)($e['country'] ?? ''));
if ($mode === 'online') {
$url = trim((string)($e['eventurl'] ?? ''));
if ($url !== '') {
$event['location'] = [
'@type' => 'VirtualLocation',
'url' => $this->absUrl($url, $base),
];
}
} else {
$address = array_filter([
'@type' => 'PostalAddress',
'addressLocality' => $city !== '' ? $city : null,
'addressCountry' => $country !== '' ? $country : null,
]);
$place = array_filter([
'@type' => 'Place',
'name' => $venue !== '' ? $venue : ($city !== '' ? $city : null),
'address' => count($address) > 1 ? $address : null,
]);
if (count($place) > 1) {
$event['location'] = $place;
}
}
$statusMap = [
'scheduled' => 'https://schema.org/EventScheduled',
'cancelled' => 'https://schema.org/EventCancelled',
'postponed' => 'https://schema.org/EventPostponed',
'rescheduled' => 'https://schema.org/EventRescheduled',
'movedonline' => 'https://schema.org/EventMovedOnline',
];
$event['eventStatus'] = $statusMap[(string)($e['eventstatus'] ?? 'scheduled')] ?? $statusMap['scheduled'];
$modeMap = [
'offline' => 'https://schema.org/OfflineEventAttendanceMode',
'online' => 'https://schema.org/OnlineEventAttendanceMode',
'mixed' => 'https://schema.org/MixedEventAttendanceMode',
];
$event['eventAttendanceMode'] = $modeMap[$mode] ?? $modeMap['offline'];
$images = [];
foreach ($imageUrls as $imageUrl) {
$abs = $this->absUrl((string)$imageUrl, $base);
if ($abs !== '') {
$images[] = $abs;
}
}
if ($images !== []) {
$event['image'] = $images;
}
$eventUrl = trim((string)($e['eventurl'] ?? ''));
if ($eventUrl !== '' && !str_starts_with($eventUrl, 't3://')) {
$event['url'] = $this->absUrl($eventUrl, $base);
}
if ($hasOrganization) {
$event['organizer'] = ['@id' => $base . '/#organization'];
}
return $event;
}
/**
* Wrap one or more schema nodes into a single @graph document string.
*
@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the
* headless JSON shapes consumed by the React frontend.
*
* serializeListItem() -> lean card data for the list plugin
* serializeDetail() -> full detail (hero, content elements, related, seo)
*
* Inline content elements are resolved flat; the VITEC Columns content block
* (vitec_columns) is resolved specially into { header…, layout, columns:{…} }.
*
* All relation/FAL resolution lives here so the List/Show UserFunc renderers
* stay thin. Every resolver is exception-safe and returns null/[] on failure.
*/
final class UsecaseSerializer
{
private const TABLE = 'tx_vitec_domain_model_usecase';
private const IMAGE_WIDTHS = [400, 800, 1200, 1600];
private const COLUMNS_CTYPE = 'vitec_columns';
/**
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function serializeListItem(array $u): array
{
$uid = (int)$u['uid'];
return [
'uid' => $uid,
'title' => (string)($u['title'] ?? ''),
'slug' => (string)($u['slug'] ?? ''),
'subtitle' => (string)($u['subtitle'] ?? ''),
'teaser' => (string)($u['teaser'] ?? ''),
'cardImage' => $this->image($uid, 'card_image'),
'customerLogo' => $this->image($uid, 'customer_logo'),
'market' => $this->market((int)($u['market'] ?? 0)),
'categories' => $this->categories($uid),
'featured' => (bool)($u['featured'] ?? false),
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
];
}
/**
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function serializeDetail(array $u): array
{
$uid = (int)$u['uid'];
$base = $this->serializeListItem($u);
return array_merge($base, [
'hero' => [
'bgImage' => $this->image($uid, 'hero_bgimage'),
'smallImage' => $this->image($uid, 'hero_small_image'),
'video' => $this->video($uid, 'hero_video'),
'overlayColor' => (string)($u['hero_overlay_color'] ?? ''),
'overlayOpacity' => (float)($u['hero_overlay_opacity'] ?? 0),
'layout' => (string)($u['hero_layout'] ?? 'fullscreen'),
'textTheme' => (string)($u['text_theme'] ?? 'light'),
],
'contentElements' => $this->contentElements($uid),
'related' => [
'show' => (bool)($u['show_related'] ?? true),
'market' => $base['market'],
'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid),
'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid),
'categories' => $base['categories'],
],
'seo' => $this->seo($u),
'appearance' => [
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
'accentColor' => (string)($u['accent_color'] ?? ''),
'featured' => (bool)($u['featured'] ?? false),
'heroLayout' => (string)($u['hero_layout'] ?? 'fullscreen'),
'textTheme' => (string)($u['text_theme'] ?? 'light'),
],
]);
}
// ---------------------------------------------------------------- images
/**
* FAL image (with srcset). $table defaults to the usecase table but can be
* a content-block collection table (for Columns items).
*
* @return array<string,mixed>|null
*/
public function image(int $recordUid, string $fieldName, ?string $table = null): ?array
{
try {
$table = $table ?? self::TABLE;
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('sfr.deleted', 0),
$qb->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $ref['crop'] ?? null]
);
return [
'uid' => (int)$ref['uid'],
'url' => $imageService->getImageUri($default),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''),
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Throwable $e) {
return null;
}
}
/**
* FAL video (public URL only). $table as in image().
*
* @return array<string,mixed>|null
*/
public function video(int $recordUid, string $fieldName, ?string $table = null): ?array
{
try {
$table = $table ?? self::TABLE;
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('uid', 'title', 'description')
->from('sys_file_reference')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
return [
'uid' => (int)$ref['uid'],
'url' => $fileReference->getPublicUrl(),
'title' => (string)($ref['title'] ?? ''),
'mimeType' => $fileReference->getProperty('mime_type'),
];
} catch (\Throwable $e) {
return null;
}
}
// ------------------------------------------------------------- relations
/**
* Single market (foreign_table, no MM) -> title/slug.
*
* @return array<string,mixed>|null
*/
public function market(int $marketUid): ?array
{
if ($marketUid <= 0) {
return null;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_market');
$row = $qb
->select('uid', 'title', 'subtitle', 'teaser')
->from('tx_vitec_domain_model_market')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($marketUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$row) {
return null;
}
return [
'uid' => (int)$row['uid'],
'title' => (string)($row['title'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
];
} catch (\Throwable $e) {
return null;
}
}
/**
* n:n relation resolved through an MM table -> [{uid,title}, …].
*
* @return array<int,array<string,mixed>>
*/
public function relationMulti(string $mmTable, string $foreignTable, int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($foreignTable);
$rows = $qb
->select('f.uid', 'f.title')
->from($foreignTable, 'f')
->join('f', $mmTable, 'mm', 'mm.uid_foreign = f.uid')
->where(
$qb->expr()->eq('mm.uid_local', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('f.deleted', 0),
$qb->expr()->eq('f.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($r) => [
'uid' => (int)$r['uid'],
'title' => (string)($r['title'] ?? ''),
], $rows);
} catch (\Throwable $e) {
return [];
}
}
/**
* System categories (sys_category MM) -> [{uid,title,description}, …].
*
* @return array<int,array<string,mixed>>
*/
public function categories(int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $qb
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter(self::TABLE, ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static fn ($c) => [
'uid' => (int)$c['uid'],
'title' => (string)($c['title'] ?? ''),
'description' => (string)($c['description'] ?? ''),
], $rows);
} catch (\Throwable $e) {
return [];
}
}
// ------------------------------------------------------- content elements
/**
* Inline content elements (tt_content owned via tx_vitec_usecase_content).
* Flat CEs are normalised; a VITEC Columns element is resolved specially.
*
* @return array<int,array<string,mixed>>
*/
public function contentElements(int $usecaseUid): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_vitec_usecase_content', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$out = [];
foreach ($rows as $row) {
$resolved = $this->resolveInlineElement($row);
if ($resolved !== null) {
$out[] = $resolved;
}
}
return $out;
} catch (\Throwable $e) {
return [];
}
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>|null
*/
private function resolveInlineElement(array $row): ?array
{
if ((string)($row['CType'] ?? '') === self::COLUMNS_CTYPE) {
return $this->resolveColumnsElement($row);
}
return ContentElementResolver::normaliseRecord($row);
}
/**
* VITEC Columns content block -> { header…, layout, columns:{left,right} }.
* Items live in a content-blocks collection table (discovered from TCA) and
* carry a `column` (left/right) plus a typed payload.
*
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function resolveColumnsElement(array $row): array
{
$uid = (int)$row['uid'];
$left = [];
$right = [];
$table = $this->collectionTable('vitec_items') ?? $this->collectionTable('items');
if ($table !== null) {
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$items = $qb
->select('*')
->from($table)
->where(
$qb->expr()->eq('foreign_table_parent_uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($items as $it) {
$column = (string)($it['vitec_column'] ?? $it['column'] ?? 'left');
$resolved = $this->resolveColumnItem($it, $table);
if ($column === 'right') {
$right[] = $resolved;
} else {
$left[] = $resolved;
}
}
} catch (\Throwable $e) {
// collection table not ready / query failed -> empty columns
}
}
return [
'id' => $uid,
'type' => self::COLUMNS_CTYPE,
'colPos' => (int)($row['colPos'] ?? 0),
'sorting' => (int)($row['sorting'] ?? 0),
'header' => (string)($row['header'] ?? ''),
'subheader' => (string)($row['subheader'] ?? ''),
'headerLayout' => (int)($row['header_layout'] ?? 0),
'headerPosition' => (string)($row['header_position'] ?? ''),
'headerLink' => (string)($row['header_link'] ?? ''),
'layout' => (string)($row['vitec_layout'] ?? $row['layout'] ?? 'cols_50_50'),
'columns' => [
'left' => $left,
'right' => $right,
],
];
}
/**
* @param array<string,mixed> $it
* @return array<string,mixed>
*/
private function resolveColumnItem(array $it, string $table): array
{
$uid = (int)$it['uid'];
$get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? '');
$imageField = array_key_exists('vitec_image', $it) ? 'vitec_image' : 'image';
$videoField = array_key_exists('vitec_video', $it) ? 'vitec_video' : 'video';
return [
'type' => $get('item_type') !== '' ? $get('item_type') : 'text',
'headline' => $get('headline'),
'text' => $get('text'),
'image' => $this->image($uid, $imageField, $table),
'video' => $this->video($uid, $videoField, $table),
'link' => $get('link'),
'linkLabel' => $get('link_label'),
];
}
/**
* Resolve a content-blocks collection field's storage table from TCA.
*/
private function collectionTable(string $field): ?string
{
$t = $GLOBALS['TCA']['tt_content']['columns'][$field]['config']['foreign_table'] ?? null;
return (is_string($t) && $t !== '') ? $t : null;
}
// ------------------------------------------------------------------- seo
/**
* SEO bundle with fallback resolution (title/teaser/card/hero images).
*
* @param array<string,mixed> $u
* @return array<string,mixed>
*/
public function seo(array $u): array
{
$uid = (int)$u['uid'];
$title = $this->firstNonEmpty([(string)($u['seo_title'] ?? ''), (string)($u['title'] ?? '')]);
$desc = $this->firstNonEmpty([(string)($u['seo_description'] ?? ''), (string)($u['teaser'] ?? '')]);
$ogTitle = $this->firstNonEmpty([(string)($u['og_title'] ?? ''), $title]);
$ogDesc = $this->firstNonEmpty([(string)($u['og_description'] ?? ''), $desc]);
$ogImage = $this->image($uid, 'og_image')
?? $this->image($uid, 'card_image')
?? $this->image($uid, 'hero_bgimage');
$twTitle = $this->firstNonEmpty([(string)($u['twitter_title'] ?? ''), $ogTitle]);
$twDesc = $this->firstNonEmpty([(string)($u['twitter_description'] ?? ''), $ogDesc]);
$twImage = $this->image($uid, 'twitter_image') ?? $ogImage;
return [
'title' => $title,
'description' => $desc,
'canonical' => (string)($u['canonical_link'] ?? ''),
'robots' => [
'noIndex' => (bool)($u['no_index'] ?? false),
'noFollow' => (bool)($u['no_follow'] ?? false),
],
'openGraph' => [
'title' => $ogTitle,
'description' => $ogDesc,
'image' => $ogImage,
],
'twitter' => [
'title' => $twTitle,
'description' => $twDesc,
'image' => $twImage,
],
];
}
/**
* @param array<int,string> $candidates
*/
private function firstNonEmpty(array $candidates): string
{
foreach ($candidates as $c) {
$c = trim($c);
if ($c !== '') {
return $c;
}
}
return '';
}
}