Skip to content
Merged
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
177 changes: 95 additions & 82 deletions src/adapter/search-response-adapter/highlight-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,39 @@ import { SearchContext } from '../../types'
*/
function replaceHighlightTags(
value: any,
highlightPreTag?: string,
highlightPostTag?: string
preTag?: string,
postTag?: string
): string {
highlightPreTag = highlightPreTag || '__ais-highlight__'
highlightPostTag = highlightPostTag || '__/ais-highlight__'
preTag = preTag || '__ais-highlight__'
postTag = postTag || '__/ais-highlight__'
// Highlight is applied by MeiliSearch (<em> tags)
// We replace the <em> by the expected tag for InstantSearch
const stringifiedValue = isString(value) ? value : JSON.stringify(value)

return stringifiedValue
.replace(/<em>/g, highlightPreTag)
.replace(/<\/em>/g, highlightPostTag)
return stringifiedValue.replace(/<em>/g, preTag).replace(/<\/em>/g, postTag)
}

function addHighlightTags(value: string, preTag?: string, postTag?: string) {
return {
value: replaceHighlightTags(value, preTag, postTag),
}
}

function resolveHighlightValue(
value: string,
preTag?: string,
postTag?: string
) {
if (typeof value === 'string') {
// String
return addHighlightTags(value, preTag, postTag)
} else if (value === undefined) {
// undefined
return { value: JSON.stringify(null) }
} else {
// Other
return { value: JSON.stringify(value) }
}
}

