1-based column number, for the * parent's tx_vitec_col{N}_align/justify - same map as the processor. */ private const COLPOS_TO_COLUMN = [ 'vitec_cols_50_50' => [211 => 1, 212 => 2], 'vitec_cols_33_66' => [251 => 1, 252 => 2], 'vitec_cols_66_33' => [241 => 1, 242 => 2], 'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3], 'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4], ]; /** Recursion cap for nested containers. */ private const MAX_CONTAINER_DEPTH = 5; private const PLUGIN_RENDERERS = [ 'vitec_productlist' => [ProductListJsonRenderer::class, 'products'], 'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'], 'vitec_usecaselist' => [UsecaseListJsonRenderer::class, 'usecases'], 'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'], 'vitec_marketshow' => [MarketShowJsonRenderer::class, 'market'], 'vitec_marketlist' => [MarketListJsonRenderer::class, 'markets'], 'vitec_solutionlist' => [SolutionListJsonRenderer::class, 'solutions'], 'vitec_solutionshow' => [SolutionShowJsonRenderer::class, 'solution'], 'vitec_downloadcard' => [DownloadcardJsonRenderer::class, 'downloadcard'], 'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'], 'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'], 'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'], 'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'], 'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'], 'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'], 'vitec_contactform' => [FormsJsonRenderer::class, 'form'], 'vitec_demoform' => [FormsJsonRenderer::class, 'form'], 'vitec_helpdeskform' => [FormsJsonRenderer::class, 'form'], 'solr_pi_results' => [SearchJsonRenderer::class, 'search'], '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'], ]; /** * Resolve a typolink string to a normalised tt_content JSON element. * * Accepted forms (all may include an optional anchor fragment): * - "t3://record?identifier=tt_content&uid=N" * - "t3://page?uid=PAGE#N" (link-popup: pick a CE on a page; * the fragment is the tt_content uid) * - "" (legacy: bare tt_content uid) * - everything else → null (pure page links etc.) * * @return array|null */ public static function resolveLink(?string $link): ?array { try { $link = trim((string)$link); if ($link === '') { return null; } $uid = self::extractTtContentUid($link); if ($uid <= 0) { return null; } $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $row = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0), $qb->expr()->eq('hidden', 0) ) ->executeQuery() ->fetchAssociative(); if (!$row) { return null; } $element = self::normaliseRecord($row); self::attachContainerPayload($row, $element, [$uid => true], 0); return $element; } catch (\Throwable $e) { return null; } } /** * Normalise a tt_content DB row to the same envelope shape that * {@see \Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor} * emits for container children. VITEC list-plugin children are * resolved to their headless JSON. * * @param array $record * @param bool $isContainerChild strip container-level fields (bg, gap, * col flex) like the page-level processor * does for its children * @return array */ public static function normaliseRecord(array $record, bool $isContainerChild = false): array { $data = []; foreach ($record as $field => $value) { if (in_array($field, self::ENVELOPE, true)) { continue; } if (in_array($field, self::SYSTEM_FIELDS, true)) { continue; } if ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) { continue; } if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) { continue; } $data[$field] = self::castValue($field, $value); } self::resolvePluginData($record, $data); return [ 'id' => (int)$record['uid'], 'type' => (string)$record['CType'], 'colPos' => (int)($record['colPos'] ?? 0), 'sorting' => (int)($record['sorting'] ?? 0), 'appearance' => [ 'layout' => (string)($record['layout'] ?? ''), 'frameClass' => (string)($record['frame_class'] ?? 'default'), 'spaceBefore' => (string)($record['space_before_class'] ?? ''), 'spaceAfter' => (string)($record['space_after_class'] ?? ''), ], 'data' => (object)$data, ]; } /** * Extract the tt_content uid from a typolink string. * * Handles four forms: * 1. plain numeric → tt_content uid * 2. t3://record?identifier=tt_content&uid=N → uid N * 3. t3://record?identifier=tt_content&uid=N#X → uid N (anchor ignored) * 4. t3://page?uid=PAGE#N → uid N from fragment * (link-popup picks a content element on a page; the fragment is * the tt_content uid, the query is the page uid) */ private static function extractTtContentUid(string $link): int { // Form 1: bare numeric uid if (ctype_digit($link)) { return (int)$link; } $parts = parse_url($link); if (!is_array($parts)) { return 0; } // Forms 2 & 3: t3://record?identifier=tt_content&uid=N if (str_starts_with($link, 't3://record') && !empty($parts['query'])) { $params = []; parse_str((string)$parts['query'], $params); $identifier = (string)($params['identifier'] ?? ''); $uid = (int)($params['uid'] ?? 0); if ($identifier === 'tt_content' && $uid > 0) { return $uid; } } // Form 4: t3://page?uid=PAGE#N → tt_content uid lives in the fragment if (str_starts_with($link, 't3://page')) { $fragment = (string)($parts['fragment'] ?? ''); if (ctype_digit($fragment)) { return (int)$fragment; } } return 0; } /** * If the record is a VITEC list-plugin, run its renderer for THIS row * and inject the decoded result under its key. Drops raw pi_flexform. * * @param array $record * @param array $data */ private static function resolvePluginData(array $record, array &$data): void { // v14: plugins are their own CType; legacy elements still carry list_type. $cType = (string)($record['CType'] ?? ''); $listType = (string)($record['list_type'] ?? ''); $key = isset(self::PLUGIN_RENDERERS[$cType]) ? $cType : (isset(self::PLUGIN_RENDERERS[$listType]) ? $listType : null); if ($key === null) { return; } try { [$rendererClass, $jsonKey] = self::PLUGIN_RENDERERS[$key]; $renderer = GeneralUtility::makeInstance($rendererClass); if (!method_exists($renderer, 'renderForRecord')) { return; } $json = $renderer->renderForRecord($record); if ($json === '' || $json === null) { return; } $decoded = json_decode($json, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { return; } $data[$jsonKey] = $decoded; unset($data['pi_flexform']); } catch (\Throwable $e) { // leave raw data on failure } } /** * If the element has container children (tx_container_parent), attach the * resolved `background` and the children as `items` in the page-level * shape: [{config: {colPos, align?, justify?}, contentElements: [...]}]. * Recurses into nested containers; $visited guards against cycles. * * @param array $record raw tt_content row * @param array $element normalised element, modified in place * @param array $visited uids already on this path */ private static function attachContainerPayload(array $record, array &$element, array $visited, int $depth): void { $background = self::resolveContainerBackground($record); if ($background !== null) { $element['background'] = $background; } if ($depth >= self::MAX_CONTAINER_DEPTH) { return; } try { $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $rows = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('tx_container_parent', $qb->createNamedParameter((int)$record['uid'], ParameterType::INTEGER)), $qb->expr()->eq('pid', $qb->createNamedParameter((int)($record['pid'] ?? 0), ParameterType::INTEGER)), $qb->expr()->eq('sys_language_uid', $qb->createNamedParameter((int)($record['sys_language_uid'] ?? 0), ParameterType::INTEGER)) ) ->orderBy('colPos') ->addOrderBy('sorting') ->executeQuery() ->fetchAllAssociative(); } catch (\Throwable $e) { return; } if ($rows === []) { return; } $byColPos = []; foreach ($rows as $childRow) { $childUid = (int)$childRow['uid']; $child = self::normaliseRecord($childRow, true); if (!isset($visited[$childUid])) { $childVisited = $visited; $childVisited[$childUid] = true; self::attachContainerPayload($childRow, $child, $childVisited, $depth + 1); } $byColPos[(int)$childRow['colPos']][] = $child; } ksort($byColPos); $colMap = self::COLPOS_TO_COLUMN[(string)($record['CType'] ?? '')] ?? []; $items = []; foreach ($byColPos as $colPos => $contentElements) { $config = ['colPos' => $colPos]; $columnNo = $colMap[$colPos] ?? null; if ($columnNo !== null) { $config['align'] = (string)($record['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch'); $config['justify'] = (string)($record['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start'); } $items[] = [ 'config' => $config, 'contentElements' => $contentElements, ]; } $element['items'] = $items; } /** * Resolved {image, size, position} of a container row - the same payload * ContainerBackgroundRenderer emits in the page JSON. Null without image. * * @param array $record * @return array|null */ private static function resolveContainerBackground(array $record): ?array { try { $cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class); $cObj->start($record, 'tt_content'); $renderer = GeneralUtility::makeInstance(ContainerBackgroundRenderer::class); $renderer->setContentObjectRenderer($cObj); $json = $renderer->render('', []); if ($json === '') { return null; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : null; } catch (\Throwable $e) { return null; } } private static function isEmpty(mixed $value): bool { return $value === null || $value === '' || $value === 0 || $value === '0'; } private static function castValue(string $field, mixed $value): mixed { if (in_array($field, ['header_layout'], true)) { return (int)$value; } return $value; } }