diff --git a/package.json b/package.json index 2e88d4874b..624dbbc91e 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "bundlesize": [ { "path": "./dist/instantsearch.production.min.js", - "maxSize": "65.25 kB" + "maxSize": "65.5 kB" }, { "path": "./dist/instantsearch.development.js", diff --git a/src/helpers/__tests__/reverseHighlight-test.ts b/src/helpers/__tests__/reverseHighlight-test.ts new file mode 100644 index 0000000000..abd9316803 --- /dev/null +++ b/src/helpers/__tests__/reverseHighlight-test.ts @@ -0,0 +1,203 @@ +import reverseHighlight from '../reverseHighlight'; + +const NONE = 'none' as const; +const FULL = 'full' as const; + +/* eslint-disable @typescript-eslint/camelcase */ +const hit = { + name: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + description: + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + brand: 'Amazon', + categories: ['TV & Home Theater', 'Streaming Media Players'], + hierarchicalCategories: { + lvl0: 'TV & Home Theater', + lvl1: 'TV & Home Theater > Streaming Media Players', + }, + type: 'Streaming - (media plyr)', + price: 39.99, + price_range: '1 - 50', + image: 'https://cdn-demo.algolia.com/bestbuy-0118/5477500_sb.jpg', + url: 'https://api.bestbuy.com/click/-/5477500/pdp', + free_shipping: false, + rating: 4, + popularity: 21469, + objectID: '5477500', + _highlightResult: { + name: { + value: + 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon'], + }, + description: { + value: + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon', 'processor', '1GB'], + }, + brand: { + value: 'Amazon', + matchLevel: FULL, + fullyHighlighted: true, + matchedWords: ['amazon'], + }, + categories: [ + { + value: 'TV & Home Theater', + matchLevel: NONE, + matchedWords: [], + }, + { + value: 'Streaming Media Players', + matchLevel: NONE, + matchedWords: [], + }, + ], + type: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media'], + }, + typeMissingSibling: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['media'], + }, + typeFullMatch: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media', 'plyr'], + }, + meta: { + name: { + value: 'Nested Amazon name', + matchLevel: NONE, + matchedWords: ['Amazon'], + }, + }, + }, +}; +/* eslint-enable @typescript-eslint/camelcase */ + +describe('reverseHighlight', () => { + test('with default tag name', () => { + expect( + reverseHighlight({ + attribute: 'name', + hit, + }) + ).toMatchInlineSnapshot( + `"Amazon - Fire TV Stick with Alexa Voice Remote - Black"` + ); + }); + + test('with full match', () => { + expect( + reverseHighlight({ + attribute: 'typeFullMatch', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming - (media plyr)"`); + }); + + test('with custom tag name', () => { + expect( + reverseHighlight({ + attribute: 'description', + highlightedTagName: 'em', + hit, + }) + ).toMatchInlineSnapshot( + `"Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming."` + ); + }); + + test('with custom highlighted class name', () => { + expect( + reverseHighlight({ + attribute: 'description', + cssClasses: { highlighted: '__highlighted class' }, + hit, + }) + ).toMatchInlineSnapshot( + `"Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming."` + ); + }); + + test('with unknown attribute returns an empty string', () => { + expect( + reverseHighlight({ + attribute: 'wrong-attribute', + hit, + }) + ).toMatchInlineSnapshot(`""`); + }); + + test('with nested attribute', () => { + expect( + reverseHighlight({ + attribute: 'meta.name', + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with nested attribute as array', () => { + expect( + reverseHighlight({ + attribute: ['meta', 'name'], + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with array attribute', () => { + expect( + reverseHighlight({ + attribute: 'categories.1', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with array attribute as array', () => { + expect( + reverseHighlight({ + attribute: ['categories', '1'], + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with non-alphanumeric character with alphanumeric siblings matching highlight', () => { + expect( + reverseHighlight({ + attribute: 'type', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); + + test('with non-alphanumeric character with different alphanumeric siblings highlight', () => { + expect( + reverseHighlight({ + attribute: 'typeMissingSibling', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); +}); diff --git a/src/helpers/__tests__/reverseSnippet-test.ts b/src/helpers/__tests__/reverseSnippet-test.ts new file mode 100644 index 0000000000..a686d320a7 --- /dev/null +++ b/src/helpers/__tests__/reverseSnippet-test.ts @@ -0,0 +1,200 @@ +import reverseSnippet from '../reverseSnippet'; + +const NONE = 'none' as const; +const FULL = 'full' as const; + +/* eslint-disable @typescript-eslint/camelcase */ +const hit = { + name: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + description: + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + brand: 'Amazon', + categories: ['TV & Home Theater', 'Streaming Media Players'], + hierarchicalCategories: { + lvl0: 'TV & Home Theater', + lvl1: 'TV & Home Theater > Streaming Media Players', + }, + type: 'Streaming media plyr', + price: 39.99, + price_range: '1 - 50', + image: 'https://cdn-demo.algolia.com/bestbuy-0118/5477500_sb.jpg', + url: 'https://api.bestbuy.com/click/-/5477500/pdp', + free_shipping: false, + rating: 4, + popularity: 21469, + objectID: '5477500', + _snippetResult: { + name: { + value: + 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon'], + }, + description: { + value: + 'Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming.', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['amazon', 'procesor', '1GB'], + }, + brand: { + value: 'Amazon', + matchLevel: FULL, + fullyHighlighted: true, + matchedWords: ['amazon'], + }, + categories: [ + { + value: 'TV & Home Theater', + matchLevel: NONE, + }, + { + value: 'Streaming Media Players', + matchLevel: NONE, + }, + ], + type: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media'], + }, + typeMissingSibling: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['media'], + }, + typeFullMatch: { + value: 'Streaming - (media plyr)', + matchLevel: FULL, + fullyHighlighted: false, + matchedWords: ['streaming', 'media', 'plyr'], + }, + meta: { + name: { + value: 'Nested Amazon name', + matchLevel: FULL, + }, + }, + }, +}; +/* eslint-enable @typescript-eslint/camelcase */ + +describe('reverseSnippet', () => { + test('with default tag name', () => { + expect( + reverseSnippet({ + attribute: 'name', + hit, + }) + ).toMatchInlineSnapshot( + `"Amazon - Fire TV Stick with Alexa Voice Remote - Black"` + ); + }); + + test('with full match', () => { + expect( + reverseSnippet({ + attribute: 'typeFullMatch', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming - (media plyr)"`); + }); + + test('with custom tag name', () => { + expect( + reverseSnippet({ + attribute: 'description', + highlightedTagName: 'em', + hit, + }) + ).toMatchInlineSnapshot( + `"Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming."` + ); + }); + + test('with custom highlighted class name', () => { + expect( + reverseSnippet({ + attribute: 'description', + cssClasses: { highlighted: '__highlighted' }, + hit, + }) + ).toMatchInlineSnapshot( + `"Enjoy smart access to videos, games and apps with this Amazon Fire TV stick. Its Alexa voice remote lets you deliver hands-free commands when you want to watch television or engage with other applications. With a quad-core processor, 1GB internal memory and 8GB of storage, this portable Amazon Fire TV stick works fast for buffer-free streaming."` + ); + }); + + test('with unknown attribute returns an empty string', () => { + expect( + reverseSnippet({ + attribute: 'wrong-attribute', + hit, + }) + ).toMatchInlineSnapshot(`""`); + }); + + test('with nested attribute', () => { + expect( + reverseSnippet({ + attribute: 'meta.name', + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with nested attribute as array', () => { + expect( + reverseSnippet({ + attribute: ['meta', 'name'], + hit, + }) + ).toMatchInlineSnapshot( + `"Nested Amazon name"` + ); + }); + + test('with array attribute', () => { + expect( + reverseSnippet({ + attribute: 'categories.1', + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with array attribute as array', () => { + expect( + reverseSnippet({ + attribute: ['categories', '1'], + hit, + }) + ).toMatchInlineSnapshot(`"Streaming Media Players"`); + }); + + test('with non-alphanumeric character with alphanumeric siblings matching highlight', () => { + expect( + reverseSnippet({ + attribute: 'type', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); + + test('with non-alphanumeric character with different alphanumeric siblings highlight', () => { + expect( + reverseSnippet({ + attribute: 'typeMissingSibling', + hit, + }) + ).toMatchInlineSnapshot( + `"Streaming - (media plyr)"` + ); + }); +}); diff --git a/src/helpers/highlight.ts b/src/helpers/highlight.ts index cb2ad93321..f77e23704f 100644 --- a/src/helpers/highlight.ts +++ b/src/helpers/highlight.ts @@ -8,9 +8,9 @@ export type HighlightOptions = { attribute: string | string[]; highlightedTagName?: string; hit: Partial; - cssClasses?: { - highlighted?: string; - }; + cssClasses?: Partial<{ + highlighted: string; + }>; }; const suit = component('Highlight'); diff --git a/src/helpers/index.ts b/src/helpers/index.ts index d4df1a4b04..efa2a06e29 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,5 +1,9 @@ export * from './highlight'; +export * from './reverseHighlight'; export * from './snippet'; +export * from './reverseSnippet'; +export { default as reverseHighlight } from './reverseHighlight'; +export { default as reverseSnippet } from './reverseSnippet'; export { default as highlight } from './highlight'; export { default as snippet } from './snippet'; export { default as insights } from './insights'; diff --git a/src/helpers/reverseHighlight.ts b/src/helpers/reverseHighlight.ts new file mode 100644 index 0000000000..3970c63afb --- /dev/null +++ b/src/helpers/reverseHighlight.ts @@ -0,0 +1,51 @@ +import { Hit } from '../types'; +import { + getPropertyByPath, + getHighlightedParts, + reverseHighlightedParts, + concatHighlightedParts, +} from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; +import { component } from '../lib/suit'; + +export type ReverseHighlightOptions = { + // @MAJOR string should no longer be allowed to be a path, only array can be a path + attribute: string | string[]; + highlightedTagName?: string; + hit: Partial; + cssClasses?: Partial<{ + highlighted: string; + }>; +}; + +const suit = component('ReverseHighlight'); + +export default function reverseHighlight({ + attribute, + highlightedTagName = 'mark', + hit, + cssClasses = {}, +}: ReverseHighlightOptions): string { + const { value: attributeValue = '' } = + getPropertyByPath(hit._highlightResult, attribute) || {}; + + // cx is not used, since it would be bundled as a dependency for Vue & Angular + const className = + suit({ + descendantName: 'highlighted', + }) + (cssClasses.highlighted ? ` ${cssClasses.highlighted}` : ''); + + const reverseHighlightedValue = concatHighlightedParts( + reverseHighlightedParts(getHighlightedParts(attributeValue)) + ); + + return reverseHighlightedValue + .replace( + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` + ) + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); +} diff --git a/src/helpers/reverseSnippet.ts b/src/helpers/reverseSnippet.ts new file mode 100644 index 0000000000..a2f06c22b0 --- /dev/null +++ b/src/helpers/reverseSnippet.ts @@ -0,0 +1,51 @@ +import { Hit } from '../types'; +import { + getPropertyByPath, + getHighlightedParts, + reverseHighlightedParts, + concatHighlightedParts, +} from '../lib/utils'; +import { TAG_REPLACEMENT } from '../lib/escape-highlight'; +import { component } from '../lib/suit'; + +export type ReverseSnippetOptions = { + // @MAJOR string should no longer be allowed to be a path, only array can be a path + attribute: string | string[]; + highlightedTagName?: string; + hit: Partial; + cssClasses?: Partial<{ + highlighted: string; + }>; +}; + +const suit = component('ReverseSnippet'); + +export default function reverseSnippet({ + attribute, + highlightedTagName = 'mark', + hit, + cssClasses = {}, +}: ReverseSnippetOptions): string { + const { value: attributeValue = '' } = + getPropertyByPath(hit._snippetResult, attribute) || {}; + + // cx is not used, since it would be bundled as a dependency for Vue & Angular + const className = + suit({ + descendantName: 'highlighted', + }) + (cssClasses.highlighted ? ` ${cssClasses.highlighted}` : ''); + + const reverseHighlightedValue = concatHighlightedParts( + reverseHighlightedParts(getHighlightedParts(attributeValue)) + ); + + return reverseHighlightedValue + .replace( + new RegExp(TAG_REPLACEMENT.highlightPreTag, 'g'), + `<${highlightedTagName} class="${className}">` + ) + .replace( + new RegExp(TAG_REPLACEMENT.highlightPostTag, 'g'), + `` + ); +} diff --git a/src/index.es.ts b/src/index.es.ts index 74e8f2ebb6..a526fcab2c 100644 --- a/src/index.es.ts +++ b/src/index.es.ts @@ -3,7 +3,9 @@ import InstantSearch from './lib/InstantSearch'; import version from './lib/version'; import { snippet, + reverseSnippet, highlight, + reverseHighlight, insights, getInsightsAnonymousUserToken, } from './helpers'; @@ -14,7 +16,9 @@ const instantsearch = (options: InstantSearchOptions): InstantSearch => instantsearch.version = version; instantsearch.snippet = snippet; +instantsearch.reverseSnippet = reverseSnippet; instantsearch.highlight = highlight; +instantsearch.reverseHighlight = reverseHighlight; instantsearch.insights = insights; instantsearch.getInsightsAnonymousUserToken = getInsightsAnonymousUserToken; instantsearch.createInfiniteHitsSessionStorageCache = createInfiniteHitsSessionStorageCache; diff --git a/src/lib/createHelpers.ts b/src/lib/createHelpers.ts index bb2330250b..01e8e500b7 100644 --- a/src/lib/createHelpers.ts +++ b/src/lib/createHelpers.ts @@ -1,8 +1,12 @@ import { highlight, + reverseHighlight, snippet, + reverseSnippet, HighlightOptions, + ReverseHighlightOptions, SnippetOptions, + ReverseSnippetOptions, insights, } from '../helpers'; import { Hit, InsightsClientMethod, InsightsClientPayload } from '../types'; @@ -12,7 +16,9 @@ type HoganRenderer = (value: any) => string; type HoganHelpers = { formatNumber: (value: number, render: HoganRenderer) => string; highlight: (options: string, render: HoganRenderer) => string; + reverseHighlight: (options: string, render: HoganRenderer) => string; snippet: (options: string, render: HoganRenderer) => string; + reverseSnippet: (options: string, render: HoganRenderer) => string; insights: (options: string, render: HoganRenderer) => string; }; @@ -43,6 +49,25 @@ The highlight helper expects a JSON object of the format: { "attribute": "name", "highlightedTagName": "mark" }`); } }, + reverseHighlight(options, render) { + try { + const reverseHighlightOptions: Omit< + ReverseHighlightOptions, + 'hit' + > = JSON.parse(options); + + return render( + reverseHighlight({ + ...reverseHighlightOptions, + hit: this, + }) + ); + } catch (error) { + throw new Error(` + The reverseHighlight helper expects a JSON object of the format: + { "attribute": "name", "highlightedTagName": "mark" }`); + } + }, snippet(options, render) { try { const snippetOptions: Omit = JSON.parse(options); @@ -59,6 +84,25 @@ The snippet helper expects a JSON object of the format: { "attribute": "name", "highlightedTagName": "mark" }`); } }, + reverseSnippet(options, render) { + try { + const reverseSnippetOptions: Omit< + ReverseSnippetOptions, + 'hit' + > = JSON.parse(options); + + return render( + reverseSnippet({ + ...reverseSnippetOptions, + hit: this, + }) + ); + } catch (error) { + throw new Error(` + The reverseSnippet helper expects a JSON object of the format: + { "attribute": "name", "highlightedTagName": "mark" }`); + } + }, insights(this: Hit, options, render) { try { type InsightsHelperOptions = { diff --git a/src/lib/main.ts b/src/lib/main.ts index f5555209c6..d0b4ef7eae 100644 --- a/src/lib/main.ts +++ b/src/lib/main.ts @@ -38,7 +38,9 @@ instantsearch.widgets = widgets; instantsearch.version = version; instantsearch.createInfiniteHitsSessionStorageCache = createInfiniteHitsSessionStorageCache; instantsearch.highlight = helpers.highlight; +instantsearch.reverseHighlight = helpers.reverseHighlight; instantsearch.snippet = helpers.snippet; +instantsearch.reverseSnippet = helpers.reverseSnippet; instantsearch.insights = helpers.insights; instantsearch.middlewares = middlewares; diff --git a/src/lib/utils/__tests__/concatHighlightedParts-test.ts b/src/lib/utils/__tests__/concatHighlightedParts-test.ts new file mode 100644 index 0000000000..503dda5c10 --- /dev/null +++ b/src/lib/utils/__tests__/concatHighlightedParts-test.ts @@ -0,0 +1,34 @@ +import concatHighlightedParts from '../concatHighlightedParts'; + +describe('concatHighlightedParts', () => { + test('returns a concatenated string from HighlightedParts with a single match', () => { + expect( + concatHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]) + ).toMatchInlineSnapshot( + `"Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black"` + ); + }); + + test('returns a concatenated string from HighlightedParts with multiple matches', () => { + expect( + concatHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, + ]) + ).toMatchInlineSnapshot( + `"Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black"` + ); + }); +}); diff --git a/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts b/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts new file mode 100644 index 0000000000..7eff585be5 --- /dev/null +++ b/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts @@ -0,0 +1,31 @@ +import { HighlightedParts } from '../../../types'; +import getHighlightFromSiblings from '../getHighlightFromSiblings'; + +const oneMatch: HighlightedParts[] = [ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, +]; + +const multipleMatches: HighlightedParts[] = [ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, +]; + +describe('getHighlightFromSiblings', () => { + test('returns the isHighlighted value with a missing sibling', () => { + expect(getHighlightFromSiblings(oneMatch, 0)).toEqual(true); + }); + + test('returns the isHighlighted value with both siblings', () => { + expect(getHighlightFromSiblings(multipleMatches, 1)).toEqual(true); + }); +}); diff --git a/src/lib/utils/__tests__/getHighlightedParts-test.ts b/src/lib/utils/__tests__/getHighlightedParts-test.ts new file mode 100644 index 0000000000..4790a6ce8d --- /dev/null +++ b/src/lib/utils/__tests__/getHighlightedParts-test.ts @@ -0,0 +1,39 @@ +import getHighlightedParts from '../getHighlightedParts'; + +describe('getHighlightedParts', () => { + test('returns an HighlightParts array of object from a string with a single match', () => { + expect( + getHighlightedParts( + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black' + ) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]); + }); + + test('returns an HighlightedParts array of object from a string with multiple matches', () => { + expect( + getHighlightedParts( + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black' + ) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - ', + }, + { + isHighlighted: true, + value: 'Tablet', + }, + { + isHighlighted: false, + value: ' - 16GB - Wi-Fi - Black', + }, + ]); + }); +}); diff --git a/src/lib/utils/__tests__/reverseHighlightedParts-test.ts b/src/lib/utils/__tests__/reverseHighlightedParts-test.ts new file mode 100644 index 0000000000..a1979fc0b6 --- /dev/null +++ b/src/lib/utils/__tests__/reverseHighlightedParts-test.ts @@ -0,0 +1,45 @@ +import reverseHighlightedParts from '../reverseHighlightedParts'; + +describe('reverseHighlightedParts', () => { + test('returns reversed HighlightedParts with a single match', () => { + expect( + reverseHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + }, + ]); + }); + + test('with reversed HighlightedParts with multiple matches', () => { + expect( + reverseHighlightedParts([ + { isHighlighted: false, value: 'Amazon' }, + { + isHighlighted: true, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, + ]) + ).toEqual([ + { isHighlighted: true, value: 'Amazon' }, + { + isHighlighted: false, + value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', + }, + { isHighlighted: true, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: true, value: 'Black' }, + ]); + }); +}); diff --git a/src/lib/utils/__tests__/unescape-test.ts b/src/lib/utils/__tests__/unescape-test.ts new file mode 100644 index 0000000000..eb2e2a519f --- /dev/null +++ b/src/lib/utils/__tests__/unescape-test.ts @@ -0,0 +1,25 @@ +import unescape from '../unescape'; + +describe('unescape', () => { + test('should unescape values', () => { + expect(unescape('&<>"'/')).toEqual('&<>"\'/'); + }); + + test('should unescape values in a sentence', () => { + expect(unescape('fred, barney, & pebbles')).toEqual( + 'fred, barney, & pebbles' + ); + }); + + test('should handle strings with nothing to unescape', () => { + expect(unescape('abc')).toEqual('abc'); + }); + + test('should not unescape the "`" character', () => { + expect(unescape('`')).toEqual('`'); + }); + + test('should not unescape the "/" character', () => { + expect(unescape('/')).toEqual('/'); + }); +}); diff --git a/src/lib/utils/concatHighlightedParts.ts b/src/lib/utils/concatHighlightedParts.ts new file mode 100644 index 0000000000..567c1ae99b --- /dev/null +++ b/src/lib/utils/concatHighlightedParts.ts @@ -0,0 +1,14 @@ +import { HighlightedParts } from '../../types'; +import { TAG_REPLACEMENT } from '../escape-highlight'; + +export default function concatHighlightedParts(parts: HighlightedParts[]) { + const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; + + return parts + .map(part => + part.isHighlighted + ? highlightPreTag + part.value + highlightPostTag + : part.value + ) + .join(''); +} diff --git a/src/lib/utils/getHighlightFromSiblings.ts b/src/lib/utils/getHighlightFromSiblings.ts new file mode 100644 index 0000000000..ddb64103f2 --- /dev/null +++ b/src/lib/utils/getHighlightFromSiblings.ts @@ -0,0 +1,22 @@ +import unescape from './unescape'; +import { HighlightedParts } from '../../types'; + +const hasAlphanumeric = new RegExp(/\w/i); + +export default function getHighlightFromSiblings( + parts: HighlightedParts[], + i: number +) { + const current = parts[i]; + const isNextHighlighted = parts[i + 1]?.isHighlighted || true; + const isPreviousHighlighted = parts[i - 1]?.isHighlighted || true; + + if ( + !hasAlphanumeric.test(unescape(current.value)) && + isPreviousHighlighted === isNextHighlighted + ) { + return isPreviousHighlighted; + } + + return current.isHighlighted; +} diff --git a/src/lib/utils/getHighlightedParts.ts b/src/lib/utils/getHighlightedParts.ts new file mode 100644 index 0000000000..9ed34fbee9 --- /dev/null +++ b/src/lib/utils/getHighlightedParts.ts @@ -0,0 +1,29 @@ +import { TAG_REPLACEMENT } from '../../lib/escape-highlight'; + +export default function getHighlightedParts(highlightedValue: string) { + const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT; + + const splitByPreTag = highlightedValue.split(highlightPreTag); + const firstValue = splitByPreTag.shift(); + const elements = !firstValue + ? [] + : [{ value: firstValue, isHighlighted: false }]; + + splitByPreTag.forEach(split => { + const splitByPostTag = split.split(highlightPostTag); + + elements.push({ + value: splitByPostTag[0], + isHighlighted: true, + }); + + if (splitByPostTag[1] !== '') { + elements.push({ + value: splitByPostTag[1], + isHighlighted: false, + }); + } + }); + + return elements; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1751cb6e90..5dae975332 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -20,6 +20,11 @@ export { default as uniq } from './uniq'; export { default as range } from './range'; export { default as isEqual } from './isEqual'; export { default as escape } from './escape'; +export { default as unescape } from './unescape'; +export { default as concatHighlightedParts } from './concatHighlightedParts'; +export { default as getHighlightedParts } from './getHighlightedParts'; +export { default as getHighlightFromSiblings } from './getHighlightFromSiblings'; +export { default as reverseHighlightedParts } from './reverseHighlightedParts'; export { default as find } from './find'; export { default as findIndex } from './findIndex'; export { default as mergeSearchParameters } from './mergeSearchParameters'; diff --git a/src/lib/utils/reverseHighlightedParts.ts b/src/lib/utils/reverseHighlightedParts.ts new file mode 100644 index 0000000000..e1fe5e5492 --- /dev/null +++ b/src/lib/utils/reverseHighlightedParts.ts @@ -0,0 +1,13 @@ +import { HighlightedParts } from '../../types'; +import getHighlightFromSiblings from './getHighlightFromSiblings'; + +export default function reverseHighlightedParts(parts: HighlightedParts[]) { + if (!parts.some(part => part.isHighlighted)) { + return parts.map(part => ({ ...part, isHighlighted: false })); + } + + return parts.map((part, i) => ({ + ...part, + isHighlighted: !getHighlightFromSiblings(parts, i), + })); +} diff --git a/src/lib/utils/unescape.ts b/src/lib/utils/unescape.ts new file mode 100644 index 0000000000..2054d47a90 --- /dev/null +++ b/src/lib/utils/unescape.ts @@ -0,0 +1,27 @@ +/** + * This implementation is taken from Lodash implementation. + * See: https://github.com/lodash/lodash/blob/4.17.11-npm/unescape.js + */ + +// Used to map HTML entities to characters. +const htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", +}; + +// Used to match HTML entities and HTML characters. +const regexEscapedHtml = /&(amp|quot|lt|gt|#39);/g; +const regexHasEscapedHtml = RegExp(regexEscapedHtml.source); + +/** + * Converts the HTML entities "&", "<", ">", '"', and "'" in `string` to their + * characters. + */ +export default function unescape(value: string): string { + return value && regexHasEscapedHtml.test(value) + ? value.replace(regexEscapedHtml, character => htmlEscapes[character]) + : value; +} diff --git a/src/types/index.ts b/src/types/index.ts index 21b8ffbba4..c3e15b5d73 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './widget'; export * from './insights'; export * from './algoliasearch'; export * from './middleware'; +export * from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000000..07615a81f0 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,4 @@ +export type HighlightedParts = { + value: string; + isHighlighted: boolean; +}; diff --git a/stories/hits.stories.js b/stories/hits.stories.js index 95a1627ab6..3e4517a967 100644 --- a/stories/hits.stories.js +++ b/stories/hits.stories.js @@ -58,6 +58,38 @@ storiesOf('Results/Hits', module) ]); }) ) + .add( + 'with reverseHighlight function', + withHits(({ search, container, instantsearch }) => { + search.addWidgets([ + hits({ + container, + templates: { + item(hit) { + return instantsearch.reverseHighlight({ + attribute: 'name', + hit, + }); + }, + }, + }), + ]); + }) + ) + .add( + 'with reverseHighlight helper', + withHits(({ search, container }) => { + search.addWidgets([ + hits({ + container, + templates: { + item: + '{{#helpers.reverseHighlight}}{ "attribute": "name" }{{/helpers.reverseHighlight}}', + }, + }), + ]); + }) + ) .add( 'with snippet function', withHits(({ search, container, instantsearch }) => { @@ -109,6 +141,57 @@ storiesOf('Results/Hits', module) ]); }) ) + .add( + 'with reverseSnippet function', + withHits(({ search, container, instantsearch }) => { + search.addWidgets([ + configure({ + attributesToSnippet: ['name', 'description'], + }), + ]); + + search.addWidgets([ + hits({ + container, + templates: { + item(hit) { + return ` +

${instantsearch.reverseSnippet({ + attribute: 'name', + hit, + })}

+

${instantsearch.reverseSnippet({ + attribute: 'description', + hit, + })}

+ `; + }, + }, + }), + ]); + }) + ) + .add( + 'with reverseSnippet helper', + withHits(({ search, container }) => { + search.addWidgets([ + configure({ + attributesToSnippet: ['name', 'description'], + }), + ]); + + search.addWidgets([ + hits({ + container, + templates: { + item: ` +

{{#helpers.reverseSnippet}}{ "attribute": "name", "highlightedTagName": "mark" }{{/helpers.reverseSnippet}}

+

{{#helpers.reverseSnippet}}{ "attribute": "description", "highlightedTagName": "mark" }{{/helpers.reverseSnippet}}

`, + }, + }), + ]); + }) + ) .add( 'with insights function', withHits(