Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
concatHighlightedParts,
getHighlightedParts,
} from '../highlight-parts';

describe('highlight-parts', () => {
describe('round trip', () => {
test.each([
'<mark>Amazon</mark> - Fire HD8 - 8&quot; - Tablet - 16GB - Wi-Fi - Black',
'<mark>Amazon</mark> - Fire HD8 - 8&quot; - <mark>Tablet</mark> - 16GB - Wi-Fi - Black',
'<mark>Amazon</mark>',
'no highlight here',
])('concat(get(%p)) returns the original string', (value) => {
expect(concatHighlightedParts(getHighlightedParts(value))).toEqual(value);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ describe('reverseHighlightedParts', () => {
value: ' - Fire HD8 - 8&quot; - 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' },
]);
});
Expand Down
18 changes: 3 additions & 15 deletions packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
33 changes: 3 additions & 30 deletions packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts
Original file line number Diff line number Diff line change
@@ -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';
92 changes: 92 additions & 0 deletions packages/instantsearch.js/src/lib/utils/highlight-parts.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
17 changes: 3 additions & 14 deletions packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading