diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts index 7cb35c0243..bb9b9fc8fb 100644 --- a/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts +++ b/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts @@ -29,4 +29,33 @@ describe('getHighlightFromSiblings', () => { test('returns the isHighlighted value with both siblings', () => { expect(getHighlightFromSiblings(multipleMatches, 1)).toEqual(true); }); + + test('adopts the shared state of present non-highlighted siblings', () => { + // A non-alphanumeric separator between two non-highlighted siblings adopts + // their `false` state. This used to collapse to `true` because of a + // `|| true` default that treated any `false` neighbor as highlighted. + const parts: HighlightedParts[] = [ + { isHighlighted: false, value: 'Fi' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'Black' }, + ]; + + expect(getHighlightFromSiblings(parts, 1)).toEqual(false); + }); + + test('defaults a missing sibling to highlighted', () => { + const parts: HighlightedParts[] = [{ isHighlighted: false, value: ' - ' }]; + + expect(getHighlightFromSiblings(parts, 0)).toEqual(true); + }); + + test('keeps its own state when siblings disagree', () => { + const parts: HighlightedParts[] = [ + { isHighlighted: true, value: 'a' }, + { isHighlighted: false, value: ' - ' }, + { isHighlighted: false, value: 'b' }, + ]; + + expect(getHighlightFromSiblings(parts, 1)).toEqual(false); + }); }); diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts new file mode 100644 index 0000000000..d57f430fd6 --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts @@ -0,0 +1,17 @@ +import { + concatHighlightedParts, + getHighlightedParts, +} from '../highlight-parts'; + +describe('highlight-parts', () => { + describe('round trip', () => { + test.each([ + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black', + 'Amazon', + 'no highlight here', + ])('concat(get(%p)) returns the original string', (value) => { + expect(concatHighlightedParts(getHighlightedParts(value))).toEqual(value); + }); + }); +}); diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts index b4943754b4..85bb677111 100644 --- a/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts +++ b/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts @@ -38,7 +38,12 @@ describe('reverseHighlightedParts', () => { value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-', }, { isHighlighted: true, value: 'Fi' }, - { isHighlighted: false, value: ' - ' }, + // The ` - ` separator sits between two siblings that share the same + // (reversed) highlight state, so it adopts that state to keep the + // highlight contiguous. This previously stayed `false` because of a + // `|| true` sibling default that collapsed any `false` neighbor to + // `true`; see `getHighlightFromSiblings`. + { isHighlighted: true, value: ' - ' }, { isHighlighted: true, value: 'Black' }, ]); }); diff --git a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts index fd6d97fe6c..0b4cfd21ce 100644 --- a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts @@ -1,15 +1,3 @@ -import { TAG_REPLACEMENT } from './escape-highlight'; - -import type { HighlightedParts } from '../../types'; - -export function concatHighlightedParts(parts: HighlightedParts[]) { - const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; - - return parts - .map((part) => - part.isHighlighted - ? highlightPreTag + part.value + highlightPostTag - : part.value - ) - .join(''); -} +// Backward-compatible re-export. The implementation now lives in the unified +// `highlight-parts` module alongside its inverse and reverse operations. +export { concatHighlightedParts } from './highlight-parts'; diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts index 03aba197eb..17870b2e5b 100644 --- a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts +++ b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts @@ -1,20 +1,3 @@ -import { unescape } from './escape-html'; - -import type { HighlightedParts } from '../../types'; - -const hasAlphanumeric = new RegExp(/\w/i); - -export 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; -} +// Backward-compatible re-export. The implementation now lives in the unified +// `highlight-parts` module alongside its inverse and reverse operations. +export { getHighlightFromSiblings } from './highlight-parts'; diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts index c95f2269fb..39a71cfced 100644 --- a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts @@ -1,30 +1,3 @@ -import { TAG_REPLACEMENT } from './escape-highlight'; - -export function getHighlightedParts(highlightedValue: string) { - // @MAJOR: this should use TAG_PLACEHOLDER - 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; -} +// Backward-compatible re-export. The implementation now lives in the unified +// `highlight-parts` module alongside its inverse and reverse operations. +export { getHighlightedParts } from './highlight-parts'; diff --git a/packages/instantsearch.js/src/lib/utils/highlight-parts.ts b/packages/instantsearch.js/src/lib/utils/highlight-parts.ts new file mode 100644 index 0000000000..a16efd79a5 --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/highlight-parts.ts @@ -0,0 +1,92 @@ +import { unescape } from './escape-html'; +import { TAG_REPLACEMENT } from './escape-highlight'; + +import type { HighlightedParts } from '../../types'; + +const hasAlphanumeric = new RegExp(/\w/i); + +/** + * Parses an Algolia highlighted string into an array of parts, where each part + * is flagged as highlighted or not. This is the inverse of + * `concatHighlightedParts`. + */ +export function getHighlightedParts(highlightedValue: string) { + // @MAJOR: this should use TAG_PLACEHOLDER + 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; +} + +/** + * Re-serializes highlighted parts into an Algolia highlighted string. This is + * the inverse of `getHighlightedParts`. + */ +export function concatHighlightedParts(parts: HighlightedParts[]) { + const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; + + return parts + .map((part) => + part.isHighlighted + ? highlightPreTag + part.value + highlightPostTag + : part.value + ) + .join(''); +} + +/** + * Resolves the effective highlight state of a part by looking at its siblings. + * A non-alphanumeric part (e.g. a separator) surrounded by two parts that share + * the same highlight state adopts that state, so reversed highlighting stays + * contiguous across separators. A missing sibling defaults to highlighted. + */ +export 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; +} + +/** + * Inverts the highlight state of each part, keeping separators contiguous with + * their siblings via `getHighlightFromSiblings`. + */ +export 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/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts index 559c6bd5da..3b55e00423 100644 --- a/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts @@ -1,14 +1,3 @@ -import { getHighlightFromSiblings } from './getHighlightFromSiblings'; - -import type { HighlightedParts } from '../../types'; - -export 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), - })); -} +// Backward-compatible re-export. The implementation now lives in the unified +// `highlight-parts` module alongside its inverse and reverse operations. +export { reverseHighlightedParts } from './highlight-parts';