mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-29 13:39:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d55764e136 | |||
| 901f0268cc |
@@ -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(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user