Compare commits

...

2 Commits

Author SHA1 Message Date
Claude d55764e136 changeset 2026-07-20 00:15:58 +00:00
Claude 901f0268cc Fix overlapping search highlights for adjacent multi-word matches
Multi-word search queries highlight each word independently, so a phrase
like "maria db" against "MariaDB" produced two touching highlight spans
whose negative margins and rounded corners overlapped into a doubled pill.
Coalesce contiguous matched parts into a single highlight span.

The pure matching logic is extracted to HighlightQuery.utils.ts so it can
be unit-tested without pulling in the React JSX runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4jiy83BeKXZjEH4Jqwn3Z
2026-07-20 00:15:58 +00:00
4 changed files with 105 additions and 42 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix search results rendering adjacent matched words from a multi-word query (e.g. "maria db" matching "MariaDB") as two overlapping highlight pills instead of one.
@@ -1,4 +1,5 @@
import { type ClassValue, tcls } from '@/lib/tailwind';
import { matchString } from './HighlightQuery.utils';
/**
* Match a string against a query and render the matching text in bold.
@@ -44,45 +45,3 @@ export function HighlightQuery(props: {
</span>
);
}
interface TextMatch {
text: string;
match?: string;
}
function matchString(text: string, query: string): TextMatch[] {
const words = splitQuery(query);
const initialParts = [{ text }];
return words.reduce((parts, word) => matchWordInParts(parts, word), initialParts);
}
function matchWordInParts(parts: TextMatch[], word: string): TextMatch[] {
return parts.reduce((result, part) => {
if (part.match) {
result.push(part);
return result;
}
const { text } = part;
const index = text.toLowerCase().indexOf(word);
if (index >= 0) {
const before = text.slice(0, index);
const inner = text.slice(index, index + word.length);
const after = text.slice(index + word.length);
if (before.length > 0) result.push({ text: before });
if (inner.length > 0) result.push({ text: inner, match: word });
if (after.length > 0) result.push({ text: after });
return result;
}
result.push({ text });
return result;
}, [] as TextMatch[]);
}
function splitQuery(text: string): string[] {
return text.toLowerCase().split(' ');
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { matchString } from './HighlightQuery.utils';
describe('matchString', () => {
it('returns the whole text as a single non-match when nothing matches', () => {
expect(matchString('MariaDB Cloud', 'postgres')).toEqual([{ text: 'MariaDB Cloud' }]);
});
it('highlights a single matching word', () => {
expect(matchString('MariaDB Cloud', 'cloud')).toEqual([
{ text: 'MariaDB ' },
{ text: 'Cloud', match: 'cloud' },
]);
});
it('coalesces adjacent word matches into one highlight span', () => {
// "maria db" matches "MariaDB" as two touching tokens; they must render
// as a single highlight, not two overlapping pills.
expect(matchString('MariaDB Cloud', 'maria db cloud')).toEqual([
{ text: 'MariaDB', match: 'mariadb' },
{ text: ' ' },
{ text: 'Cloud', match: 'cloud' },
]);
});
it('keeps non-adjacent matches as separate spans', () => {
expect(matchString('MariaDB is a cloud database', 'maria cloud')).toEqual([
{ text: 'Maria', match: 'maria' },
{ text: 'DB is a ' },
{ text: 'cloud', match: 'cloud' },
{ text: ' database' },
]);
});
});
@@ -0,0 +1,65 @@
export interface TextMatch {
text: string;
match?: string;
}
/**
* Split `text` into consecutive parts, flagging the ones that match `query`.
*/
export function matchString(text: string, query: string): TextMatch[] {
const words = splitQuery(query);
const initialParts = [{ text }];
const parts = words.reduce((parts, word) => matchWordInParts(parts, word), initialParts);
return coalesceAdjacentMatches(parts);
}
function matchWordInParts(parts: TextMatch[], word: string): TextMatch[] {
return parts.reduce((result, part) => {
if (part.match) {
result.push(part);
return result;
}
const { text } = part;
const index = text.toLowerCase().indexOf(word);
if (index >= 0) {
const before = text.slice(0, index);
const inner = text.slice(index, index + word.length);
const after = text.slice(index + word.length);
if (before.length > 0) result.push({ text: before });
if (inner.length > 0) result.push({ text: inner, match: word });
if (after.length > 0) result.push({ text: after });
return result;
}
result.push({ text });
return result;
}, [] as TextMatch[]);
}
/**
* Multi-word queries match each word independently, so a phrase like "maria db"
* against "MariaDB" produces two touching match parts. Rendered as separate
* spans their negative margins and rounded corners overlap into a doubled pill,
* so contiguous matches are merged into one highlight span.
*/
function coalesceAdjacentMatches(parts: TextMatch[]): TextMatch[] {
return parts.reduce((result, part) => {
const previous = result[result.length - 1];
if (part.match && previous?.match) {
previous.text += part.text;
previous.match += part.match;
return result;
}
result.push({ ...part });
return result;
}, [] as TextMatch[]);
}
function splitQuery(text: string): string[] {
return text.toLowerCase().split(' ');
}