From 19f0b467f67948da03159dc73e3d69245f183a6a Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 18:42:00 +0800 Subject: [PATCH 1/6] Create highlight helper file --- .../src/search/fetchMeilisearchResults.ts | 51 +---------------- .../src/search/highlight.ts | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 packages/autocomplete-client/src/search/highlight.ts diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index ebb7751e..3a3d3445 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -9,6 +9,7 @@ import { } from '../constants' import { SearchClient as MeilisearchSearchClient } from '../types/SearchClient' import { HighlightResult } from 'instantsearch.js/es/types/algoliasearch' +import { calculateHighlightMetadata } from './highlight' interface SearchParams { /** @@ -93,56 +94,6 @@ 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, - } -} - // 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) diff --git a/packages/autocomplete-client/src/search/highlight.ts b/packages/autocomplete-client/src/search/highlight.ts new file mode 100644 index 00000000..5b933b30 --- /dev/null +++ b/packages/autocomplete-client/src/search/highlight.ts @@ -0,0 +1,56 @@ +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, + } +} From d76ccfb5021ab2aabd26c60ced2039a7f58a1740 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 18:42:22 +0800 Subject: [PATCH 2/6] Remove unused type --- .../src/search/fetchMeilisearchResults.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index 3a3d3445..8a2659b3 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -29,13 +29,6 @@ interface SearchParams { > } -interface HighlightMetadata { - value: string - fullyHighlighted: boolean - matchLevel: 'none' | 'partial' | 'full' - matchedWords: string[] -} - export function fetchMeilisearchResults>({ searchClient, queries, From 1d2c2df9a855d231202799d9eff77e88eeaec0d5 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 18:42:55 +0800 Subject: [PATCH 3/6] Move utils to its own file --- .../src/search/fetchMeilisearchResults.ts | 6 +----- packages/autocomplete-client/src/utils.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 packages/autocomplete-client/src/utils.ts diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index 8a2659b3..cb0281f4 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -10,6 +10,7 @@ import { import { SearchClient as MeilisearchSearchClient } from '../types/SearchClient' import { HighlightResult } from 'instantsearch.js/es/types/algoliasearch' import { calculateHighlightMetadata } from './highlight' +import { mapOneOrMany } from '../utils' interface SearchParams { /** @@ -86,8 +87,3 @@ export function fetchMeilisearchResults>({ } ) } - -// 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) -} 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) +} From 2701af3dd6567dfb8eecd509babfa7584301885c Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 18:44:01 +0800 Subject: [PATCH 4/6] Refactor: extract function to build search requests --- .../src/search/fetchMeilisearchResults.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index cb0281f4..50b939ad 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -35,20 +35,7 @@ export function fetchMeilisearchResults>({ 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( @@ -87,3 +74,18 @@ export function fetchMeilisearchResults>({ } ) } + +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, + }, + } + }) +} From 6308b63d86810fecd93f9669d92a48719bf2efd4 Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 18:53:54 +0800 Subject: [PATCH 5/6] Refactor: create buildHits function --- .../src/search/fetchMeilisearchResults.ts | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts index 50b939ad..cb382255 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -46,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), } } ) @@ -89,3 +68,31 @@ function buildSearchRequest(queries: AlgoliaMultipleQueriesQuery[]) { } }) } + +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 HighlightResult), + })) +} From 29f70c0644d43af476d597b028a27c0a8be81b1a Mon Sep 17 00:00:00 2001 From: Strift Date: Fri, 10 Jan 2025 19:43:17 +0800 Subject: [PATCH 6/6] Add new tests for unhandled usecase --- .../__tests__/fetchMeilisearchResults.test.ts | 133 ++++++++++++++++++ .../src/search/fetchMeilisearchResults.ts | 4 +- .../src/search/highlight.ts | 2 +- 3 files changed, 136 insertions(+), 3 deletions(-) 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 cb382255..05d3411e 100644 --- a/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts +++ b/packages/autocomplete-client/src/search/fetchMeilisearchResults.ts @@ -8,7 +8,7 @@ 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' @@ -93,6 +93,6 @@ function buildHits( ) ), } - }, {} as HighlightResult), + }, {} as FieldHighlight), })) } diff --git a/packages/autocomplete-client/src/search/highlight.ts b/packages/autocomplete-client/src/search/highlight.ts index 5b933b30..9523c2fb 100644 --- a/packages/autocomplete-client/src/search/highlight.ts +++ b/packages/autocomplete-client/src/search/highlight.ts @@ -1,4 +1,4 @@ -interface HighlightMetadata { +export interface HighlightMetadata { value: string fullyHighlighted: boolean matchLevel: 'none' | 'partial' | 'full'