1-based column number. Used to pick * the parent's tx_vitec_col{N}_align/justify for each rendered column. */ 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], ]; /** Fields whose 0/empty value is still meaningful. */ private const KEEP_IF_ZERO = [ 'header_layout', ]; /** * Nested VITEC list-plugins: list_type => [ rendererClass, jsonKey ]. * The renderer's renderForRecord() is invoked with the child's own * tt_content row so the result is record-accurate (works with multiple * containers / plugins on the same page). */ 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_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'], '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'], ]; public function process( ContentObjectRenderer $cObj, array $contentObjectConfiguration, array $processorConfiguration, array $processedData ): array { $as = (string)($processorConfiguration['as'] ?? 'items'); try { $parentUid = (int)($cObj->data['uid'] ?? 0); if ($parentUid <= 0) { $processedData[$as] = []; return $processedData; } $pid = (int)($cObj->data['pid'] ?? 0); $sysLanguageUid = (int)($cObj->data['sys_language_uid'] ?? 0); $qb = GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable('tt_content'); $rows = $qb ->select('*') ->from('tt_content') ->where( $qb->expr()->eq('tx_container_parent', $qb->createNamedParameter($parentUid, Connection::PARAM_INT)), $qb->expr()->eq('pid', $qb->createNamedParameter($pid, Connection::PARAM_INT)), $qb->expr()->eq('sys_language_uid', $qb->createNamedParameter($sysLanguageUid, Connection::PARAM_INT)) ) ->orderBy('colPos') ->addOrderBy('sorting') ->executeQuery() ->fetchAllAssociative(); $byColPos = []; foreach ($rows as $record) { $byColPos[(int)$record['colPos']][] = $this->normalise($record); } ksort($byColPos); // Per-column flex settings come from the parent container record. $parentCType = (string)($cObj->data['CType'] ?? ''); $colMap = self::COLPOS_TO_COLUMN[$parentCType] ?? []; $items = []; foreach ($byColPos as $colPos => $contentElements) { $config = ['colPos' => $colPos]; $columnNo = $colMap[$colPos] ?? null; if ($columnNo !== null) { $config['align'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch'); $config['justify'] = (string)($cObj->data['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start'); } $items[] = [ 'config' => $config, 'contentElements' => $contentElements, ]; } $processedData[$as] = $items; } catch (\Throwable $e) { $processedData[$as] = []; } return $processedData; } private function normalise(array $record): 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 (in_array($field, self::CONTAINER_FIELDS, true)) { continue; } if ($this->isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) { continue; } $data[$field] = $this->castValue($field, $value); } // Resolve nested VITEC list-plugins to their headless JSON. $this->resolvePluginData($record, $data); return [ 'id' => (int)$record['uid'], 'type' => (string)$record['CType'], 'colPos' => (int)$record['colPos'], '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, ]; } /** * If the child is a VITEC list-plugin, run its JSON renderer for THIS * record and inject the decoded result under its key. Drops the raw * pi_flexform XML afterwards. Never throws. * * @param array $record * @param array $data */ private 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; // The raw FlexForm XML is noise once the plugin is resolved. unset($data['pi_flexform']); } catch (\Throwable $e) { // Leave the raw data untouched on any failure. } } private function isEmpty(mixed $value): bool { return $value === null || $value === '' || $value === 0 || $value === '0'; } private function castValue(string $field, mixed $value): mixed { if (in_array($field, ['header_layout'], true)) { return (int)$value; } return $value; } }