* 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'; 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 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)); 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, ); $searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration) ->buildForSearch(['q' => $query, 'page' => $page], $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' => (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 } $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, '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, 'results' => [], 'suggestions' => [], ]; } }