Skip to content

Fixing highlights metadata #1359

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MOVIES,
meilisearchClient,
} from '../../../__tests__/test.utils'
import { HighlightMetadata } from '../highlight'

type Movie = (typeof MOVIES)[number]

Expand Down Expand Up @@ -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 = '<em>'
const post = '</em>'
const results = await fetchMeilisearchResults<Person>({
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 = '<em>'
const post = '</em>'
const results = await fetchMeilisearchResults<Person>({
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 = '<em>'
const post = '</em>'
const results = await fetchMeilisearchResults<Person>({
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'],
})
})
})
})
139 changes: 44 additions & 95 deletions packages/autocomplete-client/src/search/fetchMeilisearchResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -28,32 +30,12 @@ interface SearchParams {
>
}

interface HighlightMetadata {
value: string
fullyHighlighted: boolean
matchLevel: 'none' | 'partial' | 'full'
matchedWords: string[]
}

export function fetchMeilisearchResults<TRecord = Record<string, any>>({
searchClient,
queries,
}: SearchParams): Promise<Array<AlgoliaSearchResponse<TRecord>>> {
return searchClient
.search<TRecord>(
queries.map((searchParameters) => {
const { params, ...headers } = searchParameters
return {
...headers,
params: {
hitsPerPage: HITS_PER_PAGE,
highlightPreTag: HIGHLIGHT_PRE_TAG,
highlightPostTag: HIGHLIGHT_POST_TAG,
...params,
},
}
})
)
.search<TRecord>(buildSearchRequest(queries))
.then(
(response: Awaited<ReturnType<typeof searchClient.search<TRecord>>>) => {
return response.results.map(
Expand All @@ -64,86 +46,53 @@ export function fetchMeilisearchResults<TRecord = Record<string, any>>({
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<TRecord>),
})),
hits: buildHits<TRecord>(result, query),
}
}
)
}
)
}

/**
* 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<T, U>(value: T | T[], mapFn: (value: T) => U): U | U[] {
return Array.isArray(value) ? value.map(mapFn) : mapFn(value)
function buildHits<TRecord>(
result: AlgoliaSearchResponse<TRecord>,
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<TRecord>),
}))
}
56 changes: 56 additions & 0 deletions packages/autocomplete-client/src/search/highlight.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
12 changes: 12 additions & 0 deletions packages/autocomplete-client/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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<T, U>(
value: T | T[],
mapFn: (value: T) => U
): U | U[] {
return Array.isArray(value) ? value.map(mapFn) : mapFn(value)
}
Loading