diff --git a/src/adapter/search-request-adapter/search-params-adapter.ts b/src/adapter/search-request-adapter/search-params-adapter.ts index 30ee9aa8..6d144ecd 100644 --- a/src/adapter/search-request-adapter/search-params-adapter.ts +++ b/src/adapter/search-request-adapter/search-params-adapter.ts @@ -63,6 +63,22 @@ export function adaptSearchParams( '*', ] + // Highlight pre tag + const highlightPreTag = searchContext?.highlightPreTag + if (highlightPreTag) { + meiliSearchParams.highlightPreTag = highlightPreTag + } else { + meiliSearchParams.highlightPreTag = '__ais-highlight__' + } + + // Highlight post tag + const highlightPostTag = searchContext?.highlightPostTag + if (highlightPostTag) { + meiliSearchParams.highlightPostTag = highlightPostTag + } else { + meiliSearchParams.highlightPostTag = '__/ais-highlight__' + } + const placeholderSearch = searchContext.placeholderSearch const query = searchContext.query diff --git a/src/adapter/search-response-adapter/format-adapter/format-adapter.ts b/src/adapter/search-response-adapter/format-adapter/format-adapter.ts index 1d9e225f..7d3c656d 100644 --- a/src/adapter/search-response-adapter/format-adapter/format-adapter.ts +++ b/src/adapter/search-response-adapter/format-adapter/format-adapter.ts @@ -1,22 +1,70 @@ -import { adaptHighlight } from './highlight-adapter' -import { SearchContext } from '../../../types' +import { isPureObject } from '../../../utils' /** - * Adapt Meilisearch formating to formating compliant with instantsearch.js. + * Stringify values following instantsearch practices. + * + * @param {any} value - value that needs to be stringified + */ +function stringifyValue(value: any) { + if (typeof value === 'string') { + // String + return value + } else if (value === undefined) { + // undefined + return JSON.stringify(null) + } else { + return JSON.stringify(value) + } +} + +/** + * Recursif function wrap the deepest possible value + * the following way: { value: "xx" }. + * + * For example: + * + * { + * "rootField": { "value": "x" } + * "nestedField": { child: { value: "y" } } + * } + * + * recursivity continues until the value is not an array or an object. + * + * @param {any} value - value of a field + * + * @returns Record + */ +function wrapValue(value: any): Record { + if (Array.isArray(value)) { + // Array + return value.map((elem) => wrapValue(elem)) + } else if (isPureObject(value)) { + // Object + return Object.keys(value).reduce>( + (nested: Record, key: string) => { + nested[key] = wrapValue(value[key]) + + return nested + }, + {} + ) + } else { + return { value: stringifyValue(value) } + } +} + +/** + * Adapt Meilisearch formatted fields to a format compliant to instantsearch.js. * * @param {Record, - searchContext: SearchContext +export function adaptFormattedFields( + hit: Record ): Record { - const preTag = searchContext?.highlightPreTag - const postTag = searchContext?.highlightPostTag - - if (!hit._formatted) return {} - const _formattedResult = adaptHighlight(hit, preTag, postTag) + if (!hit) return {} + const _formattedResult = wrapValue(hit) const highlightedHit = { // We could not determine what the differences are between those two fields. diff --git a/src/adapter/search-response-adapter/format-adapter/highlight-adapter.ts b/src/adapter/search-response-adapter/format-adapter/highlight-adapter.ts deleted file mode 100644 index b6101b8c..00000000 --- a/src/adapter/search-response-adapter/format-adapter/highlight-adapter.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isString } from '../../../utils' -/** - * Replace `em` tags in highlighted Meilisearch hits to - * provided tags by instantsearch.js. - * - * @param {string} value - * @param {string} highlightPreTag? - * @param {string} highlightPostTag? - * @returns {string} - */ -function replaceDefaultEMTag( - value: any, - preTag = '__ais-highlight__', - postTag = '__/ais-highlight__' -): string { - // Highlight is applied by Meilisearch ( tags) - // We replace the by the expected tag for InstantSearch - const stringifiedValue = isString(value) ? value : JSON.stringify(value) - - return stringifiedValue.replace(//g, preTag).replace(/<\/em>/g, postTag) -} - -function addHighlightTags( - value: any, - preTag?: string, - postTag?: string -): string { - if (typeof value === 'string') { - // String - return replaceDefaultEMTag(value, preTag, postTag) - } else if (value === undefined) { - // undefined - return JSON.stringify(null) - } else { - // Other - return JSON.stringify(value) - } -} - -export function resolveHighlightValue( - value: any, - preTag?: string, - postTag?: string -): { value: string } | Array<{ value: string }> { - if (Array.isArray(value)) { - // Array - return value.map((elem) => ({ - value: addHighlightTags(elem, preTag, postTag), - })) - } else { - return { value: addHighlightTags(value, preTag, postTag) } - } -} - -/** - * @param {Record, - preTag?: string, - postTag?: string -): Record { - // hit is the `_formatted` object returned by Meilisearch. - // It contains all the highlighted and croped attributes - - if (!hit._formatted) return hit._formatted - return Object.keys(hit._formatted).reduce((result, key) => { - const value = hit._formatted[key] - - result[key] = resolveHighlightValue(value, preTag, postTag) - return result - }, {} as any) -} diff --git a/src/adapter/search-response-adapter/hits-adapter.ts b/src/adapter/search-response-adapter/hits-adapter.ts index 2c0b88a1..9899c8d1 100644 --- a/src/adapter/search-response-adapter/hits-adapter.ts +++ b/src/adapter/search-response-adapter/hits-adapter.ts @@ -1,6 +1,6 @@ import type { PaginationContext, SearchContext } from '../../types' import { adaptPagination } from './pagination-adapter' -import { adaptFormating } from './format-adapter' +import { adaptFormattedFields } from './format-adapter' import { adaptGeoResponse } from './geo-reponse-adapter' /** @@ -18,19 +18,23 @@ export function adaptHits( const { hitsPerPage, page } = paginationContext const paginatedHits = adaptPagination(hits, page, hitsPerPage) - let formattedHits = paginatedHits.map((hit: Record) => { + let adaptedHits = paginatedHits.map((hit: Record) => { // Creates Hit object compliant with InstantSearch if (Object.keys(hit).length > 0) { - const { _formatted: formattedHit, _matchesInfo, ...restOfHit } = hit + const { _formatted: formattedHit, _matchesInfo, ...documentFields } = hit - return { - ...restOfHit, - ...adaptFormating(hit, searchContext), - ...(primaryKey && { objectID: hit[primaryKey] }), + const adaptedHit: Record = Object.assign( + documentFields, + adaptFormattedFields(formattedHit) + ) + + if (primaryKey) { + adaptedHit.objectID = hit[primaryKey] } + return adaptedHit } return hit }) - formattedHits = adaptGeoResponse(formattedHits) - return formattedHits + adaptedHits = adaptGeoResponse(adaptedHits) + return adaptedHits } diff --git a/src/utils/index.ts b/src/utils/index.ts index d81c4c9a..75968a85 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './array' export * from './string' export * from './number' +export * from './object' diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 00000000..b5a8afe3 --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,3 @@ +export function isPureObject(data: any) { + return typeof data === 'object' && !Array.isArray(data) && data !== null +} diff --git a/tests/assets/utils.ts b/tests/assets/utils.ts index 4e53a334..dbb638b2 100644 --- a/tests/assets/utils.ts +++ b/tests/assets/utils.ts @@ -208,8 +208,8 @@ export type Movies = { poster?: string genres?: string[] release_date?: number // eslint-disable-line - undefinedArray?: [undefined, undefined, undefined] - nullArray?: [null] + undefinedArray?: undefined[] + nullArray?: null[] objectArray?: Array<{ name: string }> object?: { id?: number diff --git a/tests/highlight.tests.ts b/tests/highlight.tests.ts index 1bdc7c4a..3da94601 100644 --- a/tests/highlight.tests.ts +++ b/tests/highlight.tests.ts @@ -231,13 +231,18 @@ describe('Highlight Browser test', () => { { indexName: 'movies', params: { - query: 'Ariel', + query: 'hello', attributesToHighlight: ['*'], }, }, ]) + const hit = response.results[0].hits[0]._highlightResult + if (hit?.title) { + expect(hit?.title?.value).toEqual('Ariel') + } + if (hit?.genres) { expect(hit?.genres[0]?.value).toEqual('Drama') expect(hit?.genres[1]?.value).toEqual('Crime') @@ -260,15 +265,15 @@ describe('Highlight Browser test', () => { } if (hit?.objectArray) { - // @ts-ignore - expect(hit?.objectArray[0]?.value).toEqual('{"name":"hello world"}') - // @ts-ignore - expect(hit?.objectArray[1]?.value).toEqual('{"name":"hello world"}') + expect(hit?.objectArray[0]?.name.value).toEqual( + '__ais-highlight__hello__/ais-highlight__ world' + ) } if (hit?.object) { - // @ts-ignore - expect(hit?.object?.value).toEqual('{"id":"1","name":"One two"}') + expect(hit?.object?.id?.value).toEqual('1') + + expect(hit?.object?.name?.value).toEqual('One two') } if (hit?.nullField) { diff --git a/tests/snippets.tests.ts b/tests/snippets.tests.ts index 9d9f7f32..adbea30a 100644 --- a/tests/snippets.tests.ts +++ b/tests/snippets.tests.ts @@ -297,15 +297,13 @@ describe('Snippet Browser test', () => { } if (hit?.objectArray) { - // @ts-ignore - expect(hit?.objectArray[0]?.value).toEqual('{"name":"hello…"}') - // @ts-ignore - expect(hit?.objectArray[1]?.value).toEqual('{"name":"hello…"}') + expect(hit?.objectArray[0]?.name?.value).toEqual('hello…') + expect(hit?.objectArray[1]?.name?.value).toEqual('hello…') } if (hit?.object) { - // @ts-ignore - expect(hit?.object?.value).toEqual('{"id":"1","name":"One…"}') + expect(hit?.object?.name?.value).toEqual('One…') + expect(hit?.object?.id?.value).toEqual('1') } if (hit?.nullField) { @@ -357,15 +355,13 @@ describe('Snippet Browser test', () => { } if (hit?.objectArray) { - // @ts-ignore - expect(hit?.objectArray[0]?.value).toEqual('{"name":"hello( •_•)"}') - // @ts-ignore - expect(hit?.objectArray[1]?.value).toEqual('{"name":"hello( •_•)"}') + expect(hit?.objectArray[0]?.name?.value).toEqual('hello( •_•)') + expect(hit?.objectArray[1]?.name?.value).toEqual('hello( •_•)') } if (hit?.object) { - // @ts-ignore - expect(hit?.object?.value).toEqual('{"id":"1","name":"One( •_•)"}') + expect(hit?.object?.id?.value).toEqual('1') + expect(hit?.object?.name?.value).toEqual('One( •_•)') } if (hit?.nullField) {