= 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(