* SearchResultSetService), i.e. identical to what the Fluid plugin runs - * only the rendering is JSON instead of HTML. `teaser` carries the * highlighted fragment when highlighting is enabled, otherwise a plain * 250-char excerpt of the indexed content. * * `render()` = top-level plugin / page discovery. `renderForRecord()` = one * specific tt_content row (reused by ContainerChildrenProcessor for nested * plugins). Exception-safe. */ class SearchJsonRenderer { private ?ContentObjectRenderer $cObj = null; /** Same duck-typed setter contract as the other renderers (see MarketListJsonRenderer). */ public function setContentObjectRenderer(ContentObjectRenderer $cObj): void { $this->cObj = $cObj; } private const CTYPE = 'solr_pi_results'; /** * Solr document types (index.queue config tables) to frontend-friendly * type labels. Unknown types pass through verbatim. */ private const TYPE_LABELS = [ 'pages' => 'page', 'tx_vitec_domain_model_product' => 'product', 'tx_vitec_domain_model_market' => 'market', 'tx_vitec_domain_model_usecase' => 'story', 'tx_news_domain_model_news' => 'news', 'tx_vitec_domain_model_download' => 'download', ]; private const TEASER_FALLBACK_LENGTH = 250; #[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 $contentElement */ public function renderForRecord(array $contentElement): string { try { $request = $GLOBALS['TYPO3_REQUEST'] ?? null; if ($request === null) { return ''; } // Search responses depend on the q/page GET parameters, which are // excluded from cHash - cached variants would collide (config.no_cache // is gone in TYPO3 v14, so the cache is disabled per request here). $request->getAttribute('frontend.cache.instruction') ?->disableCache('vitec search: response varies by q/page/filter query parameters'); $params = $request->getQueryParams(); $solrNamespace = (array)($params['tx_solr'] ?? []); $query = trim((string)($params['q'] ?? $solrNamespace['q'] ?? '')); $page = max(1, (int)($params['page'] ?? $solrNamespace['page'] ?? 1)); // Optional type filter, friendly vocabulary (see TYPE_LABELS). // Unknown values are ignored, never an error. $activeType = trim((string)($params['filter'] ?? '')); $typeFilterField = array_search($activeType, self::TYPE_LABELS, true); if ($typeFilterField === false) { $activeType = ''; } if ($query === '') { return (string)json_encode($this->emptyResult()); } $languageId = (int)($request->getAttribute('language')?->getLanguageId() ?? 0); $pageId = (int)($request->getAttribute('frontend.page.information')?->getId() ?? 0); $typoScriptConfiguration = GeneralUtility::makeInstance(ConfigurationManager::class) ->getTypoScriptFromRequest($request); if (!empty($contentElement['pi_flexform'])) { GeneralUtility::makeInstance(ConfigurationService::class)->overrideConfigurationWithFlexFormSettings( (string)$contentElement['pi_flexform'], $typoScriptConfiguration, ); } $connection = GeneralUtility::makeInstance(ConnectionManager::class) ->getConnectionByTypo3Site($request->getAttribute('site'), $languageId); $search = GeneralUtility::makeInstance(Search::class, $connection); $searchService = GeneralUtility::makeInstance( SearchResultSetService::class, $typoScriptConfiguration, $search, ); $arguments = ['q' => $query, 'page' => $page]; if ($activeType !== '') { $arguments['filter'] = ['type:' . $typeFilterField]; } $searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration) ->buildForSearch($arguments, $pageId, $languageId); $resultSet = $searchService->search($searchRequest); $highlighted = null; try { $highlighted = $resultSet->getUsedSearch()?->getHighlightedContent(); } catch (\Throwable) { // no highlighting available - the excerpt fallback below covers it } $results = []; foreach ($resultSet->getSearchResults() as $document) { $id = (string)$document->getId(); if ($highlighted !== null && !empty($highlighted->{$id}->content[0])) { $teaser = implode(' ... ', $highlighted->{$id}->content); } else { $teaser = mb_substr(trim((string)$document->getContent()), 0, self::TEASER_FALLBACK_LENGTH); } $results[] = [ 'title' => (string)$document->getTitle(), 'url' => (string)$document->getUrl(), 'type' => self::TYPE_LABELS[$document->getType()] ?? (string)$document->getType(), 'teaser' => $teaser, ]; } $suggestions = []; try { foreach ($resultSet->getSpellCheckingSuggestions() as $suggestion) { $word = method_exists($suggestion, 'getSuggestion') ? (string)$suggestion->getSuggestion() : ''; if ($word !== '') { $suggestions[] = $word; } } } catch (\Throwable) { // spellchecking disabled or unavailable - not essential } $facets = []; try { foreach ($resultSet->getFacets() as $facet) { $options = []; foreach ($facet->getOptions() as $option) { $value = (string)$option->getUriValue(); if ($facet->getName() === 'type') { $value = self::TYPE_LABELS[$value] ?? $value; } $options[] = [ 'value' => $value, 'count' => $option->getDocumentCount(), 'active' => $option->getSelected(), ]; } if ($options !== []) { $facets[$facet->getName()] = $options; } } } catch (\Throwable) { // faceting disabled or unavailable - facets stay empty } $numFound = $resultSet->getAllResultCount(); $resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results); return (string)json_encode([ 'query' => $query, 'page' => $page, 'resultsPerPage' => $resultsPerPage, 'numFound' => $numFound, 'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0, 'filter' => $activeType !== '' ? $activeType : null, 'facets' => (object)$facets, 'results' => $results, 'suggestions' => array_values(array_unique($suggestions)), ]); } catch (\Throwable $e) { return ''; } } /** * @return array */ private function emptyResult(): array { return [ 'query' => '', 'page' => 1, 'resultsPerPage' => 0, 'numFound' => 0, 'totalPages' => 0, 'filter' => null, 'facets' => new \stdClass(), 'results' => [], 'suggestions' => [], ]; } }