Skip to content
Open
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
25 changes: 18 additions & 7 deletions src/runtime/utils/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ function truncateHTMLFromStart(html: string, maxLength: number) {
let totalLength = 0
let insideTag = false

// Iterate through the HTML string in reverse order
for (let i = html.length - 1; i >= 0; i--) {
if (html[i] === '>') {
// Iterate through the HTML string in reverse order, one code point at a time.
// Indexing by UTF-16 code unit would slice an astral character (emoji, most
// CJK extension blocks) in half when the truncation boundary lands between
// its surrogates, emitting an unpaired surrogate that renders as `οΏ½`.
// `<` and `>` are always single code units, so tag tracking is unaffected.
const chars = Array.from(html)

for (let i = chars.length - 1; i >= 0; i--) {
const char = chars[i]!

if (char === '>') {
insideTag = true
} else if (html[i] === '<') {
} else if (char === '<') {
insideTag = false
truncated = html[i] + truncated
truncated = char + truncated
continue
}

Expand All @@ -33,7 +41,7 @@ function truncateHTMLFromStart(html: string, maxLength: number) {
}

if (totalLength <= maxLength) {
truncated = html[i] + truncated
truncated = char + truncated
} else {
// If we've reached the max length, we break out of the loop
// to prevent further processing of the string
Expand Down Expand Up @@ -94,7 +102,10 @@ export function highlight<T>(item: T & { matches?: FuseResult<T>['matches'] }, s

const markIndex = content.indexOf('<mark>')
if (markIndex !== -1) {
content = truncateHTMLFromStart(content, content.length - markIndex)
// Measure the budget in code points too, so it stays in the same units as
// the counter inside `truncateHTMLFromStart`. Identical to `.length` for
// BMP-only content.
content = truncateHTMLFromStart(content, Array.from(content.slice(markIndex)).length)
}

return content
Expand Down
37 changes: 37 additions & 0 deletions test/utils/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,41 @@ describe('highlight', () => {
expect(highlight({ label: 'foo', matches: [] }, 'foo', 'label')).toBeUndefined()
expect(highlight({ label: 'foo' }, 'foo', 'label')).toBeUndefined()
})

describe('truncation from the start', () => {
// Matches a high surrogate not followed by a low one, or a low surrogate not
// preceded by a high one β€” i.e. half of an astral character.
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/

function highlightAfterFiller(filler: string, count: number) {
const value = filler.repeat(count) + 'match'
const index = value.indexOf('match')

return highlight({ label: value, matches: [{ key: 'label', value, indices: [[index, index + 4]] }] }, 'match', 'label')
}

it('never splits an astral character, at any truncation boundary', () => {
// Truncating by UTF-16 code unit used to slice the pair in half whenever the
// boundary landed between its surrogates β€” from 7 emoji onward, and every
// length after that.
const split = Array.from({ length: 40 }, (_, i) => i + 1)
.filter(count => LONE_SURROGATE.test(highlightAfterFiller('\u{1F600}', count) ?? ''))

expect(split).toEqual([])
})

it('keeps emoji before the match intact', () => {
const result = highlightAfterFiller('\u{1F600}', 20)

expect(result).toContain('<mark>match</mark>')
expect(result).not.toContain('οΏ½')
expect(result?.replace(/^\.\.\./, '')).not.toMatch(LONE_SURROGATE)
})

it('still truncates a long prefix down to an ellipsis', () => {
const result = highlightAfterFiller('a', 50)

expect(result).toMatch(/^\.\.\.a+<mark>match<\/mark>$/)
})
})
})
Loading