/**
Expand All @@ -33,98 +54,93 @@ function replaceHighlightTags(
* @returns {Record}
*/
function adaptHighlight(
formattedHit: Record<string, any>,
highlightPreTag?: string,
highlightPostTag?: string
hit: Record<string, any>,
preTag?: string,
postTag?: string
): Record<string, any> {
// formattedHit is the `_formatted` object returned by MeiliSearch.
// hit is the `_formatted` object returned by MeiliSearch.
// It contains all the highlighted and croped attributes
const toHighlightMatch = (value: any) => ({
value: replaceHighlightTags(value, highlightPreTag, highlightPostTag),
})
return Object.keys(formattedHit).reduce((result, key) => {
const value = formattedHit[key]

return Object.keys(hit).reduce((result, key) => {
const value = hit[key]

if (Array.isArray(value)) {
result[key] = value.map((val) => ({
value: typeof val === 'object' ? JSON.stringify(val) : val,
}))
} else if (typeof value === 'object' && value !== null) {
result[key] = { value: JSON.stringify(value) }
// Array
result[key] = value.map((elem) =>
resolveHighlightValue(elem, preTag, postTag)
)
} else {
result[key] = toHighlightMatch(value)
result[key] = resolveHighlightValue(value, preTag, postTag)
}
return result
}, {} as any)
}

/**
* @param {string} value
* @param {string} snippetEllipsisText?
* @param {string} highlightPreTag?
* @param {string} highlightPostTag?
* @param {string} preTag?
* @param {string} postTag?
* @param {string} ellipsis?
* @returns {string}
*/
function snippetValue(
function resolveSnippetValue(
value: string,
snippetEllipsisText?: string,
highlightPreTag?: string,
highlightPostTag?: string
): string {
preTag?: string,
postTag?: string,
ellipsis?: string
): { value: string } {
let newValue = value
// manage a kind of `...` for the crop until this feature is implemented https://roadmap.meilisearch.com/c/69-policy-for-cropped-values?utm_medium=social&utm_source=portal_share
// `...` is put if we are at the middle of a sentence (instead at the middle of the document field)
if (snippetEllipsisText !== undefined && isString(newValue) && newValue) {

// Manage ellpsis on cropped values until this feature is implemented https://roadmap.meilisearch.com/c/69-policy-for-cropped-values?utm_medium=social&utm_source=portal_share in MeiliSearch
if (newValue && ellipsis !== undefined && isString(newValue)) {
if (
newValue[0] === newValue[0].toLowerCase() && // beginning of a sentence
newValue.startsWith('<em>') === false // beginning of the document field, otherwise MeiliSearch would crop around the highligh
newValue.startsWith('<em>') === false // beginning of the document field, otherwise MeiliSearch would crop around the highlight
) {
newValue = `${snippetEllipsisText}${newValue}`
newValue = `${ellipsis}${newValue}`
}
if (!!newValue.match(/[.!?]$/) === false) {
// end of the sentence
newValue = `${newValue}${snippetEllipsisText}`
newValue = `${newValue}${ellipsis}`
}
}
return replaceHighlightTags(newValue, highlightPreTag, highlightPostTag)
return resolveHighlightValue(newValue, preTag, postTag)
}

/**
* @param {Record<string} formattedHit
* @param {readonlystring[]|undefined} attributesToSnippet
* @param {string|undefined} snippetEllipsisText
* @param {string|undefined} highlightPreTag
* @param {string|undefined} highlightPostTag
* @param {Record<string} hit
* @param {readonlystring[]|undefined} attributes
* @param {string|undefined} ellipsis
* @param {string|undefined} preTag
* @param {string|undefined} postTage
*/
function adaptSnippet(
formattedHit: Record<string, any>,
attributesToSnippet: readonly string[] | undefined,
snippetEllipsisText: string | undefined,
highlightPreTag: string | undefined,
highlightPostTag: string | undefined
hit: Record<string, any>,
attributes: readonly string[] | undefined,
ellipsis: string | undefined,
pretag: string | undefined,
postTag: string | undefined
) {
if (attributesToSnippet === undefined) {
if (attributes === undefined) {
return null
}
attributesToSnippet = attributesToSnippet.map(
(attribute) => attribute.split(':')[0]
) as any[]
const snippetAll = attributesToSnippet.includes('*')
// formattedHit is the `_formatted` object returned by MeiliSearch.
attributes = attributes.map((attribute) => attribute.split(':')[0]) as any[]
const snippetAll = attributes.includes('*')

// hit is the `_formatted` object returned by MeiliSearch.
// It contains all the highlighted and croped attributes
const toSnippetMatch = (value: any) => ({
value: snippetValue(
value,
snippetEllipsisText,
highlightPreTag,
highlightPostTag
),
})
return (Object.keys(formattedHit) as any[]).reduce((result, key) => {
if (snippetAll || attributesToSnippet?.includes(key)) {
const value = formattedHit[key]
result[key] = Array.isArray(value)
? value.map(toSnippetMatch)
: toSnippetMatch(value)

return (Object.keys(hit) as any[]).reduce((result, key) => {
if (snippetAll || attributes?.includes(key)) {
const value = hit[key]
if (Array.isArray(value)) {
// Array
result[key] = value.map((elem) =>
resolveSnippetValue(elem, pretag, postTag, ellipsis)
)
} else {
result[key] = resolveSnippetValue(value, pretag, postTag, ellipsis)
}
}
return result
}, {} as any)
Expand All @@ -138,27 +154,24 @@ function adaptSnippet(
* @returns {Record}
*/
export function adaptFormating(
formattedHit: Record<string, any>,
hit: Record<string, any>,
searchContext: SearchContext
): Record<string, any> {
const attributesToSnippet = searchContext?.attributesToSnippet
const snippetEllipsisText = searchContext?.snippetEllipsisText
const highlightPreTag = searchContext?.highlightPreTag
const highlightPostTag = searchContext?.highlightPostTag
const ellipsis = searchContext?.snippetEllipsisText
const preTag = searchContext?.highlightPreTag
const postTag = searchContext?.highlightPostTag

if (!formattedHit || formattedHit.length) return {}
return {
_highlightResult: adaptHighlight(
formattedHit,
highlightPreTag,
highlightPostTag
),
if (!hit || hit.length) return {}
const highlightedHit = {
_highlightResult: adaptHighlight(hit, preTag, postTag),
_snippetResult: adaptSnippet(
formattedHit,
hit,
attributesToSnippet,
snippetEllipsisText,
highlightPreTag,
highlightPostTag
ellipsis,
preTag,
postTag
),
}
return highlightedHit
}
44 changes: 44 additions & 0 deletions tests/assets/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ const dataset = [
genres: ['Drama', 'Crime', 'Comedy'],
poster: 'https://image.tmdb.org/t/p/w500/ojDg0PGvs6R9xYFodRct2kdI6wC.jpg',
release_date: 593395200,
undefinedArray: [undefined, undefined, undefined],
nullArray: [null, null, null],
objectArray: [
{ name: 'charlotte' },
{ name: 'charlotte' },
{ name: 'charlotte' },
],
object: {
id: 1,
name: 'Nader',
},
nullField: null,
},
{
id: 5,
Expand All @@ -18,6 +30,18 @@ const dataset = [
genres: ['Crime', 'Comedy'],
poster: 'https://image.tmdb.org/t/p/w500/75aHn1NOYXh4M7L5shoeQ6NGykP.jpg',
release_date: 818467200,
undefinedArray: [undefined, undefined, undefined],
nullArray: [null, null, null],
objectArray: [
{ name: 'charlotte' },
{ name: 'charlotte' },
{ name: 'charlotte' },
],
object: {
id: 1,
name: 'Nader',
},
nullField: null,
},
{
id: 6,
Expand All @@ -27,6 +51,18 @@ const dataset = [
genres: ['Action', 'Thriller', 'Crime'],
poster: 'https://image.tmdb.org/t/p/w500/rYFAvSPlQUCebayLcxyK79yvtvV.jpg',
release_date: 750643200,
undefinedArray: [undefined, undefined, undefined],
nullArray: [null, null, null],
objectArray: [
{ name: 'charlotte' },
{ name: 'charlotte' },
{ name: 'charlotte' },
],
object: {
id: 1,
name: 'Nader',
},
nullField: null,
},
{
id: 11,
Expand Down Expand Up @@ -170,6 +206,14 @@ export type Movies = {
overview?: string
genres?: string[]
release_date?: number // eslint-disable-line
undefinedArray?: [undefined, undefined, undefined]
nullArray?: [null]
objectArray?: Array<{ name: string }>
object?: {
id?: number
name?: string
}
nullField?: null
_highlightResult?: Movies
}

Expand Down
51 changes: 51 additions & 0 deletions tests/highlight.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,55 @@ describe('Highlight Browser test', () => {
expect.stringMatching('<p>S</p>olo')
)
})

test('Test attributes to highlight on non-string-types', async () => {
const response = await searchClient.search<Movies>([
{
indexName: 'movies',
params: {
query: 'Ariel',
attributesToHighlight: ['*'],
},
},
])
const hit = response.results[0].hits[0]._highlightResult

if (hit?.genres) {
expect(hit?.genres[0]?.value).toEqual('Drama')
expect(hit?.genres[1]?.value).toEqual('Crime')
}
if (hit?.id) {
expect(hit?.id.value).toEqual('2')
}
if (hit?.undefinedArray) {
// @ts-ignore
expect(hit?.undefinedArray[0]?.value).toEqual('null')
// @ts-ignore
expect(hit?.undefinedArray[1]?.value).toEqual('null')
}

if (hit?.nullArray) {
// @ts-ignore
expect(hit?.nullArray[0]?.value).toEqual('null')
// @ts-ignore
expect(hit?.nullArray[1]?.value).toEqual('null')
}

if (hit?.objectArray) {
// @ts-ignore
expect(hit?.objectArray[0]?.value).toEqual('{"name":"charlotte"}')
// @ts-ignore
expect(hit?.objectArray[1]?.value).toEqual('{"name":"charlotte"}')
}

if (hit?.object) {
// @ts-ignore
expect(hit?.object?.value).toEqual('{"id":1,"name":"Nader"}')
}

if (hit?.nullField) {
// @ts-ignore
expect(hit?.nullField?.value).toEqual('null')
}
})
})
Loading