* * { "query": "mgw d", * "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ], * "products": [ { "title", "teaser", "image", "url" } ] } * * Runs through Solr (decision Olli: synonyms, ranking and the indexed * product set stay identical to the site search), restricted to product * documents. The LAST term is completed as a prefix ("mgw d" finds * "MGW Diamond"), so cards appear while typing. One card per product, at * most MAX_PRODUCTS; `suggestions` are the matched product titles for the * autocomplete list. `teaser` is the product's one-liner from the record * (the indexed content is the full page text - too long for a card), * `image` the indexed 640px tile. * * Wired as USER_INT on a lean page type (like the EXT:solr suggest 7384): * every keystroke is a new query, a cached USER would pin the first one. * Fail-soft: any error yields the empty shape, never a broken menu. */ final class ProductMenuSearchJsonRenderer { private const MIN_CHARS = 2; private const MAX_PRODUCTS = 6; private const PRODUCT_TYPE = 'tx_vitec_domain_model_product'; private const TEASER_LENGTH = 120; #[AsAllowedCallable] public function render(string $content, array $conf): string { $query = ''; try { $request = $GLOBALS['TYPO3_REQUEST'] ?? null; $query = trim((string)($request?->getQueryParams()['q'] ?? '')); if ($request === null || mb_strlen($query) < self::MIN_CHARS) { return $this->json($query, []); } $connection = GeneralUtility::makeInstance(ConnectionManager::class)->getConnectionByTypo3Site( $request->getAttribute('site'), (int)($request->getAttribute('language')?->getLanguageId() ?? 0) ); $documents = $this->search($connection, $query, true); if ($documents === []) { // e.g. a typed hyphen or a complete word the prefix form misses $documents = $this->search($connection, $query, false); } return $this->json($query, $this->cards($documents)); } catch (\Throwable) { return $this->json($query, []); } } /** @return array> Solr document fields, best first */ private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array { $solrQuery = new Query(); $helper = $solrQuery->getHelper(); $terms = preg_split('/\s+/u', $input, -1, PREG_SPLIT_NO_EMPTY) ?: []; $parts = []; foreach ($terms as $index => $term) { $escaped = $helper->escapeTerm($term); $isLast = $index === count($terms) - 1; $parts[] = ($prefixLastTerm && $isLast && preg_match('/^[\p{L}\p{N}]+$/u', $term)) ? $escaped . '*' : $escaped; } if ($parts === []) { return []; } $solrQuery->setQuery(implode(' ', $parts)); $solrQuery->setQueryDefaultOperator('AND'); $solrQuery->getEDisMax()->setQueryFields('title^20 content'); $solrQuery->createFilterQuery('type')->setQuery('type:' . self::PRODUCT_TYPE); $solrQuery->setRows(self::MAX_PRODUCTS); $solrQuery->setFields(['uid', 'title', 'url', 'image']); $response = $connection->getReadService()->search($solrQuery); $documents = []; foreach ((array)($response->response->docs ?? []) as $document) { $documents[] = method_exists($document, 'getFields') ? (array)$document->getFields() : (array)$document; } return $documents; } /** * @param array> $documents * @return array */ private function cards(array $documents): array { $uids = array_values(array_filter(array_map(static fn(array $d): int => (int)($d['uid'] ?? 0), $documents))); $teasers = $this->teasers($uids); $cards = []; $seen = []; foreach ($documents as $document) { $uid = (int)($document['uid'] ?? 0); $url = trim((string)($document['url'] ?? '')); if ($uid === 0 || $url === '' || isset($seen[$uid])) { continue; } $seen[$uid] = true; $image = trim((string)($document['image'] ?? '')); if ($image !== '' && !str_starts_with($image, '/') && !preg_match('#^https?://#', $image)) { $image = '/' . $image; } $cards[] = [ 'title' => (string)($document['title'] ?? ''), 'teaser' => $teasers[$uid] ?? '', 'image' => $image !== '' ? $image : null, 'url' => $url, ]; } return $cards; } /** * @param int[] $uids * @return array uid => one-line teaser */ private function teasers(array $uids): array { if ($uids === []) { return []; } $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::PRODUCT_TYPE); $rows = $queryBuilder->select('uid', 'teaser')->from(self::PRODUCT_TYPE) ->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($uids, ArrayParameterType::INTEGER))) ->executeQuery()->fetchAllAssociative(); $teasers = []; foreach ($rows as $row) { $text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['teaser']), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if (mb_strlen($text) > self::TEASER_LENGTH) { $cut = mb_substr($text, 0, self::TEASER_LENGTH); $space = mb_strrpos($cut, ' '); $text = rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …'; } $teasers[(int)$row['uid']] = $text; } return $teasers; } /** @param array> $products */ private function json(string $query, array $products): string { return (string)json_encode([ 'query' => $query, 'suggestions' => array_values(array_unique(array_column($products, 'title'))), 'products' => $products, ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } }