diff --git a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts
index 8ced9f1a..572e3b6d 100644
--- a/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts
+++ b/packages/autocomplete-client/src/search/__tests__/fetchMeilisearchResults.test.ts
@@ -4,6 +4,7 @@ import {
MOVIES,
meilisearchClient,
} from '../../../__tests__/test.utils'
+import { HighlightMetadata } from '../highlight'
type Movie = (typeof MOVIES)[number]
@@ -184,4 +185,136 @@ describe('fetchMeilisearchResults', () => {
matchedWords: [],
})
})
+
+ describe('nested object and array highlighting', () => {
+ interface Person {
+ id: number
+ name: string
+ nicknames: string[]
+ familyMembers: Array<{
+ relationship: string
+ name: string
+ }>
+ }
+
+ interface PersonHighlightResult {
+ id: HighlightMetadata
+ name: HighlightMetadata
+ nicknames: HighlightMetadata[]
+ familyMembers: Array<{
+ relationship: HighlightMetadata
+ name: HighlightMetadata
+ }>
+ }
+
+ const PERSON: Person = {
+ id: 1,
+ name: 'Joseph',
+ nicknames: ['Joe', 'Joey'],
+ familyMembers: [
+ {
+ relationship: 'mother',
+ name: 'Susan',
+ },
+ {
+ relationship: 'father',
+ name: 'John',
+ },
+ ],
+ }
+ const PEOPLE_INDEX = 'people_highlight_test'
+
+ beforeAll(async () => {
+ await meilisearchClient.deleteIndex(PEOPLE_INDEX)
+ const task = await meilisearchClient
+ .index(PEOPLE_INDEX)
+ .addDocuments([PERSON])
+ await meilisearchClient.waitForTask(task.taskUid)
+ })
+
+ afterAll(async () => {
+ await meilisearchClient.deleteIndex(PEOPLE_INDEX)
+ })
+
+ test('highlights in array values', async () => {
+ const pre = ''
+ const post = ''
+ const results = await fetchMeilisearchResults({
+ searchClient,
+ queries: [
+ {
+ indexName: PEOPLE_INDEX,
+ query: 'Joe',
+ params: {
+ highlightPreTag: pre,
+ highlightPostTag: post,
+ },
+ },
+ ],
+ })
+
+ const highlightResult = results[0].hits[0]
+ ._highlightResult as PersonHighlightResult
+ expect(highlightResult.nicknames[0]).toEqual({
+ value: `${pre}Joe${post}`,
+ fullyHighlighted: true,
+ matchLevel: 'full',
+ matchedWords: ['Joe'],
+ })
+ })
+
+ test('highlights in nested objects within arrays', async () => {
+ const pre = ''
+ const post = ''
+ const results = await fetchMeilisearchResults({
+ searchClient,
+ queries: [
+ {
+ indexName: PEOPLE_INDEX,
+ query: 'Susan',
+ params: {
+ highlightPreTag: pre,
+ highlightPostTag: post,
+ },
+ },
+ ],
+ })
+
+ const highlightResult = results[0].hits[0]
+ ._highlightResult as PersonHighlightResult
+ expect(highlightResult.familyMembers[0].name).toEqual({
+ value: `${pre}Susan${post}`,
+ fullyHighlighted: true,
+ matchLevel: 'full',
+ matchedWords: ['Susan'],
+ })
+ })
+
+ test('highlights multiple nested fields', async () => {
+ const pre = ''
+ const post = ''
+ const results = await fetchMeilisearchResults({
+ searchClient,
+ queries: [
+ {
+ indexName: PEOPLE_INDEX,
+ query: 'mother',
+ params: {
+ highlightPreTag: pre,
+ highlightPostTag: post,
+ },
+ },
+ ],
+ })
+
+ const highlightResult = results[0].hits[0]
+ ._highlightResult as PersonHighlightResult
+ expect(highlightResult.familyMembers[0].relationship).toEqual({
+ value: `${pre}mother${post}`,
+ fullyHighlighted: true,
+ matchLevel: 'full',
+ matchedWords: ['mother'],
+ })
+ })
+ })
})
diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts
index ebb7751e..05d3411e 100644
--- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts
+++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts
@@ -8,7 +8,9 @@ import {
HITS_PER_PAGE,
} from '../constants'
import { SearchClient as MeilisearchSearchClient } from '../types/SearchClient'
-import { HighlightResult } from 'instantsearch.js/es/types/algoliasearch'
+import { FieldHighlight } from 'instantsearch.js/es/types/algoliasearch'
+import { calculateHighlightMetadata } from './highlight'
+import { mapOneOrMany } from '../utils'
interface SearchParams {
/**
@@ -28,32 +30,12 @@ interface SearchParams {
>
}
-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,
- },
- }
- })
- )
+ .search(buildSearchRequest(queries))
.then(
(response: Awaited>>) => {
return response.results.map(
@@ -64,28 +46,7 @@ export function fetchMeilisearchResults>({
const query = queries[resultsArrayIndex]
return {
...result,
- hits: result.hits.map((hit) => ({
- ...hit,
- _highlightResult: (
- Object.entries(hit?._highlightResult || {}) as Array<
- | [keyof TRecord, { value: string }]
- | [keyof TRecord, Array<{ value: string }>] // if the field is an array
- >
- ).reduce((acc, [field, highlightResult]) => {
- return {
- ...acc,
- // if the field is an array, highlightResult is an array of objects
- [field]: mapOneOrMany(highlightResult, (highlightResult) =>
- calculateHighlightMetadata(
- query.query || '',
- query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG,
- query.params?.highlightPostTag || HIGHLIGHT_POST_TAG,
- highlightResult.value
- )
- ),
- }
- }, {} as HighlightResult),
- })),
+ hits: buildHits(result, query),
}
}
)
@@ -93,57 +54,45 @@ export function fetchMeilisearchResults>({
)
}
-/**
- * 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,
- }
+function buildSearchRequest(queries: AlgoliaMultipleQueriesQuery[]) {
+ return queries.map((searchParameters) => {
+ const { params, ...headers } = searchParameters
+ return {
+ ...headers,
+ params: {
+ hitsPerPage: HITS_PER_PAGE,
+ highlightPreTag: HIGHLIGHT_PRE_TAG,
+ highlightPostTag: HIGHLIGHT_POST_TAG,
+ ...params,
+ },
+ }
+ })
}
-// 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)
+function buildHits(
+ result: AlgoliaSearchResponse,
+ query: AlgoliaMultipleQueriesQuery
+) {
+ return result.hits.map((hit) => ({
+ ...hit,
+ _highlightResult: (
+ Object.entries(hit?._highlightResult || {}) as Array<
+ | [keyof TRecord, { value: string }]
+ | [keyof TRecord, Array<{ value: string }>] // if the field is an array
+ >
+ ).reduce((acc, [field, highlightResult]) => {
+ return {
+ ...acc,
+ // if the field is an array, highlightResult is an array of objects
+ [field]: mapOneOrMany(highlightResult, (highlightResult) =>
+ calculateHighlightMetadata(
+ query.query || '',
+ query.params?.highlightPreTag || HIGHLIGHT_PRE_TAG,
+ query.params?.highlightPostTag || HIGHLIGHT_POST_TAG,
+ highlightResult.value
+ )
+ ),
+ }
+ }, {} as FieldHighlight),
+ }))
}
diff --git a/packages/autocomplete-client/src/search/highlight.ts b/packages/autocomplete-client/src/search/highlight.ts
new file mode 100644
index 00000000..9523c2fb
--- /dev/null
+++ b/packages/autocomplete-client/src/search/highlight.ts
@@ -0,0 +1,56 @@
+export interface HighlightMetadata {
+ value: string
+ fullyHighlighted: boolean
+ matchLevel: 'none' | 'partial' | 'full'
+ matchedWords: string[]
+}
+
+/**
+ * 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.
+ */
+export 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,
+ }
+}
diff --git a/packages/autocomplete-client/src/utils.ts b/packages/autocomplete-client/src/utils.ts
new file mode 100644
index 00000000..866dc1cb
--- /dev/null
+++ b/packages/autocomplete-client/src/utils.ts
@@ -0,0 +1,12 @@
+/**
+ * Apply a function to a single value or an array of values
+ * @param value - The value or array of values to apply the function to
+ * @param mapFn - The function to apply to the value or array of values
+ * @returns The result of the function applied to the value or array of values
+ */
+export function mapOneOrMany(
+ value: T | T[],
+ mapFn: (value: T) => U
+): U | U[] {
+ return Array.isArray(value) ? value.map(mapFn) : mapFn(value)
+}