import type { AlgoliaMultipleQueriesQuery, AlgoliaSearchResponse, } from '@meilisearch/instant-meilisearch' import { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG, HITS_PER_PAGE, } from '../constants/index.js' import type { SearchClient as MeilisearchSearchClient } from '../types/SearchClient.js' import type { HighlightResult } from 'algoliasearch-helper/types/algoliasearch.js' interface SearchParams { /** The initialized Meilisearch search client. */ searchClient: MeilisearchSearchClient /** A list of queries to execute. */ queries: Array< AlgoliaMultipleQueriesQuery & { params?: { highlightPreTag?: string highlightPostTag?: string } } > } interface HighlightMetadata { value: string fullyHighlighted: boolean matchLevel: 'none' | 'partial' | 'full' matchedWords: string[] } export function fetchMeilisearchResults>({ searchClient, queries, }: SearchParams): Promise>> { return searchClient .search( queries.map((searchParameters) => { const { params, ...headers } = searchParameters return { ...headers, params: { hitsPerPage: HITS_PER_PAGE, highlightPreTag: HIGHLIGHT_PRE_TAG, highlightPostTag: HIGHLIGHT_POST_TAG, ...params, }, } }) ) .then( (response: Awaited>>) => { return response.results.map( ( result: AlgoliaSearchResponse, resultsArrayIndex: number ) => { const query = queries[resultsArrayIndex] return { ...result, hits: result.hits.map((hit) => { const enrichedHit: any = { ...hit, _highlightResult: ( Object.entries(hit?._highlightResult || {}) as Array< [keyof TRecord, PossibleHighlightResult] > ).reduce((acc, [field, highlightResult]) => { if (!isDefinedHighlightValue(highlightResult)) { return acc } // if the field is an array, highlightResult is an array of objects acc[field] = mapOneOrMany( highlightResult, (highlightResult) => { return calculateHighlightMetadata( query.query || '', query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG, query.params?.highlightPostTag || HIGHLIGHT_POST_TAG, highlightResult.value ) } ) return acc }, {} as HighlightResult), } // Attach metadata to each hit if present (for Meilisearch Cloud Analytics) if ((result as any)._meilisearch?.metadata) { enrichedHit._meilisearch = { metadata: (result as any)._meilisearch.metadata, } } return enrichedHit }), } } ) } ) } /** * Calculate the highlight metadata for a given highlight value. * * @param query - The query string. * @param preTag - The pre tag. * @param postTag - The post tag. * @param highlightValue - The highlight value response from Meilisearch. * @returns The highlight metadata. */ function calculateHighlightMetadata( query: string, preTag: string, postTag: string, highlightValue: string ): HighlightMetadata { // Extract all highlighted segments const highlightRegex = new RegExp(`${preTag}(.*?)${postTag}`, 'g') const matches: string[] = [] let match while ((match = highlightRegex.exec(highlightValue)) !== null) { matches.push(match[1]) } // Remove highlight tags to get the highlighted text without the tags const cleanValue = highlightValue.replace( new RegExp(`${preTag}|${postTag}`, 'g'), '' ) // Determine if the entire attribute is highlighted // fullyHighlighted = true if cleanValue and the concatenation of all matched segments are identical const highlightedText = matches.join('') const fullyHighlighted = cleanValue === highlightedText // Determine match level: // - 'none' if no matches // - 'partial' if some matches but not fully highlighted // - 'full' if the highlighted text is the entire field value content let matchLevel: 'none' | 'partial' | 'full' = 'none' if (matches.length > 0) { matchLevel = cleanValue.includes(query) ? 'full' : 'partial' } return { value: highlightValue, fullyHighlighted, matchLevel, matchedWords: matches, } } // Helper to apply a function to a single value or an array of values function mapOneOrMany(value: T | T[], mapFn: (value: T) => U): U | U[] { return Array.isArray(value) ? value.map(mapFn) : mapFn(value) } type DefinedHighlightResult = { value: string } | Array<{ value: string }> // if the field is an array /** * Some fields may not return a value at all - nested arrays/objects for example * * Ideally server honours the `attributesToHighlight` param and only includes * those attributes in the response rather than all attributes (highlighted or * not) */ type UndefinedHighlightResult = { value?: never } | Array<{ value?: never }> type PossibleHighlightResult = DefinedHighlightResult | UndefinedHighlightResult function isDefinedHighlightValue( input: PossibleHighlightResult ): input is DefinedHighlightResult { if (Array.isArray(input)) { return input.every((r) => typeof r.value === 'string') } return typeof input.value === 'string' }