Markets List and Detail Plugin

This commit is contained in:
2026-08-05 16:53:02 +02:00
parent a739dd8fe3
commit 27cc22dc69
66 changed files with 1078 additions and 5494 deletions

View File

@@ -57,4 +57,18 @@ class MarketController extends ActionController
return $this->htmlResponse();
}
/**
* action list
*
* Extbase registration target for the Marketlist plugin. In headless mode
* the JSON output is produced by MarketListJsonRenderer, not by this
* controller — same arrangement as LocationController::listAction().
*
* @return ResponseInterface
*/
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
}

View File

@@ -7,6 +7,7 @@ use Evomedien\Vitec\UserFunc\ProductListJsonRenderer;
use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketListJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
@@ -110,6 +111,7 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'vitec_usecaselist' => [UsecaseListJsonRenderer::class, 'usecases'],
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'],
'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'],
'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],

View File

@@ -13,6 +13,18 @@ class Market extends AbstractEntity
*/
protected $title = '';
/**
* @var string
*/
protected $slug = '';
/**
* Page uid presenting this market (TCA type "group", allowed: pages).
*
* @var int
*/
protected $detailPage = 0;
/**
* @var string
*/
@@ -65,6 +77,38 @@ class Market extends AbstractEntity
$this->title = $title;
}
/**
* @return string
*/
public function getSlug(): string
{
return $this->slug;
}
/**
* @param string $slug
*/
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
/**
* @return int
*/
public function getDetailPage(): int
{
return $this->detailPage;
}
/**
* @param int $detailPage
*/
public function setDetailPage(int $detailPage): void
{
$this->detailPage = $detailPage;
}
/**
* @return string
*/

View File

@@ -13,6 +13,11 @@ class Solution extends AbstractEntity
*/
protected $title = '';
/**
* @var string
*/
protected $slug = '';
/**
* @var string
*/
@@ -65,6 +70,22 @@ class Solution extends AbstractEntity
$this->title = $title;
}
/**
* @return string
*/
public function getSlug(): string
{
return $this->slug;
}
/**
* @param string $slug
*/
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
/**
* @return string
*/

View File

@@ -9,6 +9,7 @@ use Evomedien\Vitec\UserFunc\ProductListJsonRenderer;
use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketListJsonRenderer;
use Evomedien\Vitec\UserFunc\MarketShowJsonRenderer;
use Evomedien\Vitec\UserFunc\SolutionShowJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
@@ -75,6 +76,7 @@ final class ContentElementResolver
'vitec_usecaselist' => [UsecaseListJsonRenderer::class, 'usecases'],
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'],
'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'],
'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'],
'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'],
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Turns raw richtext (RTE) database content into frontend-ready HTML.
*
* TYPO3 stores RTE fields with unresolved internal references: internal links as
* `<a href="t3://page?uid=12">`, legacy content as `<link>` tags, images with
* relative paths. Fluid resolves those through `parseFunc` when it renders; a
* headless UserFunc renderer that hands the raw column straight to JSON does
* not, and the frontend receives dead links.
*
* The two upstream packages already close this gap for the output they own:
* `friendsoftypo3/headless` applies `parseFunc =< lib.parseFunc_RTE` to the core
* text elements via TypoScript, and `nb-headless-content-blocks` calls
* `parseFunc($value, null, '< lib.parseFunc_RTE')` for every Content Block field
* whose TCA has `enableRichtext`. This class closes it for the VITEC UserFunc
* renderers, using exactly the same call.
*
* Static by design: the conversion is stateless, and the call sites are payload
* array literals where a `GeneralUtility::makeInstance(...)->` prefix would add
* only noise. Same rationale as `CropVariants::firstImage()` and
* `FormDefinitions::get()`.
*
* Fail-soft per architecture spec clause 9.5: when parsing is impossible — most
* notably outside a frontend request, where no TypoScript setup exists — the raw
* value is returned. Content is never lost; at worst it stays unresolved.
*/
final class RteResolver
{
/**
* @param mixed $value raw column value; null and non-strings are tolerated
*/
public static function html(mixed $value): string
{
$raw = (string)($value ?? '');
if (trim($raw) === '') {
return '';
}
try {
$parsed = GeneralUtility::makeInstance(ContentObjectRenderer::class)
->parseFunc($raw, null, '< lib.parseFunc_RTE');
return $parsed !== '' ? $parsed : $raw;
} catch (\Throwable $e) {
return $raw;
}
}
}

View File

@@ -11,6 +11,7 @@ use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -261,7 +262,7 @@ class DatasheetsJsonRenderer
'title' => (string)($download['title'] ?? ''),
'slug' => (string)($download['slug'] ?? ''),
'teaser' => (string)($download['teaser'] ?? ''),
'description' => (string)($download['description'] ?? ''),
'description' => RteResolver::html($download['description'] ?? ''),
'tstamp' => (int)($download['tstamp'] ?? 0),
'filetype' => $filetype,
'file' => $this->getDownloadFile($uid, (string)($download['fileprefix'] ?? ''), $filetype),

View File

@@ -10,6 +10,7 @@ use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
@@ -254,7 +255,7 @@ class DownloadcardJsonRenderer
'title' => (string)($download['title'] ?? ''),
'slug' => (string)($download['slug'] ?? ''),
'teaser' => (string)($download['teaser'] ?? ''),
'description' => (string)($download['description'] ?? ''),
'description' => RteResolver::html($download['description'] ?? ''),
'keywords' => (string)($download['keywords'] ?? ''),
'icon' => (string)($download['icon'] ?? ''),
'filepath' => (string)($download['filepath'] ?? ''),

View File

@@ -12,6 +12,7 @@ use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -258,7 +259,7 @@ class DownloadcardcollectionJsonRenderer
'title' => (string)($download['title'] ?? ''),
'slug' => (string)($download['slug'] ?? ''),
'teaser' => (string)($download['teaser'] ?? ''),
'description' => (string)($download['description'] ?? ''),
'description' => RteResolver::html($download['description'] ?? ''),
'keywords' => (string)($download['keywords'] ?? ''),
'icon' => (string)($download['icon'] ?? ''),
'filepath' => (string)($download['filepath'] ?? ''),

View File

@@ -9,6 +9,7 @@ use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -195,7 +196,7 @@ class EventlistJsonRenderer
'title' => (string)($event['title'] ?? ''),
'slug' => (string)($event['slug'] ?? ''),
'teaser' => (string)($event['teaser'] ?? ''),
'description' => (string)($event['description'] ?? ''),
'description' => RteResolver::html($event['description'] ?? ''),
'eventstart' => $start > 0 ? date('Y-m-d', $start) : null,
'eventend' => $end > 0 ? date('Y-m-d', $end) : null,
'venue' => (string)($event['venue'] ?? ''),

View File

@@ -8,6 +8,7 @@ use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
@@ -82,7 +83,7 @@ class LocationsJsonRenderer
$settings = $flexFormData['settings'] ?? [];
$variant = (string)($settings['variant'] ?? 'grid');
$mapText = trim((string)($settings['maptext'] ?? ''));
$mapText = RteResolver::html($settings['maptext'] ?? '');
$debugMode = (bool)($settings['debug'] ?? false);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\RteResolver;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* UserFunc: render all VITEC markets as JSON (headless).
*
* Output under content.markets:
* { "layout": "grid|list|carousel", "markets": [ … ] }
*
* Selection semantics (FlexForm `settings.markets`):
* - nothing selected -> ALL visible markets, alphabetical by title
* (tx_vitec_domain_model_market has no `sorting` column)
* - markets selected -> exactly those, in the order the editor arranged
* them in the FlexForm
*
* The page carrying the plugin is never used as a filter.
*
* Each item uses the same shape as the card payload (ModelcardJsonRenderer,
* model type "market"), so the frontend can render list and single card with
* one component. Image resolution is delegated to UsecaseSerializer::image()
* rather than re-implemented inline (architecture spec, clause 9.2).
*
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* plugins). Exception-safe.
*/
class MarketListJsonRenderer
{
private const TABLE = 'tx_vitec_domain_model_market';
private const CTYPE = 'vitec_marketlist';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === self::CTYPE) {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter(self::CTYPE, ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$layout = (string)($settings['layout'] ?? 'grid');
$debugMode = (bool)($settings['debug'] ?? false);
$selectedUids = GeneralUtility::intExplode(',', (string)($settings['markets'] ?? ''), true);
// Always fetch the full (small) table once — that way a selected
// record that has meanwhile been hidden or deleted simply drops out.
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb
->select('*')
->from(self::TABLE)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
if ($selectedUids === []) {
$markets = array_map(
fn(array $r): array => $this->serializeMarket($r, $serializer),
$rows
);
} else {
// Keep the editor's FlexForm order. A SQL IN() would return the
// rows in storage order, so the sequence is rebuilt here.
$byUid = [];
foreach ($rows as $r) {
$byUid[(int)$r['uid']] = $r;
}
$markets = [];
foreach ($selectedUids as $uid) {
if (isset($byUid[$uid])) {
$markets[] = $this->serializeMarket($byUid[$uid], $serializer);
}
}
}
$response = [
'layout' => $layout,
'markets' => $markets,
];
if ($debugMode) {
$response['debug'] = [
'count' => count($markets),
'selected' => $selectedUids,
'settings' => $settings,
];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $r
* @return array<string,mixed>
*/
private function serializeMarket(array $r, UsecaseSerializer $serializer): array
{
$uid = (int)$r['uid'];
return [
'uid' => $uid,
'title' => (string)($r['title'] ?? ''),
'slug' => (string)($r['slug'] ?? ''),
'subtitle' => (string)($r['subtitle'] ?? ''),
'teaser' => (string)($r['teaser'] ?? ''),
'description' => RteResolver::html($r['description'] ?? ''),
'detailUrl' => $this->detailUrl((int)($r['detail_page'] ?? 0)),
'image' => $serializer->image($uid, 'image', self::TABLE, true),
];
}
/**
* Resolve the `detail_page` uid to a URL. The frontend cannot do anything
* with a raw page uid, so the link is built server side — same convention
* as `headerLink` and LocationsJsonRenderer.
*
* Returns null when no page is set or the link cannot be resolved.
*/
private function detailUrl(int $pageUid): ?string
{
if ($pageUid <= 0) {
return null;
}
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$url = $cObj->typoLink_URL(['parameter' => (string)$pageUid]);
return $url !== '' ? $url : null;
} catch (\Throwable $e) {
return null;
}
}
}

View File

@@ -10,8 +10,10 @@ use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* UserFunc to render a single market as JSON for headless output.
@@ -155,9 +157,11 @@ class MarketShowJsonRenderer
return [
'uid' => $uid,
'title' => (string)($market['title'] ?? ''),
'slug' => (string)($market['slug'] ?? ''),
'subtitle' => (string)($market['subtitle'] ?? ''),
'teaser' => (string)($market['teaser'] ?? ''),
'description' => (string)($market['description'] ?? ''),
'description' => RteResolver::html($market['description'] ?? ''),
'detailUrl' => $this->detailUrl((int)($market['detail_page'] ?? 0)),
'categories' => $this->getMarketCategories($uid),
'image' => $this->getMarketImage($uid),
];
@@ -267,4 +271,25 @@ class MarketShowJsonRenderer
];
}, $categories);
}
/**
* Resolve the `detail_page` uid to a URL. The frontend cannot do anything
* with a raw page uid, so the link is built server side — same convention
* as `headerLink` and LocationsJsonRenderer.
*
* Returns null when no page is set or the link cannot be resolved.
*/
private function detailUrl(int $pageUid): ?string
{
if ($pageUid <= 0) {
return null;
}
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$url = $cObj->typoLink_URL(['parameter' => (string)$pageUid]);
return $url !== '' ? $url : null;
} catch (\Throwable $e) {
return null;
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\RteResolver;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -166,9 +167,10 @@ class ModelcardJsonRenderer
return [
'uid' => $uid,
'title' => (string)($row['title'] ?? ''),
'slug' => (string)($row['slug'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
'description' => (string)($row['description'] ?? ''),
'description' => RteResolver::html($row['description'] ?? ''),
'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType], true),
];
}

View File

@@ -11,6 +11,7 @@ use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -319,7 +320,7 @@ final class NewsJsonRenderer
'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''),
'bodytext' => (string)($news['bodytext'] ?? ''),
'bodytext' => RteResolver::html($news['bodytext'] ?? ''),
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),

View File

@@ -16,6 +16,7 @@ use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -177,9 +178,9 @@ class ProductListJsonRenderer
'teaser' => (string)($product['teaser'] ?? ''),
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => (string)($product['applications'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'highlights' => (string)($product['highlights'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
@@ -697,7 +698,7 @@ class ProductListJsonRenderer
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'description' => RteResolver::html($download['description'] ?? ''),
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'fileprefix' => $fileprefix,
@@ -741,7 +742,7 @@ class ProductListJsonRenderer
'slug' => (string)($rel['slug'] ?? ''),
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''),
'description' => RteResolver::html($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];

View File

@@ -10,6 +10,7 @@ use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
@@ -192,9 +193,9 @@ class ProductShowJsonRenderer
'teaser' => (string)($product['teaser'] ?? ''),
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => (string)($product['applications'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'highlights' => (string)($product['highlights'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
@@ -769,7 +770,7 @@ class ProductShowJsonRenderer
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'description' => RteResolver::html($download['description'] ?? ''),
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'fileprefix' => $fileprefix,
@@ -813,7 +814,7 @@ class ProductShowJsonRenderer
'slug' => (string)($rel['slug'] ?? ''),
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''),
'description' => RteResolver::html($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];

View File

@@ -10,6 +10,7 @@ use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use Evomedien\Vitec\Service\RteResolver;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
@@ -155,9 +156,10 @@ class SolutionShowJsonRenderer
return [
'uid' => $uid,
'title' => (string)($solution['title'] ?? ''),
'slug' => (string)($solution['slug'] ?? ''),
'subtitle' => (string)($solution['subtitle'] ?? ''),
'teaser' => (string)($solution['teaser'] ?? ''),
'description' => (string)($solution['description'] ?? ''),
'description' => RteResolver::html($solution['description'] ?? ''),
'categories' => $this->getSolutionCategories($uid),
'image' => $this->getSolutionImage($uid),
];

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Market List Settings</sheetTitle>
<type>array</type>
<el>
<settings.layout>
<label>Layout</label>
<description>Display variant for the market cards.</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>Grid</label>
<value>grid</value>
</numIndex>
<numIndex index="1">
<label>List</label>
<value>list</value>
</numIndex>
<numIndex index="2">
<label>Carousel</label>
<value>carousel</value>
</numIndex>
</items>
<default>grid</default>
</config>
</settings.layout>
<settings.markets>
<label>Markets</label>
<description>Leave empty to show all markets alphabetically. If you select markets, exactly those are shown — in the order you arrange them here (use the arrows to sort).</description>
<config>
<type>select</type>
<renderType>selectMultipleSideBySide</renderType>
<foreign_table>tx_vitec_domain_model_market</foreign_table>
<foreign_table_where>AND tx_vitec_domain_model_market.hidden = 0 AND tx_vitec_domain_model_market.deleted = 0 ORDER BY tx_vitec_domain_model_market.title</foreign_table_where>
<size>8</size>
<minitems>0</minitems>
<maxitems>999</maxitems>
</config>
</settings.markets>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -80,6 +80,10 @@ return [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-locationlist.svg',
],
'vitec-plugin-marketlist' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-marketlist.svg',
],
'vitec-plugin-customerlogos' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-customerlogos.svg',

View File

@@ -101,6 +101,18 @@ tt_content {
}
}
vitec_marketlist < lib.contentElementWithHeader
vitec_marketlist {
fields {
content {
fields {
markets = USER
markets.userFunc = Evomedien\Vitec\UserFunc\MarketListJsonRenderer->render
}
}
}
}
vitec_solutionshow < lib.contentElementWithHeader
vitec_solutionshow {
fields {

View File

@@ -101,6 +101,16 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
'FILE:EXT:vitec/Configuration/FlexForms/Locationlist.xml'
);
ExtensionUtility::registerPlugin(
'Vitec',
'Marketlist',
'VITEC Market List',
'vitec-plugin-marketlist',
'vitec',
'All markets as grid, list or carousel.',
'FILE:EXT:vitec/Configuration/FlexForms/Marketlist.xml'
);
// Change frame_class to allow multiple selections.
$GLOBALS['TCA']['tt_content']['columns']['frame_class']['config']['renderType'] = 'selectCheckBox';

View File

@@ -24,7 +24,7 @@ return [
],
'types' => [
'1' => [
'showitem' => 'title, subtitle, teaser, description, image,
'showitem' => 'title, slug, detail_page, subtitle, teaser, description, image,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
@@ -112,6 +112,34 @@ return [
'default' => '',
],
],
'slug' => [
'exclude' => false,
'label' => 'URL Segment',
'config' => [
'type' => 'slug',
'size' => 50,
'generatorOptions' => [
'fields' => ['title'],
'fieldSeparator' => '-',
'replacements' => [
'/' => '',
],
],
'fallbackCharacter' => '-',
'eval' => 'uniqueInPid',
],
],
'detail_page' => [
'exclude' => true,
'label' => 'Detail Page',
'description' => 'Page that presents this market. Resolved to a URL in the JSON output; leave empty for no link.',
'config' => [
'type' => 'group',
'allowed' => 'pages',
'size' => 1,
'maxitems' => 1,
],
],
'subtitle' => [
'exclude' => true,
'label' => 'Subtitle',

View File

@@ -311,7 +311,7 @@ return [
'label' => 'Applications',
'config' => [
'type' => 'text',
'enableRichtext' => 'true',
'enableRichtext' => true,
'eval' => 'trim',
'default' => ''
],
@@ -322,7 +322,7 @@ return [
'label' => 'Description',
'config' => [
'type' => 'text',
'enableRichtext' => 'true',
'enableRichtext' => true,
'eval' => 'trim',
'default' => ''
],
@@ -334,7 +334,7 @@ return [
'label' => 'Highlights',
'config' => [
'type' => 'text',
'enableRichtext' => 'true',
'enableRichtext' => true,
'eval' => 'trim',
'default' => ''
],

View File

@@ -24,7 +24,7 @@ return [
],
'types' => [
'1' => [
'showitem' => 'title, subtitle, teaser, description, image,
'showitem' => 'title, slug, subtitle, teaser, description, image,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
@@ -112,6 +112,23 @@ return [
'default' => '',
],
],
'slug' => [
'exclude' => false,
'label' => 'URL Segment',
'config' => [
'type' => 'slug',
'size' => 50,
'generatorOptions' => [
'fields' => ['title'],
'fieldSeparator' => '-',
'replacements' => [
'/' => '',
],
],
'fallbackCharacter' => '-',
'eval' => 'uniqueInPid',
],
],
'subtitle' => [
'exclude' => true,
'label' => 'Subtitle',

View File

@@ -3,12 +3,23 @@
| | |
|---|---|
| **Document identifier** | EVOVITECHL001 |
| **Version** | 1.0 |
| **Version** | 1.5 |
| **Status** | Released |
| **Date** | 20260709 |
| **Date** | 20260805 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch |
**Revision history**
| Version | Date | Changes |
|---|---|---|
| 1.0 | 20260709 | Initial released specification. |
| 1.1 | 20260805 | Catalogue completed with the card, customerlogo, location and form plugins (7.87.11, 8). Pagelevel auxiliary fields — menus and favicons — specified (6.7, 7.12). Formsubmission endpoint added to the interface (7.8.2). Cropvariant and debugflag conventions added (9.9, 9.10). News CType count corrected to nine and the `=<` marking on `news_pi1` withdrawn (7.6, 8, B1). Nonconformities B8…B10 recorded. |
| 1.2 | 20260805 | **Interface change (additive):** `tx_vitec_domain_model_market` and `tx_vitec_domain_model_solution` gained a `slug` field, now emitted by `MarketShowJsonRenderer`, `SolutionShowJsonRenderer` and the card payload. Market and Solution detail payloads specified (7.13); 7.9 updated. |
| 1.3 | 20260805 | **New plugin** `vitec_marketlist` (`MarketListJsonRenderer`, payload key `markets`) — first list plugin built entirely on the shared serializer per 9.2, with editorcontrolled selection and ordering. Clause 7.13 restructured into detail (7.13.1) and list (7.13.2); catalogue updated. |
| 1.4 | 20260805 | **Interface change (additive):** `tx_vitec_domain_model_market` gained a `detail_page` field (TCA `group`/`pages`), emitted as the **resolved** `detailUrl` in both market payloads (7.13.1, 7.13.2). Not added to Solution — see B11. |
| 1.5 | 20260805 | **Defect fix, outputchanging:** richtext fields were emitted as raw database content by every VITEC UserFunc renderer, leaving `t3://` links unresolved in the JSON. New `RteResolver` service and mandatory convention 9.11; applied at all 19 richtext call sites across 11 renderers. Duplication register 10.2 updated. |
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
users) and ISO/IEC 25010 (product quality). The key words **shall**, **should** and
@@ -34,7 +45,8 @@ The platform combines the generic headless page renderer (`friendsoftypo3/headle
with three sources of content JSON:
1. **Custom plugins** (product, use case/success story, market, solution, downloads,
datasheets, events, news) rendered by dedicated *UserFunc* classes;
datasheets, events, news, cards, customer logos, locations, forms) rendered by
dedicated *UserFunc* classes;
2. **Layout containers** (b13/container based column grids and a card carousel)
rendered by a *DataProcessor*;
3. **Content Blocks** (`friendsoftypo3/content-blocks`) serialised automatically by
@@ -43,6 +55,11 @@ with three sources of content JSON:
All three are unified under a single **contentelement envelope** so that the front
end can consume every element with one predictable shape.
Alongside `content[]` the page object carries **pagelevel auxiliary fields** — the
navigation menus, the favicon set and the schema.org graph (Clause 6.7). One
**writeside** endpoint complements the read interface: form submissions are POSTed
back to the CMS (Clause 7.8.2).
---
## 1 Scope
@@ -51,7 +68,8 @@ end can consume every element with one predictable shape.
This document specifies:
- the runtime environment and the software stack (Clause 5);
- the JSON rendering pipeline and its layers (Clause 6);
- the public JSON interface — envelope, payloads, structured data (Clause 7);
- the public JSON interface — envelope, payloads, structured data, pagelevel
auxiliary fields and the formsubmission endpoint (Clause 7);
- the catalogue of content types and their JSON keys (Clause 8);
- the mandatory conventions for implementing and extending renderers (Clause 9);
- maintainability and upgradesafety requirements (Clause 10);
@@ -157,7 +175,17 @@ definitions on top. The headless page response carries
enhancers for products (`tx_vitec_domain_model_product.slug`) and news detail
(`path_segment`).
### 5.3 Architectural principles (rationale)
### 5.3 Frontend middlewares
Two middlewares are registered in `Configuration/RequestMiddlewares.php`, both after
`typo3/cms-core/normalized-params-attribute` and before `typo3/cms-frontend/site`
that is, **before page resolution**:
| Middleware | Purpose |
|---|---|
| `vitec/form-submission` | Answers `POST /api/vitec/form/<formKey>` (Clause 7.8.2). Every other request passes through untouched. |
| `vitec/success-story-path-rewrite` | Rewrites `/success-stories/<slug>` internally to `/success-stories/story/<slug>` when `<slug>` matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest pageslug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a nonmatching slug leaves the request untouched; any exception leaves the request untouched. |
### 5.4 Architectural principles (rationale)
- **P1 — One envelope.** Every content element, regardless of source, is exposed with
the same outer shape so the front end has a single rendering contract.
- **P2 — Payload isolation.** Domain JSON is produced in PHP, fully decoupled from
@@ -192,6 +220,8 @@ HTTP request (Accept: application/json, headless:1)
[L5] Normalisation & nesting ContentElementResolver / PLUGIN_RENDERERS
[L6] Structured data (JSONLD) PageJsonLdRenderer + StructuredDataService
[L7] Pagelevel fields menus (MenuProcessor) + favicons
```
### 6.1 L1 — Page renderer
@@ -282,6 +312,32 @@ Node types and their triggers:
FAQ and event nodes are collected from the respective tables; event image URLs are
currently built with a hardcoded `/fileadmin` prefix (see 10.3 / Annex B5).
### 6.7 L7 — Pagelevel auxiliary fields
Beyond `content[]` and `jsonLd`, four further fields are attached to the page object
in `Configuration/TypoScript/Headless/vitec_menus.typoscript`:
```typoscript
page.10.fields.mainNavigation =< lib.mainNavigation
page.10.fields.footerMenu =< lib.footerMenu
page.10.fields.metaMenu =< lib.metaMenu
page.10.fields.favicons = USER
page.10.fields.favicons.userFunc = Evomedien\Vitec\UserFunc\FaviconsJsonRenderer->render
```
The three menus are built by `FriendsOfTYPO3\Headless\DataProcessing\MenuProcessor`,
which resolves shortcut pages to their target, skips pages with `nav_hide = 1`, marks
active/current items and nests sublevels under `children`. `mainNavigation` is the
full hierarchy (`levels = 10`, `expandAll = 1`); `footerMenu` and `metaMenu` are
curated flat lists driven by the site settings `menu.footer.pageUids` and
`menu.meta.pageUids`. All three use the title field `nav_title // title` and exclude
spacers.
> **NOTE** These menus use the reference operator `=<` against `lib.*` objects. That
> is the idiomatic headless form and is safe; the `<`copy rule of 6.4/10.5(1)
> constrains `tt_content → tt_content` derivations only.
Payloads are specified in Clause 7.12.
---
## 7 JSON interface specification
@@ -348,6 +404,24 @@ a lean variant of the envelope:
(`tx_vitec_col{N}_align/justify`); they therefore apply to **all** children of that
column. These containeronly fields **shall not** appear in a childs `data`
(enforced by `CONTAINER_FIELDS` filtering).
- `tx_vitec_bg_variant` is passed through unchanged (background variant of the grid).
- `vitec_container` and `vitec_cards_carousel` **drop** `gap` (single column / single
track) and add `cssClass`, read from the FlexForm `settings.cssClass`.
`vitec_cards_carousel` additionally emits a `carousel` object from its FlexForm:
```jsonc
{ "carousel": { "slidesPerView": "3", "showArrows": "1", "showIndicators": "1",
"loop": "0", "autoplay": "1", "autoplayInterval": "5000" } }
```
> **NOTE** These members are TypoScript `TEXT` values and therefore arrive as
> **strings**, not numbers or booleans. The front end coerces them.
The `colPos` value of each column is fixed by `COLPOS_TO_COLUMN` in
`ContainerChildrenProcessor` — 211/212 (50/50), 221223 (33/33/33), 231234
(25/25/25/25), 241/242 (66/33), 251/252 (33/66) — and **shall** be kept in sync with
the container TCA (Annex B5).
### 7.5 Success Story (use case) detail payload
Produced by `UsecaseSerializer::serializeDetail()` — the reference implementation of
@@ -389,8 +463,13 @@ Produced by `NewsJsonRenderer` under `content.news`:
"templateLayoutLabel": "Compact List", "detailPid": 45, } }
```
Ten News CTypes (`news_pi1`, `news_newsdetail`, `news_newsliststicky`, …) share one
renderer; `news_newsdetail` yields `mode: "detail"` with a single `news` object.
Nine News CTypes share this one renderer — `news_pi1` plus the eight variants
`news_newsliststicky`, `news_newsselectedlist`, `news_newsdetail`,
`news_newsdatemenu`, `news_categorylist`, `news_newssearchform`,
`news_newssearchresult`, `news_taglist`. `news_newsdetail` yields `mode: "detail"`
with a single `news` object. `news_pi1` is derived with the `<` copy operator from
`lib.contentElementWithHeader`, the eight variants with `<` from
`tt_content.news_pi1`.
### 7.7 Content Blocks
Content Blocks (`card`, `cta-banner`, `hero-section`, `intro-paragraph`, `video`,
@@ -406,6 +485,245 @@ its collection items are resolved explicitly by `UsecaseSerializer::resolveColum
into `{ header…, layout, columns: { left: [], right: [] } }`, with the collection
storage table discovered from TCA (`foreign_table`) rather than hardcoded.
### 7.8 Forms
#### 7.8.1 Form plugin payload
Produced by `FormsJsonRenderer` under `content.form` for all three form CTypes
(`vitec_contactform`, `vitec_demoform`, `vitec_helpdeskform`); the CType selects the
definition through `FormDefinitions::CTYPE_MAP`:
```jsonc
{ "formKey": "contact", // contact | demo | helpdesk
"title": "Contact VITEC",
"endpoint": "/api/vitec/form/contact",
"honeypot": "_website",
"fields": [
{ "name": "firstName", "type": "text", "label": "First Name", "required": true },
{ "name": "email", "type": "email", "label": "Email", "required": true },
{ "name": "country", "type": "select", "label": "Country", "required": false,
"optionsSource": "countries" },
{ "name": "solution", "type": "select", "label": "Solution of Interest",
"required": false, "options": [ "IPTV Distribution", "…" ] }
] }
```
Field `type` is one of `text`, `email`, `tel`, `select`, `textarea`. A `select` carries
either an inline `options` array **or** an `optionsSource` key (`countries`,
`usStates`) naming a list the front end supplies itself — this keeps long ISO lists out
of every page response and consistent across the app. `contact` and `demo` share one
field set; `helpdesk` has its own, adding `product` and `serialNumber`.
`Classes/Forms/FormDefinitions.php` is the **single source of truth**: the same
definition produces this JSON *and* validates the submission serverside. Fields
**shall** be added there and nowhere else.
#### 7.8.2 Submission endpoint (write side)
`FormSubmissionMiddleware` (5.3) answers:
```
POST /api/vitec/form/<formKey> Content-Type: application/json
```
Processing order: honeypot check → validation → persistence → delivery.
| Situation | HTTP | Body |
|---|---|---|
| Accepted — including honeypot tripped and delivery failure | 200 | `{"success": true}` |
| Validation failed | 422 | `{"success": false, "errors": {"<field>": "<message>"}}` |
| Unknown `formKey` | 404 | `{"success": false, "errors": {"_form": "Unknown form"}}` |
| Method not POST | 405 | `{"success": false, "errors": {"_form": "POST only"}}` |
| Unexpected error | 500 | `{"success": false, "errors": {"_form": "Unexpected error"}}` |
Every accepted submission is written to `tx_vitec_form_submission` (`form_key`,
`payload` as JSON, `delivery_method`, `delivery_status` ∈ {`pending`, `sent`,
`failed`}, `delivery_error`) **before** delivery is attempted. Delivery therefore
cannot lose data: a failed delivery still answers `success: true` and the failure is
visible on the backend record. Unknown payload keys are dropped by
`FormDefinitions::filterPayload()`; a filled honeypot field (`_website`) is answered
with `success: true` and stored nowhere.
Delivery is a strategy (`Classes/Forms/Delivery/DeliveryInterface`), selected by the
FlexForm setting `delivery` (default `email`):
| Strategy | State |
|---|---|
| `EmailDelivery` | active |
| `SalesforceDelivery` | **prepared stub**`deliver()` always throws. A submission with `delivery = salesforce` is stored and then marked `delivery_status = failed`; the endpoint still answers `success: true`, so no data is lost. The previous website posted to Salesforce WebtoLead; completing it requires the credentials plus the camelCase → Salesforce fieldid mapping. |
> **NOTE (design constraint)** Delivery settings are read from the **first**
> nondeleted, nonhidden plugin element of that CType found sitewide — the endpoint
> is stateless and receives no element uid. There is therefore **one delivery
> configuration per form type**, not per placed element (Annex B10).
### 7.9 Card payload (`vitec_modelcard`)
One plugin serves four model types. The FlexForm picks `modelType` plus one record,
and **all** card data comes from that record — nothing is authored on the element:
```jsonc
{ "modelType": "product", // product | story | market | solution
"layout": "vertical",
"item": { "uid": 12, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
"image": { "url": "…", "srcset": [ ] } } }
```
`item` is `null` when no record is selected or the record is hidden/deleted. Per model
type: `story` reuses `UsecaseSerializer::serializeListItem()` verbatim (the canonical
card serialisation); `product` falls back from the `image` field to `productimage`;
`market` and `solution` share one field set and add `description`. **All four model
types carry `slug`** (since v1.2), so the front end can build a detail link from any
card without a second request.
Image resolution is delegated to `UsecaseSerializer::image()`. This renderer is the
reference for reusing a serializer instead of reimplementing FAL logic (9.2).
Model table names are held in the class constant `MODEL_TABLES` (10.3).
### 7.10 Locations payload (`vitec_locationlist`)
Produced by `LocationsJsonRenderer` under `content.locations`. The plugin is
**global**: every visible `tx_vitec_domain_model_location` record is emitted, ordered
by `sorting`; the page carrying the plugin is not used as a filter.
```jsonc
{ "variant": "grid", // grid | list | map
"mapText": "<p>…</p>", // only when variant = "map" and text is set, else null
"locations": [
{ "id": 3, "slug": "…", "name": "…", "countryCode": "DE",
"coordinates": { "latitude": 50.1, "longitude": 8.6 },
"address": { "company": , "street": , "additional": ,
"postalCode": , "city": , "region": , "country": },
"contact": { "phone": , "fax": , "email": },
"links": { "contact": "/contact", "legal": [ "/imprint", "…" ] },
"marker": { "label": "…", "color": "#ff6633", "size": 0.5 },
"sorting": 1, "active": true } ] }
```
Empty `address` and `contact` members are `null`, never `""`, so the front end can
test presence directly. `links.contact` is a resolved typolink; `links.legal` is a
newlineseparated list of typolinks, each resolved individually. `marker.label` falls
back to the location name, `marker.color` to `#ff6633`, `marker.size` to `0.5`.
### 7.11 Customerlogo payload (`vitec_customerlogos`)
Produced by `CustomerlogosJsonRenderer` under `content.customerlogos`:
```jsonc
{ "layout": "grid", // list | grid | carousel | marquee
"logos": [ { "id": 7, "name": "…", "emphasized": false, "color": true,
"logo": { "uid": 42, "url": "…", "title": "…", "alternative": "…",
"srcset": [ ], "properties": { "mimeType": "…" } } } ] }
```
Selection semantics — normative for the front end:
| FlexForm state | Result |
|---|---|
| no customers selected | **all** logos, each `color: false` (render blackandwhite) |
| customers selected | the selected ones **first**, in selection order, `color: true`; then all remaining logos, `color: false` |
| customers selected + `onlySelected` | only the selected ones, `color: true` |
`color` is therefore a *rendering hint*, not a property of the record. SVG logos are
delivered unprocessed with an empty `srcset`; raster logos receive a WebP `srcset`.
### 7.12 Pagelevel auxiliary payloads
Attached to the page object, not to a content element (Clause 6.7).
**`mainNavigation` / `footerMenu` / `metaMenu`** — arrays of headless `MenuProcessor`
items (`title`, `link`, `active`, `current`, `spacer`, `children[]`).
**`favicons`** — a static, pageindependent descriptor set produced by
`FaviconsJsonRenderer`, so the head tags are CMSdriven instead of hardcoded in React:
```jsonc
{ "themeColor": "#26358C",
"manifest": "/fileadmin/icons/site.webmanifest",
"links": [ { "rel": "icon", "type": "image/x-icon",
"href": "/fileadmin/icons/favicon.ico" },
{ "rel": "icon", "type": "image/png", "sizes": "32x32", "href": "…" },
{ "rel": "icon", "type": "image/png", "sizes": "16x16", "href": "…" },
{ "rel": "apple-touch-icon", "sizes": "180x180", "href": "…" },
{ "rel": "manifest", "href": "…" } ] }
```
`links` is ordered most to leastspecific and is meant to be rendered verbatim as
`<link>` elements. Hrefs are rootrelative under `/fileadmin/icons/` — the same
convention as image URLs (`UsecaseSerializer::image()`). **If the front end is ever
served from an origin other than the CMS, it must prefix these hrefs with the CMS base
URL**, exactly as it does for image URLs.
### 7.13 Market and Solution payloads
#### 7.13.1 Detail payloads
`MarketShowJsonRenderer` and `SolutionShowJsonRenderer` emit the same shape under
`content.market` and `content.solution` respectively — the two models are fieldforfield
identical:
```jsonc
{ "uid": 4, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
"description": "<p>…</p>", // RTE HTML
"detailUrl": "/markets/aviation/", // market only, null when unset
"categories": [ { "uid": 9, "title": "…", "description": "…" } ],
"image": { "uid": 12, "url": "…", "title": "…", "alternative": "…",
"description": "…", "srcset": [ ],
"properties": { "width": 1920, "height": 1080, } } }
```
`slug` was added in v1.2. It is generated from `title` with `eval: uniqueInPid`, exactly
as on `product` and `usecase` (9.9 governs their images, 9.6 their naming), and is the
routing handle for the `/markets/<slug>/` and `/solutions/<slug>/` URLs of the relaunch
sitemap.
`detailUrl` was added in v1.4 and exists on **Market only**. It is backed by the TCA
field `detail_page` (`type: group`, `allowed: pages`, `maxitems: 1`) — the editor picks
the page with the standard page browser. The renderer **shall not** expose the raw page
uid: a headless front end cannot turn a uid into a link, so the value is resolved with
`typoLink_URL()` server side, the same convention as `headerLink` (6.2) and the location
links (7.10). It is `null` when no page is selected or the link cannot be resolved —
never `0` and never an empty string, so the front end can test it directly.
> **NOTE** Neither model is *routed* by slug yet: market and solution pages are still
> resolved as ordinary TYPO3 pages carrying a `…show` plugin that selects one record via
> its FlexForm. The field exists so the front end can build canonical URLs today, and so
> slugbased routing (a route enhancer, as `product` already has, or a middleware, as
> Success Stories have) can be introduced later without a second data migration.
#### 7.13.2 Market list payload (`vitec_marketlist`)
`MarketListJsonRenderer` emits, under `content.markets`:
```jsonc
{ "layout": "grid", // grid | list | carousel
"markets": [
{ "uid": 4, "title": "…", "slug": "…", "subtitle": "…", "teaser": "…",
"description": "<p>…</p>", // RTE HTML, resolved per 9.11
"detailUrl": "/markets/aviation/", // null when no detail page is set
"image": { "url": "…", "srcset": [ ] } } ] }
```
The page carrying the plugin is never used as a filter. Which markets appear, and in
which order, is decided by the FlexForm field `settings.markets`:
| FlexForm state | Result |
|---|---|
| nothing selected | **all** visible markets, alphabetical by `title` |
| markets selected | exactly those, **in the order the editor arranged them** in the FlexForm |
The second row is the loadbearing one: `settings.markets` stores a commaseparated uid
list whose sequence *is* the intended display order. A `WHERE uid IN (…)` query returns
rows in storage order and would silently discard it, so the renderer fetches the (small)
table once and rebuilds the sequence in PHP — the same approach
`CustomerlogosJsonRenderer` uses. Fetching everything also means a selected record that
has since been hidden or deleted simply drops out instead of producing a gap or an error.
`tx_vitec_domain_model_market` has **no `sorting` column**, which is why the unselected
case falls back to alphabetical rather than to a backenddefined order.
Each item carries the **same field set as the detail payload** (7.13.1) apart from
`categories`, so the front end can render a list item, a card and a detail header from
one shape. `description` is included and is RTE HTML resolved per 9.11 — it was omitted
in v1.3 on the assumption that lists only need `teaser`, which turned out to be wrong in
practice. Image resolution is delegated to `UsecaseSerializer::image()` per 9.2; this
renderer duplicates no FAL logic.
A Solution list counterpart does not exist yet. When it is added it **should** reuse this
shape under `content.solutions`.
---
## 8 Contenttype catalogue
@@ -416,13 +734,20 @@ storage table discovered from TCA (`foreign_table`) rather than hardcoded.
| `vitec_productshow` | `< lib.…WithHeader` | ProductShowJsonRenderer | `product` | detail |
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
| `vitec_marketlist` | `< lib.…WithHeader` | MarketListJsonRenderer | `markets` | list (global) |
| `vitec_marketshow` | `< lib.…WithHeader` | MarketShowJsonRenderer | `market` | detail |
| `vitec_solutionshow` | `< lib.…WithHeader` | SolutionShowJsonRenderer | `solution` | detail |
| `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail |
| `vitec_downloadcardcollection` | `< lib.…WithHeader` | DownloadcardcollectionJsonRenderer | `downloadcardcollection` | list |
| `vitec_datasheets` | `< lib.…WithHeader` | DatasheetsJsonRenderer | `datasheets` | list |
| `vitec_eventlist` | `=< lib.…WithHeader` ⚠ | EventlistJsonRenderer | `eventlist` | list |
| `news_pi1` (+9 variants) | `=< lib.…WithHeader`; variants `<` | NewsJsonRenderer | `news` | list/detail |
| `vitec_locationlist` | `< lib.…WithHeader` | LocationsJsonRenderer | `locations` | list (global) |
| `vitec_customerlogos` | `< lib.…WithHeader` | CustomerlogosJsonRenderer | `customerlogos` | list |
| `vitec_modelcard` | `< lib.…WithHeader` | ModelcardJsonRenderer | `card` | detail (4 model types) |
| `vitec_contactform` | `< lib.…WithHeader` | FormsJsonRenderer | `form` | form |
| `vitec_demoform` | `< tt_content.vitec_contactform` | FormsJsonRenderer | `form` | form |
| `vitec_helpdeskform` | `< tt_content.vitec_contactform` | FormsJsonRenderer | `form` | form |
| `news_pi1` (+8 variants) | `< lib.…WithHeader`; variants `< tt_content.news_pi1` | NewsJsonRenderer | `news` | list/detail |
| `vitec_cols_50_50` | `= JSON` | ContainerChildrenProcessor | `items` | container |
| `vitec_cols_33_66 / 66_33 / 33_33_33 / 25_25_25_25` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container |
| `vitec_container` | `< vitec_cols_50_50` | ContainerChildrenProcessor | `items` | container (1 col) |
@@ -430,7 +755,18 @@ storage table discovered from TCA (`foreign_table`) rather than hardcoded.
| Content Blocks (`vitec_card`, …) | Content Blocks + nbheadless | — | (auto) | element |
| `vitec_columns` (CB, inline) | Content Blocks | UsecaseSerializer (special) | `columns` | element |
⚠ = uses the reference operator `=<`; see Annex B1.
⚠ = uses the reference operator `=<` against a `lib.*` object; verified safe, see
Annex B1. `vitec_eventlist` is the only remaining `=<` derivation among the content
elements.
**Pagelevel fields** (not content elements): `jsonLd` (`PageJsonLdRenderer`),
`favicons` (`FaviconsJsonRenderer`) and `mainNavigation` / `footerMenu` / `metaMenu`
(headless `MenuProcessor`) — Clauses 6.7 and 7.12.
**Registered but not headlessenabled:** `vitec_simplecard` is registered as an
Extbase plugin and offered in the contentelement wizard, but has neither a TypoScript
mapping nor a renderer; placed on a page it emits a content element without a payload
(Annex B8).
---
@@ -492,6 +828,61 @@ Every custom CE/plugin/container **shall** expose the standard header section (t
"header convention". Header fields in JSON use the names `header`, `subheader`,
`headerLayout`, `headerPosition`, `headerLink`.
### 9.9 Image crop variants
The "first image" of every cardcapable model (Product, Success Story, Market,
Solution) **shall** declare its crop variants through the single definition
`Evomedien\Vitec\Tca\CropVariants::firstImage()`, referenced from the model TCA:
```php
'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
```
It yields three editor tabs — `default` (free, 16:9, 4:3, 1:1; for detail, hero and
list use), `card` (4:3) and `largeCard` (16:9); a free crop stays available in each.
Crop variants **shall not** be redefined per model.
### 9.10 Debug flag
A plugin FlexForm **may** expose a `settings.debug` checkbox. When it is set the
renderer **shall** attach a `debug` member to its payload (typically `settings` plus a
count or the resolved record uid) and **shall not** change the regular payload in any
other way. This is the only sanctioned form of diagnostic output (9.5); `debug` is
absent from production payloads.
### 9.11 Richtext fields
Any field whose TCA carries `enableRichtext` **shall** be passed through
`Evomedien\Vitec\Service\RteResolver::html()` before it enters a payload. Handing the
raw database column to JSON is **prohibited**.
```php
'description' => RteResolver::html($market['description'] ?? ''), // correct
'description' => (string)($market['description'] ?? ''), // prohibited
```
**Rationale.** TYPO3 stores richtext with unresolved internal references — internal
links as `<a href="t3://page?uid=12">`, legacy content as `<link>` tags, images with
relative paths. Fluid resolves these through `parseFunc` at render time. A headless
renderer that skips that step ships dead links to the front end, and the defect is
invisible until an editor actually places an internal link.
The upstream packages already do this for the output they own: `friendsoftypo3/headless`
applies `parseFunc =< lib.parseFunc_RTE` to the core text elements via TypoScript, and
`nb-headless-content-blocks` calls `parseFunc($value, null, '< lib.parseFunc_RTE')` for
every Content Block field with `enableRichtext`. `RteResolver` performs the identical
call, so all three paths produce the same HTML.
The resolver is **static** — the conversion is stateless and the call sites are payload
array literals — and **failsoft** per 9.5: when no TypoScript setup is available it
returns the raw value rather than throwing, so content is never lost.
> **NOTE** `enableRichtext` **shall** be the boolean `true`, not the string `'true'`.
> FormEngine accepts both, but `nb-headless-content-blocks` tests with `=== true` and a
> string silently disables its richtext conversion. Three product fields carried the
> string form until v1.5.
Fields that merely *look* like richtext are out of scope: FAL metadata
(`sys_file_reference.description`), `sys_category.description` and `sys_file` metadata
are plain text and **shall not** be passed through the resolver.
---
## 10 Maintainability and upgradesafety (ISO 25010)
@@ -501,8 +892,12 @@ and the concrete risks and rules that preserve them.
### 10.1 Modularity — current state
Strengths: uniform envelope, dualentry pattern, exception safety, a shared
`PLUGIN_RENDERERS` map, and the `UsecaseSerializer` reference pattern.
Weakness: **substantial duplication** across the inline renderers.
`PLUGIN_RENDERERS` map, and the `UsecaseSerializer` reference pattern — reused without
duplication by `ModelcardJsonRenderer` (7.9), while `FormDefinitions` (7.8.1) is the
equivalent single source of truth for the form plugins.
Weakness: **substantial duplication** remains across the older inline renderers
(Product, Download, Datasheet), which is why those are the largest files in the
extension.
### 10.2 Reusability — duplication register
The following logic is duplicated across many renderers and **should** be extracted
@@ -510,13 +905,14 @@ into shared services (target design in parentheses):
| Duplicated logic | Occurrences | Target service |
|---|---|---|
| FAL image/`srcset` resolution | Product(List/Show), Market, Solution, Event, Datasheets | `FalImageResolver` |
| FAL image/`srcset` resolution | Product(List/Show), Market, Solution, Event, Datasheets, Customerlogos | `FalImageResolver` |
| FAL video resolution | Product, Usecase, hero | `FalImageResolver::video()` |
| `sys_category` MM query | ≥ 9 renderers | `CategoryResolver` |
| Custom MM (product↔download, …) | ≥ 5 renderers | `RelationResolver::resolveMany()` |
| Download filebyconvention | 5 renderers | `ConventionFileResolver` |
| `letterSequenceToRank()` sort helper | 5 renderers | static utility |
| Pageid discovery | all renderers | `PageIdResolver::resolve()` |
| RTE `parseFunc` conversion | 19 sites / 11 renderers | **`RteResolver::html()` — DONE (v1.5)** |
> **RULE** When a shared resolver service exists, new renderers **shall** use it and
> **shall not** reimplement the logic inline.
@@ -569,15 +965,20 @@ Deviations are recorded in Annex B and **shall** carry a remediation plan.
and/or `serializeDetail()`; reuse existing resolver services.
3. **Renderer** — add `Classes/UserFunc/<Domain><Kind>JsonRenderer` per 9.1, delegating
to the serializer; implement `render()` (9.3/9.4) and `renderForRecord()`.
4. **TypoScript** — in `Configuration/Sets/Vitecset/setup.typoscript`:
4. **Registration**`ExtensionUtility::configurePlugin()` in `ext_localconf.php`, a
FlexForm under `Configuration/FlexForms/`, an icon, and a wizard entry in
`Configuration/page.tsconfig`.
5. **TypoScript** — in `Configuration/Sets/Vitecset/setup.typoscript`:
`tt_content.<ctype> < lib.contentElementWithHeader` and
`content.fields.<key> = USER` + `.userFunc = …->render`.
5. **Header section** — ensure the `headers` palette is present (9.8).
6. **Nesting** — if the plugin may sit inside a container, register it in
`content.fields.<key> = USER` + `.userFunc = …->render`. **Omitting this step
yields a content element with no payload** (Annex B8).
6. **Header section** — ensure the `headers` palette is present (9.8).
7. **Nesting** — if the plugin may sit inside a container, register it in
`PLUGIN_RENDERERS` in **both** the resolver and the processor (9.7).
7. **Deploy**`vendor/bin/typo3 database:updateschema "*.add,*.change"` then
8. **Deploy**`vendor/bin/typo3 database:updateschema "*.add,*.change"` then
`vendor/bin/typo3 cache:flush`.
8. **Verify** — fetch the page JSON; confirm envelope (C1), keys (C4) and nested output.
9. **Verify** — fetch the page JSON; confirm envelope (C1), keys (C4) and nested
output; confirm `debug` is absent unless the FlexForm flag is set (9.10).
---
@@ -587,25 +988,61 @@ The following items were identified during architecture analysis. Items marked
*(to verify)* were reported by static review and **shall** be confirmed before
remediation.
- **B1 — `=<` on Event/News CTypes.** `vitec_eventlist` and `news_pi1` use the
reference operator `=< lib.contentElementWithHeader`. Reference *to a `lib.*` object*
is used by headless itself and is generally safe; however, for consistency with
10.5(1) these are VERIFIED SAFE and left as-is (references to lib.* are idiomatic in headless; only tt_content-to-tt_content references are unsafe, and those are already `<` for containers).
- **B1 — `=<` on the Event CType.** `vitec_eventlist` uses the reference operator
`=< lib.contentElementWithHeader`. VERIFIED SAFE and left as is: a reference *to a
`lib.*` object* is idiomatic in headless and used by headless itself; only
`tt_contenttt_content` references are unsafe, and those are `<` copies throughout
(containers, News variants, form variants). The pagelevel menus use `=<` against
`lib.*` objects for the same reason (6.7). **CORRECTION 20260805:** v1.0 of this
document also listed `news_pi1` here — `news_pi1` is, and was, derived with `<`.
- **B2 — Containers cannot be authored inline (IRRE).** b13 container children live in
`tx_container_parent` and require the pagemodule grid; they cannot be created inside
an inline field. This is a platform limitation, not a defect. The Success Story
"Columns" Content Block (`vitec_columns`) is the sanctioned inline alternative.
- **B3 — Duplicated `PLUGIN_RENDERERS` map** in `ContentElementResolver` and
`ContainerChildrenProcessor` (9.7/10.3).
`ContainerChildrenProcessor` (9.7/10.3). OPEN. Both copies verified in sync on
20260805 — 25 entries each (16 VITEC CTypes + 9 News CTypes). Remediation:
promote to a single shared constant.
- **B4 — Inline duplication** of image/category/MM/pageid logic (10.2).
- **B5 — Hardcoded literals** — model/MM table names, a `/fileadmin` prefix for
event JSONLD image URLs (`PageJsonLdRenderer`), and at least one magic
parentcategory uid appear as inline literals across download/datasheet/structureddata
code *(to verify and extract to constants)*. `COLPOS_TO_COLUMN` in
event JSONLD image URLs (`PageJsonLdRenderer`), the `/fileadmin/icons` prefix and
the theme colour in `FaviconsJsonRenderer`, and at least one magic parentcategory
uid appear as inline literals across download/datasheet/structureddata code
*(to verify and extract to constants)*. `COLPOS_TO_COLUMN` in
`ContainerChildrenProcessor` must be kept in sync with the container TCA colPos values.
- **B6 — `DownloadcardcollectionJsonRenderer`**: two misplaced thumbnail output lines referenced an undefined variable in the image resolver. FIXED 2026-07-09 (removed; the PDF-thumbnail method is unaffected).
- **B7 — v13→v14 `CType`/`list_type` compatibility branches** in some download/
datasheet renderers FIXED 2026-07-09: the unsatisfiable legacy OR branch was removed; the query now filters on the v14 CType only.
- **B8 — `vitec_simplecard` has no headless rendering.** The plugin is registered in
`ext_localconf.php`, has a FlexForm, Fluid templates and a wizard entry in
`Configuration/page.tsconfig`, but there is no `tt_content.vitec_simplecard` mapping
in `setup.typoscript` and no `*JsonRenderer`. Placed on a page it produces a content
element without a payload. Decide: complete it per Annex A, or withdraw the plugin
registration and the wizard entry.
- **B9 — Stray files in the extension.** Four `*.bak.rebuild` files
(`UsecaseListJsonRenderer`, `UsecaseShowJsonRenderer`,
`tx_vitec_domain_model_usecase`, `ext_tables.sql`) and several tracked
`.msys00000…` artefacts under `Classes/DataProcessing/` and `Classes/Service/`.
They are never loaded, but they are indexed by IDEs and static analysis. Remove.
- **B10 — One delivery configuration per form type.** `FormSubmissionMiddleware`
reads the delivery settings from the first matching plugin element found sitewide
(7.8.2). Perelement configuration would require the front end to submit the element
uid and the endpoint to resolve it. This is a documented constraint, not a defect —
but placing two elements of the same form type with different `delivery` settings is
silently ineffective.
- **B11 — `detail_page` exists on Market but not on Solution.** Introduced in v1.4 on
`tx_vitec_domain_model_market` only, because that is what was requested. The two models
are otherwise fieldforfield identical (7.13.1) and their renderers share one payload
shape, so the asymmetry is a latent inconsistency: `SolutionShowJsonRenderer` emits no
`detailUrl` at all. Either add the field to Solution as well, or record the divergence
as intended. Related: `ModelcardJsonRenderer` serves `market` and `solution` from one
shared branch and therefore emits no `detailUrl` either — the card payload (7.9) cannot
link to a market detail page until this is resolved.
- **B12 — `detailUrl()` duplicated** in `MarketListJsonRenderer` and
`MarketShowJsonRenderer`, and a third nearidentical `resolveLink()` lives in
`LocationsJsonRenderer` (7.10). Three copies of "resolve a link server side" is the
smallest concrete case of B4; it is the natural seed for the `LinkResolver` service
that 10.2 calls for.
---
@@ -616,4 +1053,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 EVOVITECHL001 v1.0.*
*End of document EVOVITECHL001 v1.5.*

View File

@@ -34,6 +34,8 @@ this extension turns every content element into clean **JSON** for a React front
- [Content elements &amp; plugins](#content-elements--plugins)
- [Content Blocks](#content-blocks)
- [Layout containers](#layout-containers)
- [Forms](#forms)
- [Pagelevel fields](#pagelevel-fields)
- [Structured data (JSONLD)](#structured-data-json-ld)
- [Requirements](#requirements)
- [Installation](#installation)
@@ -52,6 +54,8 @@ this extension turns every content element into clean **JSON** for a React front
children inside layout containers.
- 🎛️ **Editorfriendly Content Blocks** — hero, cards, CTA, FAQ, video, intro and a
twocolumn layout block, all with a unified header section.
- 📮 **Forms without a form framework** — one PHP definition drives both the JSON the
React app renders *and* the serverside validation of the submission.
- 🔎 **SEO built in** — a schema.org `@graph` (Organization, Product, FAQ, Events,
News …) is emitted per page.
- 🛡️ **Failsoft** — a failing element yields empty output, never a broken page.
@@ -78,6 +82,9 @@ HTTP request (headless: 1)
[L6] Structured data PageJsonLdRenderer + StructuredDataService → @graph
[L7] Page-level fields MenuProcessor + FaviconsJsonRenderer → menus, favicons
```
Each renderer follows one pattern — an `#[AsAllowedCallable] render()` for toplevel use
@@ -94,12 +101,23 @@ as the reference).
|---|---|---|---|
| Products | `vitec_productlist` / `vitec_productshow` | `ProductList/ProductShowJsonRenderer` | `products` / `product` |
| Success Stories | `vitec_usecaselist` / `vitec_usecaseshow` | `UsecaseList/ShowJsonRenderer``UsecaseSerializer` | `usecases` / `usecase` |
| Markets | `vitec_marketshow` | `MarketShowJsonRenderer` | `market` |
| Markets | `vitec_marketlist` / `vitec_marketshow` | `MarketList/MarketShowJsonRenderer` | `markets` / `market` |
| Solutions | `vitec_solutionshow` | `SolutionShowJsonRenderer` | `solution` |
| Downloads | `vitec_downloadcard` / `vitec_downloadcardcollection` | `Downloadcard*JsonRenderer` | `downloadcard` / `downloadcardcollection` |
| Datasheets | `vitec_datasheets` | `DatasheetsJsonRenderer` | `datasheets` |
| Events | `vitec_eventlist` | `EventlistJsonRenderer` | `eventlist` |
| News | `news_pi1` (+ variants) | `NewsJsonRenderer` | `news` |
| Locations | `vitec_locationlist` | `LocationsJsonRenderer` | `locations` |
| Customer logos | `vitec_customerlogos` | `CustomerlogosJsonRenderer` | `customerlogos` |
| Cards | `vitec_modelcard` | `ModelcardJsonRenderer` | `card` |
| Forms | `vitec_contactform` / `vitec_demoform` / `vitec_helpdeskform` | `FormsJsonRenderer` | `form` |
| News | `news_pi1` (+ 8 variants) | `NewsJsonRenderer` | `news` |
**Cards** (`vitec_modelcard`) are one plugin for four model types — the FlexForm picks
`product`, `story`, `market` or `solution` plus a record, and every card field comes
from that record. Image resolution is delegated to `UsecaseSerializer::image()`.
> ⚠️ `vitec_simplecard` is registered as a plugin and offered in the wizard, but has
> no JSON renderer — it emits no payload in headless mode. See Annex B8 of the spec.
## Content Blocks
@@ -130,6 +148,40 @@ Nested column grids (`b13/container`) that own child content elements and emit t
| `vitec_container` | Single column with a custom CSS class |
| `vitec_cards_carousel` | Carousel of card elements |
## Forms
Three form plugins (contact · demo · helpdesk) share one renderer and one definition.
[`FormDefinitions`](Classes/Forms/FormDefinitions.php) is the single source of truth:
the same field list produces the JSON the React app renders **and** validates the
submission serverside.
```
GET page JSON → content.form = { formKey, title, endpoint, honeypot, fields[] }
POST /api/vitec/form/<formKey> → { "success": true } | 422 { success:false, errors{} }
```
`FormSubmissionMiddleware` handles the endpoint: honeypot → validation → store in
`tx_vitec_form_submission` → deliver. Delivery is a strategy
([`DeliveryInterface`](Classes/Forms/Delivery/DeliveryInterface.php)) with
`EmailDelivery` (active) and `SalesforceDelivery` (**prepared stub — `deliver()` always
throws**), chosen per form via the FlexForm. Because the submission is stored *before*
delivery is attempted, a failed delivery never loses data — it is recorded as
`delivery_status = failed` on the record and the endpoint still answers `success: true`.
## Pagelevel fields
Beyond `content[]`, every page response carries:
| Field | Source |
|---|---|
| `mainNavigation` / `footerMenu` / `metaMenu` | headless `MenuProcessor`; the curated menus are driven by the site settings `menu.footer.pageUids` / `menu.meta.pageUids` |
| `favicons` | `FaviconsJsonRenderer` — readytorender `<link>` descriptors plus `themeColor` |
| `jsonLd` | `PageJsonLdRenderer` (see below) |
Two frontend middlewares run before page resolution: `vitec/form-submission` (the form
endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL
`/success-stories/<slug>` resolve to the detail subpage without changing the browser URL.
## Structured data (JSONLD)
`PageJsonLdRenderer` + `StructuredDataService` assemble a schema.org `@graph` per page:
@@ -177,15 +229,18 @@ The short version (full normative rules in the architecture spec, Clause 9 &amp;
2. **Serializer**`Classes/Service/<Domain>Serializer` with `serializeListItem()` / `serializeDetail()`.
3. **Renderer**`Classes/UserFunc/<Domain><Kind>JsonRenderer` with
`#[AsAllowedCallable] render()` + `renderForRecord()`, delegating to the serializer.
4. **TypoScript** in `Configuration/Sets/Vitecset/setup.typoscript`:
4. **Registration**`configurePlugin()` in `ext_localconf.php`, a FlexForm, an icon
and a wizard entry in `Configuration/page.tsconfig`.
5. **TypoScript** — in `Configuration/Sets/Vitecset/setup.typoscript`:
```typoscript
tt_content.<ctype> < lib.contentElementWithHeader
tt_content.<ctype>.fields.content.fields.<key> = USER
tt_content.<ctype>.fields.content.fields.<key>.userFunc = Evomedien\Vitec\UserFunc\<Class>->render
```
5. **Nesting** — if it may sit inside a container, register it in `PLUGIN_RENDERERS`
Skipping this step is exactly what leaves a plugin payloadless (see `vitec_simplecard`).
6. **Nesting** — if it may sit inside a container, register it in `PLUGIN_RENDERERS`
(in both `ContentElementResolver` and `ContainerChildrenProcessor`).
6. **Deploy** — `database:updateschema "*.add,*.change"` &amp; `cache:flush`.
7. **Deploy** — `database:updateschema "*.add,*.change"` &amp; `cache:flush`.
> **Conventions:** list keys are plural, detail keys singular; pageid via the
> `frontend.page.information` request attribute; container CTypes derive with `<` (copy),
@@ -202,9 +257,11 @@ packages/vitec/
│ ├── UserFunc/ # JSON renderers (one per plugin) — headless entry points
│ ├── Service/ # Serializers, ContentElementResolver, StructuredDataService
│ ├── DataProcessing/ # ContainerChildrenProcessor (container → items)
│ ├── Forms/ # FormDefinitions + Delivery/ (email, salesforce)
│ ├── Middleware/ # form endpoint, success-story path rewrite
│ ├── Domain/Model|Repository/
│ ├── Controller/ # Extbase controllers (non-headless / backend)
│ └── Backend/ · View/ · Hook/ · EventListener/
│ └── Backend/ · View/ · Hook/ · EventListener/ · Tca/ · Preview/
├── ContentBlocks/
│ └── ContentElements/ # card, cta-banner, columns, faq, hero-section, intro-paragraph, video
├── Configuration/

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="4" y="5" width="10" height="9" rx="1.5" fill="#26358C"/>
<rect x="18" y="5" width="10" height="9" rx="1.5" fill="#ff6a00"/>
<rect x="4" y="18" width="10" height="9" rx="1.5" fill="#ff6a00"/>
<rect x="18" y="18" width="10" height="9" rx="1.5" fill="#26358C"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -93,6 +93,17 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
]
);
ExtensionUtility::configurePlugin(
'Vitec',
'Marketlist',
[
\Evomedien\Vitec\Controller\MarketController::class => 'list'
],
[
\Evomedien\Vitec\Controller\MarketController::class => 'list'
]
);
ExtensionUtility::configurePlugin(
'Vitec',
'Solutionshow',

View File

@@ -154,6 +154,7 @@ CREATE TABLE tx_vitec_domain_model_solution (
l10n_parent int(11) DEFAULT '0' NOT NULL,
l10n_diffsource mediumblob,
title varchar(255) DEFAULT '' NOT NULL,
slug varchar(255) DEFAULT '' NOT NULL,
subtitle varchar(255) DEFAULT '' NOT NULL,
teaser varchar(255) DEFAULT '' NOT NULL,
description text,
@@ -177,6 +178,8 @@ CREATE TABLE tx_vitec_domain_model_market (
l10n_parent int(11) DEFAULT '0' NOT NULL,
l10n_diffsource mediumblob,
title varchar(255) DEFAULT '' NOT NULL,
slug varchar(255) DEFAULT '' NOT NULL,
detail_page int(11) DEFAULT '0' NOT NULL,
subtitle varchar(255) DEFAULT '' NOT NULL,
teaser varchar(255) DEFAULT '' NOT NULL,
description text,