Skip to content

Commit 11abe24

Browse files
feat(oc-docs): typo-tolerant endpoint search via Fuse.js (#131)
* feat(oc-docs): typo-tolerant endpoint search via Fuse.js Replace the hand-rolled subsequence matcher with a Fuse.js (Bitap) index so search tolerates typos (bikling matches billing) and matches whole words contiguously instead of stitching characters across tokens (the "b" of baseUrl no longer lit up when searching "billing"). - Narrow search fields to name, URL and folder chain (drop params and description); Fuse key weights name > url > folder. - Tune threshold to 0.3 to keep real typos while dropping shared-prefix false positives (cursor no longer matches currencies). - Bold the exact matched ranges Fuse reports in each field; the result name is no longer bold by default. - Remove the now-unused fuzzyMatch utility and add fuse.js@^7.5.0. * feat(oc-docs): tolerate adjacent-letter transpositions in search Search also runs adjacent-swap variants of the query so a swap typo on a short word (hotles matches hotels) resolves near-exact, without loosening the Fuse threshold. Records introduced only by a variant are gated to near-exact scores, so the expansion adds recall without reopening prefix-bleed false positives. * test(oc-docs): update search empty-state copy assertion to 'folder' * chore(oc-docs): add fuse.js to the lockfile * refactor(oc-docs): address search review (e2e, types, 1-char UX, guards) - Add e2e covering the new behaviours: a swap-typo correcting to its request, folder-chain text search (not just the filter dropdown), and a single-char query holding the initial prompt. - Treat a query shorter than Fuse's minMatchCharLength as "not typing yet" so the first keystroke keeps the initial prompt instead of a no-results flash. - Skip transposition expansion for long queries (likely pasted, not mistyped). - Type collectMatches ranges as [number, number] tuples, not number[][]. - Drop em-dashes from the search comments and test titles. * style(oc-docs): drop redundant comment in search result styles * test(oc-docs): assert search highlight by bold tag, not class name
1 parent 407ba7c commit 11abe24

11 files changed

Lines changed: 417 additions & 213 deletions

File tree

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/oc-docs/e2e/tests/search/search.spec.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test.describe('Search palette', () => {
6363
await search.field.click();
6464

6565
await expect(search.panel).toContainText('Search the collection');
66-
await expect(search.panel).toContainText('Find any request by name, endpoint, or description.');
66+
await expect(search.panel).toContainText('Find any request by name, endpoint, or folder.');
6767
});
6868

6969
test('typing fuzzy-matches over request names', async ({ page, search }) => {
@@ -76,6 +76,36 @@ test.describe('Search palette', () => {
7676
await expect(search.panel).toContainText('Login');
7777
});
7878

79+
test('corrects a typo (adjacent letter swap) to the intended request', async ({ page, search }) => {
80+
await page.setViewportSize(DESKTOP);
81+
await page.goto(FIXTURE);
82+
await search.field.click();
83+
await search.field.fill('lgoin'); // "login" with the o/g swapped
84+
85+
await expect(search.results.first()).toBeVisible();
86+
await expect(search.panel).toContainText('Login');
87+
});
88+
89+
test('matches on the folder chain typed as text (not only the filter dropdown)', async ({ page, search }) => {
90+
await page.setViewportSize(DESKTOP);
91+
await page.goto(FIXTURE);
92+
await search.field.click();
93+
await search.field.fill('authentication'); // the folder Login lives under
94+
95+
await expect(search.results.first()).toBeVisible();
96+
await expect(search.panel).toContainText('Login');
97+
});
98+
99+
test('a single character keeps the initial prompt (below the match threshold)', async ({ page, search }) => {
100+
await page.setViewportSize(DESKTOP);
101+
await page.goto(FIXTURE);
102+
await search.field.click();
103+
await search.field.fill('l');
104+
105+
await expect(search.panel).toContainText('Search the collection');
106+
await expect(search.resultsList).toHaveCount(0);
107+
});
108+
79109
test('selecting a result navigates and closes the panel', async ({ page, search }) => {
80110
await page.setViewportSize(DESKTOP);
81111
await page.goto(FIXTURE);

packages/oc-docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@types/prismjs": "^1.26.5",
7373
"ajv": "^8.12.0",
7474
"codemirror": "^6.0.2",
75+
"fuse.js": "^7.5.0",
7576
"js-yaml": "^4.1.0",
7677
"jshint": "^2.13.6",
7778
"markdown-it": "^14.1.0",

packages/oc-docs/src/components/Search/SearchBar/SearchBar.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
buildSearchRecords,
66
collectTopLevelFolders,
77
collectMethods,
8-
searchRecords,
8+
createSearchIndex,
9+
searchHits,
10+
type SearchHit,
911
type SearchRecord,
1012
} from '../searchIndex';
1113
import { SearchIcon, CloseIcon } from '../../../assets/icons';
@@ -16,6 +18,11 @@ import { StyledWrapper } from './StyledWrapper';
1618

1719
const RESULTS_ID = 'search-listbox';
1820

21+
// A single character can't clear Fuse's `minMatchCharLength`, so treat a query
22+
// shorter than this as "not typing yet": keep the initial prompt rather than
23+
// flashing "no matching requests" after the first keystroke.
24+
const MIN_QUERY_LENGTH = 2;
25+
1926
interface SearchBarProps {
2027
/** Open state, controlled by the shell so it is shared with the Topbar's
2128
* below-desktop search row (one source of truth: icon, row and panel agree). */
@@ -33,9 +40,9 @@ interface SearchBarProps {
3340
}
3441

3542
/**
36-
* Header-anchored endpoint search. Fuzzy text search over name/url/params/
37-
* description plus palette-local method + folder filters. Results render in the
38-
* palette itself and selecting one navigates via the slug route.
43+
* Header-anchored endpoint search. Typo-tolerant (Fuse/Bitap) search over name,
44+
* URL and folder chain plus palette-local method + folder filters. Results
45+
* render in the palette itself and selecting one navigates via the slug route.
3946
*
4047
* Expands in place (a combobox whose listbox drops directly below the field)
4148
* rather than opening a centered modal. Open state is controlled so the Topbar
@@ -46,6 +53,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
4653
const model = useNavModel();
4754

4855
const records = useMemo(() => buildSearchRecords(model.ordered), [model]);
56+
const fuse = useMemo(() => createSearchIndex(records), [records]);
4957
const folders = useMemo(() => collectTopLevelFolders(model.ordered), [model]);
5058
// One chip per method present in the collection (canonical order).
5159
const methodOptions = useMemo(() => collectMethods(model.ordered), [model]);
@@ -63,17 +71,21 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
6371

6472
const optionId = (i: number) => `${RESULTS_ID}-opt-${i}`;
6573

66-
const hasQuery = query.trim().length > 0;
74+
const hasQuery = query.trim().length >= MIN_QUERY_LENGTH;
6775
const hasFilter = methods.size > 0 || folder !== null;
6876

69-
const results = useMemo(() => {
70-
const base = hasQuery ? searchRecords(query, records) : hasFilter ? records : [];
77+
const results = useMemo<SearchHit[]>(() => {
78+
const base: SearchHit[] = hasQuery
79+
? searchHits(fuse, query)
80+
: hasFilter
81+
? records.map((record) => ({ record, matches: {} }))
82+
: [];
7183
return base.filter(
72-
(r) =>
84+
({ record: r }) =>
7385
(methods.size === 0 || (r.method ? methods.has(r.method.toUpperCase()) : false)) &&
7486
(folder === null || r.ancestorSlugs.includes(folder)),
7587
);
76-
}, [query, methods, folder, records, hasQuery, hasFilter]);
88+
}, [query, methods, folder, records, fuse, hasQuery, hasFilter]);
7789

7890
useEffect(() => setActiveIdx(-1), [results]);
7991

@@ -165,10 +177,10 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
165177
}
166178
if (e.key === 'Enter') {
167179
// Enter selects the highlighted row, or the first result if none navigated.
168-
const rec = activeIdx >= 0 ? results[activeIdx] : results[0];
169-
if (rec) {
180+
const hit = activeIdx >= 0 ? results[activeIdx] : results[0];
181+
if (hit) {
170182
e.preventDefault();
171-
handleSelect(rec);
183+
handleSelect(hit.record);
172184
}
173185
}
174186
};
@@ -230,7 +242,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
230242
<SearchIcon />
231243
</span>
232244
<p className="search-empty-title">Search the collection</p>
233-
<p className="search-empty-text">Find any request by name, endpoint, or description.</p>
245+
<p className="search-empty-text">Find any request by name, endpoint, or folder.</p>
234246
</div>
235247
) : results.length === 0 ? (
236248
<div className="search-empty">
@@ -257,9 +269,9 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
257269
aria-label="Search results"
258270
data-testid="search-results"
259271
>
260-
{results.map((rec, i) => (
272+
{results.map((hit, i) => (
261273
<li
262-
key={rec.id}
274+
key={hit.record.id}
263275
id={optionId(i)}
264276
role="option"
265277
aria-selected={i === activeIdx}
@@ -269,7 +281,12 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
269281
if (e.movementX !== 0 || e.movementY !== 0) setActiveIdx(i);
270282
}}
271283
>
272-
<SearchResultItem record={rec} active={i === activeIdx} onSelect={handleSelect} />
284+
<SearchResultItem
285+
record={hit.record}
286+
matches={hit.matches}
287+
active={i === activeIdx}
288+
onSelect={handleSelect}
289+
/>
273290
</li>
274291
))}
275292
</ul>

packages/oc-docs/src/components/Search/SearchResultItem/SearchResultItem.spec.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { describe, it, expect } from 'vitest';
44
import { SearchResultItem } from './SearchResultItem';
55
import type { SearchRecord } from '../searchIndex';
66

7+
// Match "<text>" wrapped in a bold element, regardless of its attributes, so the
8+
// assertion keys off the tag (the highlight contract) and not a styling class.
9+
const boldElement = /<b(?:\s[^>]*)?>([^<]*)<\/b>/g;
10+
const boldedText = (html: string): string[] => [...html.matchAll(boldElement)].map((m) => m[1]);
11+
712
const record: SearchRecord = {
813
id: 'u1',
914
slug: 'hotels/get-all',
@@ -12,8 +17,6 @@ const record: SearchRecord = {
1217
breadcrumb: 'Hotels / Browse & search',
1318
ancestorSlugs: ['hotels'],
1419
url: '{{baseUrl}}/api/v1/hotels',
15-
params: '',
16-
description: '',
1720
};
1821

1922
describe('SearchResultItem', () => {
@@ -29,4 +32,17 @@ describe('SearchResultItem', () => {
2932
const html = renderToStaticMarkup(<SearchResultItem record={{ ...record, url: '' }} onSelect={() => {}} />);
3033
expect(html).not.toContain('class="search-result-url"');
3134
});
35+
36+
it('wraps the matched ranges of a field in a bold element', () => {
37+
// "Hotels" sits at indices 8-13 of "Get All Hotels".
38+
const html = renderToStaticMarkup(
39+
<SearchResultItem record={record} matches={{ name: [[8, 13]] }} onSelect={() => {}} />,
40+
);
41+
expect(boldedText(html)).toContain('Hotels');
42+
});
43+
44+
it('renders plain text (no bold element) when a field has no matches', () => {
45+
const html = renderToStaticMarkup(<SearchResultItem record={record} matches={{}} onSelect={() => {}} />);
46+
expect(boldedText(html)).toHaveLength(0);
47+
});
3248
});

packages/oc-docs/src/components/Search/SearchResultItem/SearchResultItem.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
import React from 'react';
22
import { getMethodColorVar } from '../../../theme/methodColors';
33
import { getShortMethod } from '../../../utils/request';
4-
import type { SearchRecord } from '../searchIndex';
4+
import type { SearchRecord, FieldMatches } from '../searchIndex';
55
import { StyledWrapper } from './StyledWrapper';
66

77
interface SearchResultItemProps {
88
record: SearchRecord;
9+
/** Matched character ranges per field, so each field bolds what matched. */
10+
matches?: FieldMatches;
911
/** Whether this row is the active (keyboard-highlighted) result. */
1012
active?: boolean;
1113
/** Navigate to this result (caller also closes the palette). */
1214
onSelect: (record: SearchRecord) => void;
1315
testId?: string;
1416
}
1517

18+
/**
19+
* Bold the `ranges` (inclusive [start, end] pairs from Fuse) inside `text`, so
20+
* each field advertises the exact substring that matched. The mark keeps the
21+
* field's own colour (weight is the only highlight signal). No ranges leaves
22+
* the text plain.
23+
*/
24+
const highlightRanges = (text: string, ranges?: Array<[number, number]>): React.ReactNode => {
25+
if (!ranges || ranges.length === 0) return text;
26+
const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
27+
const nodes: React.ReactNode[] = [];
28+
let cursor = 0;
29+
sorted.forEach(([start, end], i) => {
30+
if (start > cursor) nodes.push(text.slice(cursor, start));
31+
nodes.push(
32+
<b className="search-hl" key={i}>
33+
{text.slice(start, end + 1)}
34+
</b>
35+
);
36+
cursor = end + 1;
37+
});
38+
if (cursor < text.length) nodes.push(text.slice(cursor));
39+
return nodes;
40+
};
41+
1642
/**
1743
* One result row in the search palette. The method is rendered as a plain
1844
* colour-coded mono label (not the filled sidebar badge) while still sourcing
@@ -21,6 +47,7 @@ interface SearchResultItemProps {
2147
*/
2248
export const SearchResultItem: React.FC<SearchResultItemProps> = ({
2349
record,
50+
matches,
2451
active = false,
2552
onSelect,
2653
testId = 'search-result',
@@ -37,10 +64,12 @@ export const SearchResultItem: React.FC<SearchResultItemProps> = ({
3764
)}
3865
<span className="search-result-body">
3966
<span className="search-result-title-row">
40-
<span className="search-result-name">{record.name}</span>
41-
{record.breadcrumb && <span className="search-result-breadcrumb">{record.breadcrumb}</span>}
67+
<span className="search-result-name">{highlightRanges(record.name, matches?.name)}</span>
68+
{record.breadcrumb && (
69+
<span className="search-result-breadcrumb">{highlightRanges(record.breadcrumb, matches?.breadcrumb)}</span>
70+
)}
4271
</span>
43-
{record.url && <span className="search-result-url">{record.url}</span>}
72+
{record.url && <span className="search-result-url">{highlightRanges(record.url, matches?.url)}</span>}
4473
</span>
4574
</StyledWrapper>
4675
);

packages/oc-docs/src/components/Search/SearchResultItem/StyledWrapper.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,18 @@ export const StyledWrapper = styled.button`
5151
5252
.search-result-name {
5353
font-size: 13px;
54-
font-weight: 600;
54+
font-weight: 400;
5555
line-height: 1.3;
5656
color: var(--oc-text);
5757
white-space: nowrap;
5858
overflow: hidden;
5959
text-overflow: ellipsis;
6060
}
6161
62+
.search-hl {
63+
font-weight: 700;
64+
}
65+
6266
.search-result-breadcrumb {
6367
font-size: 11px;
6468
line-height: 1.3;

0 commit comments

Comments
 (0)