jamdesk 1.1.103 → 1.1.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/unit/dev-open-browser.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-open-browser.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-open-browser.test.js +123 -0
- package/dist/__tests__/unit/dev-open-browser.test.js.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +166 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/__tests__/unit/migrate-detect.test.js +73 -1
- package/dist/__tests__/unit/migrate-detect.test.js.map +1 -1
- package/dist/__tests__/unit/migrate-resolve-theme.test.js +6 -0
- package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
- package/dist/commands/dev.d.ts +38 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +88 -1
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +2 -1
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate/detect.d.ts +11 -1
- package/dist/commands/migrate/detect.d.ts.map +1 -1
- package/dist/commands/migrate/detect.js +17 -4
- package/dist/commands/migrate/detect.js.map +1 -1
- package/dist/commands/migrate/index.d.ts.map +1 -1
- package/dist/commands/migrate/index.js +7 -3
- package/dist/commands/migrate/index.js.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/docs-config.d.ts +2 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +2 -2
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/language-filter.d.ts +31 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +14 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/dist/lib/open-url.d.ts +12 -0
- package/dist/lib/open-url.d.ts.map +1 -0
- package/dist/lib/open-url.js +14 -0
- package/dist/lib/open-url.js.map +1 -0
- package/package.json +1 -1
- package/vendored/components/search/SearchModal.tsx +175 -67
- package/vendored/lib/analytics-client.ts +2 -0
- package/vendored/lib/search-client.ts +223 -2
- package/vendored/lib/search.ts +8 -0
- package/vendored/lib/static-artifacts.ts +11 -0
- package/vendored/scripts/build-search-index.cjs +1 -0
- package/vendored/workspace-package-lock.json +70 -70
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, useRef, useCallback, type ReactElement } from 'react';
|
|
3
|
+
import { useEffect, useState, useRef, useCallback, useMemo, type ReactElement } from 'react';
|
|
4
4
|
import { usePathname, useRouter } from 'next/navigation';
|
|
5
5
|
import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
|
|
6
6
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
@@ -9,7 +9,7 @@ import { getSuggestions } from '@/lib/search-suggestions';
|
|
|
9
9
|
import { trackSearch } from '@/lib/analytics-client';
|
|
10
10
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
11
11
|
import { useProjectSlug } from '@/lib/project-slug-context';
|
|
12
|
-
import type { SearchResult } from '@/lib/search-client';
|
|
12
|
+
import type { SearchResult, SearchGroup } from '@/lib/search-client';
|
|
13
13
|
|
|
14
14
|
interface PopularPage {
|
|
15
15
|
title: string;
|
|
@@ -90,12 +90,57 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
type SearchRow =
|
|
94
|
+
| { kind: 'page'; group: SearchGroup; result: SearchResult }
|
|
95
|
+
| { kind: 'section'; group: SearchGroup; result: SearchResult; sectionIndex: number }
|
|
96
|
+
| { kind: 'expander'; group: SearchGroup; hiddenCount: number };
|
|
97
|
+
|
|
98
|
+
const VISIBLE_SECTIONS_PER_GROUP = 3;
|
|
99
|
+
|
|
100
|
+
function flattenGroups(groups: SearchGroup[], expanded: Set<string>): SearchRow[] {
|
|
101
|
+
const rows: SearchRow[] = [];
|
|
102
|
+
for (const g of groups) {
|
|
103
|
+
rows.push({ kind: 'page', group: g, result: g.page });
|
|
104
|
+
const isExpanded = expanded.has(g.slug);
|
|
105
|
+
const visible = isExpanded ? g.sections : g.sections.slice(0, VISIBLE_SECTIONS_PER_GROUP);
|
|
106
|
+
visible.forEach((s, idx) =>
|
|
107
|
+
rows.push({ kind: 'section', group: g, result: s, sectionIndex: idx }),
|
|
108
|
+
);
|
|
109
|
+
const hiddenCount = g.sections.length - visible.length;
|
|
110
|
+
if (hiddenCount > 0) rows.push({ kind: 'expander', group: g, hiddenCount });
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Prefer `description` (frontmatter summary) when it contains a query token;
|
|
117
|
+
* otherwise fall back to the page's intro `content` — that's what Orama
|
|
118
|
+
* actually scored against, so highlights are guaranteed to render there.
|
|
119
|
+
*/
|
|
120
|
+
function pickHighlightSnippet(
|
|
121
|
+
description: string | undefined,
|
|
122
|
+
content: string,
|
|
123
|
+
query: string,
|
|
124
|
+
): string {
|
|
125
|
+
if (!description) return content;
|
|
126
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
127
|
+
if (tokens.length === 0) return description;
|
|
128
|
+
const descLower = description.toLowerCase();
|
|
129
|
+
return tokens.some(t => descLower.includes(t)) ? description : content;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function countVisibleResults(rows: SearchRow[]): number {
|
|
133
|
+
return rows.reduce((n, r) => (r.kind === 'expander' ? n : n + 1), 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
93
136
|
export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
|
|
94
137
|
const linkPrefix = useLinkPrefix();
|
|
95
138
|
const projectSlug = useProjectSlug();
|
|
96
139
|
const pathname = usePathname() ?? '';
|
|
97
140
|
const [query, setQuery] = useState('');
|
|
98
|
-
const [
|
|
141
|
+
const [groups, setGroups] = useState<SearchGroup[]>([]);
|
|
142
|
+
const [expandedSlugs, setExpandedSlugs] = useState<Set<string>>(new Set());
|
|
143
|
+
const rows = useMemo(() => flattenGroups(groups, expandedSlugs), [groups, expandedSlugs]);
|
|
99
144
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
100
145
|
const [popularIndex, setPopularIndex] = useState(0);
|
|
101
146
|
const [isSearching, setIsSearching] = useState(false);
|
|
@@ -108,7 +153,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
108
153
|
// Track if user clicked a result (to avoid double-tracking on modal close)
|
|
109
154
|
const hasTrackedRef = useRef(false);
|
|
110
155
|
// Store last search state for tracking on modal close
|
|
111
|
-
const lastSearchRef = useRef<{ query: string; resultsCount: number } | null>(null);
|
|
156
|
+
const lastSearchRef = useRef<{ query: string; resultsCount: number; groupCount: number } | null>(null);
|
|
112
157
|
|
|
113
158
|
// Effective popular pages (use provided or defaults)
|
|
114
159
|
const effectivePopularPages = popularPages?.length ? popularPages : DEFAULT_POPULAR_PAGES;
|
|
@@ -137,6 +182,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
137
182
|
type: 'search_query',
|
|
138
183
|
query: lastSearchRef.current.query,
|
|
139
184
|
resultsCount: lastSearchRef.current.resultsCount,
|
|
185
|
+
groupCount: lastSearchRef.current.groupCount,
|
|
140
186
|
});
|
|
141
187
|
}
|
|
142
188
|
// Reset tracking state when modal opens
|
|
@@ -192,7 +238,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
192
238
|
// Debounced search effect
|
|
193
239
|
useEffect(() => {
|
|
194
240
|
if (!query.trim()) {
|
|
195
|
-
|
|
241
|
+
setGroups([]);
|
|
196
242
|
setIsSearching(false);
|
|
197
243
|
return;
|
|
198
244
|
}
|
|
@@ -202,21 +248,25 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
202
248
|
|
|
203
249
|
const timer = setTimeout(async () => {
|
|
204
250
|
try {
|
|
205
|
-
const {
|
|
251
|
+
const { searchGrouped, resolveActiveLocale } = await import('@/lib/search-client');
|
|
206
252
|
const activeLocale = resolveActiveLocale(pathname);
|
|
207
|
-
const searchResults = await
|
|
253
|
+
const searchResults = await searchGrouped(query, 15, activeLocale);
|
|
208
254
|
if (!cancelled) {
|
|
209
|
-
|
|
255
|
+
setGroups(searchResults);
|
|
256
|
+
setExpandedSlugs(new Set());
|
|
210
257
|
setSelectedIndex(0);
|
|
211
258
|
// Store search state for tracking on commit (Enter, click, modal close)
|
|
212
259
|
// This avoids tracking intermediate keystrokes like "C", "Co", "Col" when typing "Column"
|
|
213
|
-
|
|
260
|
+
const visibleRowCount = countVisibleResults(flattenGroups(searchResults, new Set()));
|
|
261
|
+
lastSearchRef.current = {
|
|
262
|
+
query: query.trim(),
|
|
263
|
+
resultsCount: visibleRowCount,
|
|
264
|
+
groupCount: searchResults.length,
|
|
265
|
+
};
|
|
214
266
|
}
|
|
215
267
|
} catch (error) {
|
|
216
268
|
console.error('Search error:', error);
|
|
217
|
-
if (!cancelled)
|
|
218
|
-
setResults([]);
|
|
219
|
-
}
|
|
269
|
+
if (!cancelled) setGroups([]);
|
|
220
270
|
} finally {
|
|
221
271
|
if (!cancelled) {
|
|
222
272
|
setIsSearching(false);
|
|
@@ -230,30 +280,46 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
230
280
|
};
|
|
231
281
|
}, [query, pathname]);
|
|
232
282
|
|
|
233
|
-
// Scroll selected result into view
|
|
283
|
+
// Scroll selected result into view. Operates only on the results container —
|
|
284
|
+
// `scrollIntoView` inside a fixed-position modal scrolls every scrollable
|
|
285
|
+
// ancestor (including the page behind the modal), which is a documented
|
|
286
|
+
// anti-pattern in CLAUDE.md.
|
|
234
287
|
useEffect(() => {
|
|
235
|
-
if (
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
288
|
+
if (rows.length === 0) return;
|
|
289
|
+
const container = resultsContainerRef.current;
|
|
290
|
+
if (!container) return;
|
|
291
|
+
const el = container.querySelector<HTMLElement>(`#search-result-${selectedIndex}`);
|
|
292
|
+
if (!el) return;
|
|
293
|
+
const cRect = container.getBoundingClientRect();
|
|
294
|
+
const eRect = el.getBoundingClientRect();
|
|
295
|
+
if (eRect.top < cRect.top) {
|
|
296
|
+
container.scrollTop += eRect.top - cRect.top;
|
|
297
|
+
} else if (eRect.bottom > cRect.bottom) {
|
|
298
|
+
container.scrollTop += eRect.bottom - cRect.bottom;
|
|
299
|
+
}
|
|
300
|
+
}, [selectedIndex, rows.length]);
|
|
239
301
|
|
|
240
302
|
// Click handler for a result row. Defined before the keyboard-navigation
|
|
241
303
|
// effect so it can be a stable dep without TDZ issues.
|
|
242
304
|
const handleResultClick = useCallback(
|
|
243
|
-
(
|
|
305
|
+
(row: Exclude<SearchRow, { kind: 'expander' }>, index: number) => {
|
|
306
|
+
const result = row.result;
|
|
307
|
+
const visibleCount = countVisibleResults(rows);
|
|
244
308
|
if (query.trim()) {
|
|
245
309
|
// Track search_query event (the search itself)
|
|
246
310
|
trackSearch(projectSlug, {
|
|
247
311
|
type: 'search_query',
|
|
248
312
|
query: query.trim(),
|
|
249
|
-
resultsCount:
|
|
313
|
+
resultsCount: visibleCount,
|
|
314
|
+
groupCount: groups.length,
|
|
250
315
|
});
|
|
251
316
|
|
|
252
317
|
// Track search_click event (the result they clicked)
|
|
253
318
|
trackSearch(projectSlug, {
|
|
254
319
|
type: 'search_click',
|
|
255
320
|
query: query.trim(),
|
|
256
|
-
resultsCount:
|
|
321
|
+
resultsCount: visibleCount,
|
|
322
|
+
groupCount: groups.length,
|
|
257
323
|
clickedResult: {
|
|
258
324
|
slug: result.slug,
|
|
259
325
|
position: index + 1, // 1-indexed for analytics
|
|
@@ -266,36 +332,53 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
266
332
|
addRecentSearch(projectSlug, query.trim());
|
|
267
333
|
}
|
|
268
334
|
|
|
269
|
-
const
|
|
335
|
+
const hash =
|
|
336
|
+
row.kind === 'section' && result.section
|
|
337
|
+
? `#${result.section.toLowerCase().replace(/\s+/g, '-')}`
|
|
338
|
+
: '';
|
|
339
|
+
const url = `${linkPrefix}/${result.slug}${hash}`;
|
|
270
340
|
onNavigate?.(url);
|
|
271
341
|
router.push(url);
|
|
272
342
|
onClose();
|
|
273
343
|
setQuery('');
|
|
274
|
-
|
|
344
|
+
setGroups([]);
|
|
275
345
|
},
|
|
276
|
-
[query, projectSlug,
|
|
346
|
+
[query, projectSlug, rows, groups, linkPrefix, onNavigate, router, onClose],
|
|
277
347
|
);
|
|
278
348
|
|
|
349
|
+
const handleExpanderClick = useCallback((slug: string) => {
|
|
350
|
+
setExpandedSlugs(prev => {
|
|
351
|
+
const next = new Set(prev);
|
|
352
|
+
next.add(slug);
|
|
353
|
+
return next;
|
|
354
|
+
});
|
|
355
|
+
}, []);
|
|
356
|
+
|
|
279
357
|
// Keyboard navigation
|
|
280
358
|
useEffect(() => {
|
|
281
359
|
if (!isOpen) return;
|
|
282
360
|
|
|
283
361
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
284
362
|
// When we have search results, navigate through them
|
|
285
|
-
if (
|
|
363
|
+
if (rows.length > 0) {
|
|
286
364
|
if (e.key === 'ArrowDown') {
|
|
287
365
|
e.preventDefault();
|
|
288
|
-
setSelectedIndex((prev) => Math.min(prev + 1,
|
|
366
|
+
setSelectedIndex((prev) => Math.min(prev + 1, rows.length - 1));
|
|
289
367
|
} else if (e.key === 'ArrowUp') {
|
|
290
368
|
e.preventDefault();
|
|
291
369
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
292
|
-
} else if (e.key === 'Enter' &&
|
|
370
|
+
} else if (e.key === 'Enter' && rows[selectedIndex]) {
|
|
293
371
|
e.preventDefault();
|
|
294
|
-
|
|
372
|
+
const row = rows[selectedIndex];
|
|
373
|
+
if (row.kind === 'expander') {
|
|
374
|
+
handleExpanderClick(row.group.slug);
|
|
375
|
+
} else {
|
|
376
|
+
handleResultClick(row, selectedIndex);
|
|
377
|
+
}
|
|
295
378
|
}
|
|
296
379
|
}
|
|
297
380
|
// When query exists but no results, Enter tracks the zero-result search
|
|
298
|
-
else if (query.trim() &&
|
|
381
|
+
else if (query.trim() && rows.length === 0 && !isSearching && e.key === 'Enter') {
|
|
299
382
|
e.preventDefault();
|
|
300
383
|
// Track this as a committed search with zero results
|
|
301
384
|
trackSearch(projectSlug, {
|
|
@@ -336,7 +419,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
336
419
|
|
|
337
420
|
document.addEventListener('keydown', handleKeyDown);
|
|
338
421
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
339
|
-
}, [isOpen, query, recentSearches,
|
|
422
|
+
}, [isOpen, query, recentSearches, rows, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug, handleResultClick, handleExpanderClick, isSearching, linkPrefix]);
|
|
340
423
|
|
|
341
424
|
const handleClearRecentSearches = () => {
|
|
342
425
|
clearRecentSearches(projectSlug);
|
|
@@ -368,7 +451,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
368
451
|
|
|
369
452
|
// Screen reader announcement text
|
|
370
453
|
const getAnnouncementText = () => {
|
|
371
|
-
if (
|
|
454
|
+
if (rows.length > 0) return `${countVisibleResults(rows)} results found`;
|
|
372
455
|
if (query.trim() && !isSearching) return 'No results found';
|
|
373
456
|
return '';
|
|
374
457
|
};
|
|
@@ -411,7 +494,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
411
494
|
autoFocus
|
|
412
495
|
aria-label="Search documentation"
|
|
413
496
|
aria-controls="search-results"
|
|
414
|
-
aria-activedescendant={
|
|
497
|
+
aria-activedescendant={rows[selectedIndex] ? `search-result-${selectedIndex}` : undefined}
|
|
415
498
|
/>
|
|
416
499
|
<button
|
|
417
500
|
onClick={onClose}
|
|
@@ -444,45 +527,70 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
444
527
|
<LoadingSpinner message="Loading search…" />
|
|
445
528
|
) : isSearching ? (
|
|
446
529
|
<LoadingSpinner message="Searching…" />
|
|
447
|
-
) :
|
|
530
|
+
) : rows.length > 0 ? (
|
|
448
531
|
<div className="py-2">
|
|
449
|
-
{
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
</span>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
532
|
+
{rows.map((row, index) => {
|
|
533
|
+
const isSelected = index === selectedIndex;
|
|
534
|
+
|
|
535
|
+
if (row.kind === 'expander') {
|
|
536
|
+
const label = `+${row.hiddenCount} more section${row.hiddenCount === 1 ? '' : 's'}`;
|
|
537
|
+
return (
|
|
538
|
+
<button
|
|
539
|
+
id={`search-result-${index}`}
|
|
540
|
+
key={`expander:${row.group.slug}`}
|
|
541
|
+
onClick={() => handleExpanderClick(row.group.slug)}
|
|
542
|
+
role="option"
|
|
543
|
+
aria-selected={isSelected}
|
|
544
|
+
aria-label={label}
|
|
545
|
+
className={`w-full pl-10 pr-4 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] ${
|
|
546
|
+
isSelected ? 'bg-[var(--color-bg-secondary)]' : 'hover:bg-[var(--color-bg-secondary)]'
|
|
547
|
+
}`}
|
|
548
|
+
>
|
|
549
|
+
<i className="fa-solid fa-chevron-down h-2.5 w-2.5" aria-hidden="true" />
|
|
550
|
+
<span>{label}</span>
|
|
551
|
+
</button>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const snippet = row.kind === 'page'
|
|
555
|
+
? pickHighlightSnippet(row.result.description, row.result.content, query)
|
|
556
|
+
: null;
|
|
557
|
+
return (
|
|
558
|
+
<button
|
|
559
|
+
id={`search-result-${index}`}
|
|
560
|
+
key={`${row.group.slug}:${row.kind}:${row.result.id}`}
|
|
561
|
+
onClick={() => handleResultClick(row, index)}
|
|
562
|
+
role="option"
|
|
563
|
+
aria-selected={isSelected}
|
|
564
|
+
className={`w-full px-4 py-2.5 flex items-start gap-3 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-inset ${
|
|
565
|
+
isSelected ? 'bg-[var(--color-bg-secondary)]' : 'hover:bg-[var(--color-bg-secondary)]'
|
|
566
|
+
} ${row.kind === 'section' ? 'pl-10' : ''}`}
|
|
567
|
+
>
|
|
568
|
+
{row.kind === 'page' ? (
|
|
569
|
+
<i className="fa-solid fa-file-lines h-4 w-4 text-[var(--color-accent)] flex-shrink-0 mt-0.5" aria-hidden="true" />
|
|
570
|
+
) : (
|
|
571
|
+
<i className="fa-solid fa-hashtag h-3 w-3 text-[var(--color-text-muted)] flex-shrink-0 mt-1" aria-hidden="true" />
|
|
572
|
+
)}
|
|
573
|
+
<div className="flex-1 text-left min-w-0">
|
|
574
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
575
|
+
<span className={`text-sm truncate ${row.kind === 'page' ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>
|
|
576
|
+
{row.kind === 'page'
|
|
577
|
+
? highlightMatch(row.result.title, query)
|
|
578
|
+
: highlightMatch(row.result.section ?? row.result.title, query)}
|
|
579
|
+
</span>
|
|
580
|
+
{row.kind === 'page' && <TypeBadge type={row.result.type} />}
|
|
581
|
+
</div>
|
|
582
|
+
{snippet && (
|
|
583
|
+
<p className="text-xs text-[var(--color-text-tertiary)] line-clamp-2">{highlightMatch(snippet, query)}</p>
|
|
584
|
+
)}
|
|
585
|
+
{row.kind === 'section' && row.result.content && (
|
|
586
|
+
<p className="text-xs text-[var(--color-text-tertiary)] line-clamp-1">
|
|
587
|
+
{highlightMatch(row.result.content, query)}
|
|
588
|
+
</p>
|
|
476
589
|
)}
|
|
477
590
|
</div>
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
</p>
|
|
482
|
-
)}
|
|
483
|
-
</div>
|
|
484
|
-
</button>
|
|
485
|
-
))}
|
|
591
|
+
</button>
|
|
592
|
+
);
|
|
593
|
+
})}
|
|
486
594
|
</div>
|
|
487
595
|
) : query.trim() ? (
|
|
488
596
|
<NoResultsState query={query} onSuggestionClick={setQuery} />
|
|
@@ -28,12 +28,14 @@ interface SearchQueryEvent {
|
|
|
28
28
|
type: 'search_query';
|
|
29
29
|
query: string;
|
|
30
30
|
resultsCount: number;
|
|
31
|
+
groupCount?: number;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
interface SearchClickEvent {
|
|
34
35
|
type: 'search_click';
|
|
35
36
|
query: string;
|
|
36
37
|
resultsCount: number;
|
|
38
|
+
groupCount?: number;
|
|
37
39
|
clickedResult: {
|
|
38
40
|
slug: string;
|
|
39
41
|
position: number;
|
|
@@ -16,8 +16,19 @@ export interface SearchResult {
|
|
|
16
16
|
slug: string;
|
|
17
17
|
section?: string;
|
|
18
18
|
type?: 'api' | 'component' | 'guide' | 'help' | 'quickstart';
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Language of the document. Empty string for default-language pages.
|
|
21
|
+
* OPTIONAL because legacy R2 search-data.json files predate this field;
|
|
22
|
+
* the client treats `undefined` and `''` identically.
|
|
23
|
+
*/
|
|
24
|
+
locale?: string;
|
|
25
|
+
/**
|
|
26
|
+
* True for the canonical page-level document (idx=0). NOT a queryable
|
|
27
|
+
* Orama field — used only by `pageDocsBySlugLocale` to identify the
|
|
28
|
+
* page-level doc per (slug, locale). Legacy indexes that lack this field
|
|
29
|
+
* fall back via the 3-pass inference in `rebuildPageDocsMap`.
|
|
30
|
+
*/
|
|
31
|
+
isPageLevel?: boolean;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
type OramaDb = Orama<{
|
|
@@ -56,6 +67,62 @@ let lastParsedData: SearchResult[] | null = null;
|
|
|
56
67
|
let projectLocalesLowered: ReadonlySet<string> = new Set();
|
|
57
68
|
let indexHasLocaleField = false;
|
|
58
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Canonical page-level document per (slug, locale). Rebuilt on every
|
|
72
|
+
* `initializeSearch` call (BEFORE the fingerprint short-circuit) so the
|
|
73
|
+
* map can never drift from the committed data. Used by `searchGrouped`
|
|
74
|
+
* to surface a guaranteed group header regardless of BM25 score.
|
|
75
|
+
*/
|
|
76
|
+
let pageDocsBySlugLocale: Map<string, SearchResult> = new Map();
|
|
77
|
+
|
|
78
|
+
function mapKey(slug: string, locale: string): string {
|
|
79
|
+
return `${slug}|${locale}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Three-pass page-level inference:
|
|
84
|
+
* 1. Explicit `isPageLevel === true` (new emitter)
|
|
85
|
+
* 2. Empty `section` field (legacy emitter, page has intro text)
|
|
86
|
+
* 3. `id` ends in `-0` (legacy emitter, page starts with a heading)
|
|
87
|
+
*
|
|
88
|
+
* First-match wins per (slug, locale).
|
|
89
|
+
*/
|
|
90
|
+
function rebuildPageDocsMap(data: SearchResult[]): void {
|
|
91
|
+
// Build into a local map and assign at the end so an in-flight reader
|
|
92
|
+
// can never observe a partial state.
|
|
93
|
+
const m = new Map<string, SearchResult>();
|
|
94
|
+
const localeOf = (item: SearchResult) => item.locale ?? '';
|
|
95
|
+
|
|
96
|
+
// Pass 1: explicit flag
|
|
97
|
+
for (const item of data) {
|
|
98
|
+
if (item.isPageLevel !== true) continue;
|
|
99
|
+
const key = mapKey(item.slug, localeOf(item));
|
|
100
|
+
if (!m.has(key)) m.set(key, item);
|
|
101
|
+
}
|
|
102
|
+
// Pass 2: section-less docs
|
|
103
|
+
for (const item of data) {
|
|
104
|
+
if (item.section) continue;
|
|
105
|
+
const key = mapKey(item.slug, localeOf(item));
|
|
106
|
+
if (!m.has(key)) m.set(key, item);
|
|
107
|
+
}
|
|
108
|
+
// Pass 3: id ends in `-0` (lowest-idx doc)
|
|
109
|
+
for (const item of data) {
|
|
110
|
+
if (!item.id.endsWith('-0')) continue;
|
|
111
|
+
const key = mapKey(item.slug, localeOf(item));
|
|
112
|
+
if (!m.has(key)) m.set(key, item);
|
|
113
|
+
}
|
|
114
|
+
pageDocsBySlugLocale = m;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns the canonical page-level document for the given (slug, locale)
|
|
119
|
+
* pair, or undefined if the index has no doc for that pair. Used by
|
|
120
|
+
* `searchGrouped` to construct group headers.
|
|
121
|
+
*/
|
|
122
|
+
export function getPageDocForSlug(slug: string, locale: string): SearchResult | undefined {
|
|
123
|
+
return pageDocsBySlugLocale.get(mapKey(slug, locale));
|
|
124
|
+
}
|
|
125
|
+
|
|
59
126
|
/**
|
|
60
127
|
* Cheap fingerprint: count + first/last IDs + a sample of content lengths.
|
|
61
128
|
* Detects new/removed pages AND content edits (which change content length).
|
|
@@ -135,6 +202,12 @@ async function buildIndex(data: SearchResult[], etag: string): Promise<void> {
|
|
|
135
202
|
}
|
|
136
203
|
|
|
137
204
|
export async function initializeSearch(data: SearchResult[], etag = ''): Promise<void> {
|
|
205
|
+
// ALWAYS rebuild the page-docs map before any short-circuit. Rebuilding
|
|
206
|
+
// it here (rather than inside buildIndex) guarantees the map stays
|
|
207
|
+
// consistent with the input even when Orama build is skipped on a
|
|
208
|
+
// fingerprint match (ETag re-fetch, modal re-open with same data).
|
|
209
|
+
rebuildPageDocsMap(data);
|
|
210
|
+
|
|
138
211
|
const fp = fingerprint(data);
|
|
139
212
|
|
|
140
213
|
// Skip rebuild if the committed index already matches
|
|
@@ -217,6 +290,154 @@ export async function search(
|
|
|
217
290
|
return docs.slice(0, limit);
|
|
218
291
|
}
|
|
219
292
|
|
|
293
|
+
/**
|
|
294
|
+
* A group of search hits collapsed under a single page.
|
|
295
|
+
*
|
|
296
|
+
* `page` is always present and represents the group header. Source order:
|
|
297
|
+
* 1. The canonical page-level doc from `pageDocsBySlugLocale`
|
|
298
|
+
* 2. (fallback) A page-level hit observed in this query's results
|
|
299
|
+
* 3. (fallback) A synthesized header derived from the highest-ranked
|
|
300
|
+
* matching section
|
|
301
|
+
*
|
|
302
|
+
* `sections` excludes the page-level doc and is the FULL score-sorted list
|
|
303
|
+
* of matched sub-sections. The UI applies the visible cap.
|
|
304
|
+
*/
|
|
305
|
+
export interface SearchGroup {
|
|
306
|
+
slug: string;
|
|
307
|
+
page: SearchResult;
|
|
308
|
+
sections: SearchResult[];
|
|
309
|
+
totalSections: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Same query as `search()` but collapses hits by slug.
|
|
314
|
+
*
|
|
315
|
+
* `groupLimit` is the maximum number of groups (pages). Internally
|
|
316
|
+
* over-fetches up to `groupLimit * 5`, capped at 75, so a page with many
|
|
317
|
+
* matching sections doesn't starve other pages out of the result set.
|
|
318
|
+
*
|
|
319
|
+
* Tail risk: pages with VERY many matching sections (50+) can still
|
|
320
|
+
* dominate the over-fetch window and crowd out lower-ranked pages.
|
|
321
|
+
* Acceptable for docs; switch to Orama v3.1's `groupBy` if it becomes an
|
|
322
|
+
* issue.
|
|
323
|
+
*/
|
|
324
|
+
export async function searchGrouped(
|
|
325
|
+
query: string,
|
|
326
|
+
groupLimit = 10,
|
|
327
|
+
language?: string,
|
|
328
|
+
): Promise<SearchGroup[]> {
|
|
329
|
+
if (!db) {
|
|
330
|
+
console.warn('Search database not initialized');
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
if (!query.trim()) return [];
|
|
334
|
+
|
|
335
|
+
const targetLocale = language ?? '';
|
|
336
|
+
const docFetchLimit = Math.min(groupLimit * 5, 75);
|
|
337
|
+
|
|
338
|
+
const results = await oramaSearch(db, {
|
|
339
|
+
term: query,
|
|
340
|
+
limit: docFetchLimit,
|
|
341
|
+
tolerance: 1,
|
|
342
|
+
boost: { title: 2, section: 1.5, description: 1, content: 0.5 },
|
|
343
|
+
...(indexHasLocaleField ? { where: { locale: { eq: targetLocale } } } : {}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
let hits = results.hits.map(h => ({
|
|
347
|
+
doc: h.document as unknown as SearchResult,
|
|
348
|
+
score: h.score,
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
// Locale post-filter for legacy indexes that lack the locale field.
|
|
352
|
+
if (!indexHasLocaleField) {
|
|
353
|
+
if (targetLocale) {
|
|
354
|
+
const prefix = `${targetLocale}/`;
|
|
355
|
+
hits = hits.filter(h => h.doc.slug.startsWith(prefix));
|
|
356
|
+
} else if (projectLocalesLowered.size > 0) {
|
|
357
|
+
hits = hits.filter(h => {
|
|
358
|
+
const firstSeg = h.doc.slug.split('/', 1)[0]?.toLowerCase();
|
|
359
|
+
return !firstSeg || !projectLocalesLowered.has(firstSeg);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
type Bucket = {
|
|
365
|
+
slug: string;
|
|
366
|
+
bestScore: number;
|
|
367
|
+
pageHit?: SearchResult;
|
|
368
|
+
sections: { doc: SearchResult; score: number }[];
|
|
369
|
+
};
|
|
370
|
+
const bucketsBySlug = new Map<string, Bucket>();
|
|
371
|
+
const slugOrder: string[] = [];
|
|
372
|
+
|
|
373
|
+
for (const { doc, score } of hits) {
|
|
374
|
+
let bucket = bucketsBySlug.get(doc.slug);
|
|
375
|
+
if (!bucket) {
|
|
376
|
+
bucket = { slug: doc.slug, bestScore: score, sections: [] };
|
|
377
|
+
bucketsBySlug.set(doc.slug, bucket);
|
|
378
|
+
slugOrder.push(doc.slug);
|
|
379
|
+
}
|
|
380
|
+
if (score > bucket.bestScore) bucket.bestScore = score;
|
|
381
|
+
|
|
382
|
+
// Identify page-level doc by ID match against the canonical map
|
|
383
|
+
// rather than `doc.isPageLevel` — robust against Orama field stripping.
|
|
384
|
+
//
|
|
385
|
+
// CRITICAL: this lookup MUST use the doc's own locale (defaulting to ''),
|
|
386
|
+
// NOT `targetLocale`. The map is keyed by `${slug}|${item.locale ?? ''}`
|
|
387
|
+
// — so legacy docs (no locale field) live at `slug|''` regardless of the
|
|
388
|
+
// active search locale. Looking up with `targetLocale` would silently
|
|
389
|
+
// miss the canonical doc on non-default-locale searches, breaking
|
|
390
|
+
// page-first identification for the exact legacy case the 3-pass
|
|
391
|
+
// fallback protects.
|
|
392
|
+
const canonicalId = getPageDocForSlug(doc.slug, doc.locale ?? '')?.id;
|
|
393
|
+
if (canonicalId && canonicalId === doc.id) {
|
|
394
|
+
bucket.pageHit = doc;
|
|
395
|
+
} else {
|
|
396
|
+
bucket.sections.push({ doc, score });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const topBuckets = slugOrder
|
|
401
|
+
.map(s => bucketsBySlug.get(s)!)
|
|
402
|
+
.sort((a, b) => b.bestScore - a.bestScore)
|
|
403
|
+
.slice(0, groupLimit);
|
|
404
|
+
|
|
405
|
+
return topBuckets.map(bucket => {
|
|
406
|
+
// Use the locale of an actual hit in the bucket to look up the
|
|
407
|
+
// canonical doc — mirrors the map's keying rule (`item.locale ?? ''`).
|
|
408
|
+
// Avoids the legacy-index miss where targetLocale='fr' but the map
|
|
409
|
+
// keyed the slug at `slug|''` because the doc had no locale field.
|
|
410
|
+
const sampleHit = bucket.pageHit ?? bucket.sections[0]?.doc;
|
|
411
|
+
const lookupLocale = sampleHit?.locale ?? '';
|
|
412
|
+
const canonical = getPageDocForSlug(bucket.slug, lookupLocale);
|
|
413
|
+
|
|
414
|
+
const synthesized: SearchResult | undefined =
|
|
415
|
+
!canonical && !bucket.pageHit && bucket.sections[0]
|
|
416
|
+
? {
|
|
417
|
+
...bucket.sections[0].doc,
|
|
418
|
+
id: `synth:${bucket.slug}:${encodeURIComponent(query)}`,
|
|
419
|
+
section: undefined,
|
|
420
|
+
content: bucket.sections[0].doc.description || bucket.sections[0].doc.content,
|
|
421
|
+
isPageLevel: true,
|
|
422
|
+
}
|
|
423
|
+
: undefined;
|
|
424
|
+
const page = canonical ?? bucket.pageHit ?? synthesized!;
|
|
425
|
+
|
|
426
|
+
// Copy before sorting — bucket.sections is still referenced from
|
|
427
|
+
// bucketsBySlug; don't mutate it in place.
|
|
428
|
+
const sortedSections = [...bucket.sections]
|
|
429
|
+
.sort((a, b) => b.score - a.score)
|
|
430
|
+
.map(s => s.doc);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
slug: bucket.slug,
|
|
434
|
+
page,
|
|
435
|
+
sections: sortedSections,
|
|
436
|
+
totalSections: sortedSections.length,
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
220
441
|
/** @internal Used by tests only */
|
|
221
442
|
export function isInitialized(): boolean {
|
|
222
443
|
return db !== null;
|
package/vendored/lib/search.ts
CHANGED
|
@@ -10,6 +10,13 @@ export interface SearchResult {
|
|
|
10
10
|
content: string;
|
|
11
11
|
slug: string;
|
|
12
12
|
section?: string;
|
|
13
|
+
/**
|
|
14
|
+
* True only on the first document emitted for each page (idx === 0).
|
|
15
|
+
* Closes the gap where client-side grouping infers the page-level entry from
|
|
16
|
+
* an empty `section` — which fails when pages open with a heading rather
|
|
17
|
+
* than introductory prose.
|
|
18
|
+
*/
|
|
19
|
+
isPageLevel?: boolean;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
let searchDocuments: SearchResult[] = [];
|
|
@@ -94,6 +101,7 @@ export function buildSearchIndex(): SearchResult[] {
|
|
|
94
101
|
content: cleanContent.substring(0, 300), // Limit content length
|
|
95
102
|
slug: slug,
|
|
96
103
|
section: section.heading || undefined,
|
|
104
|
+
...(idx === 0 ? { isPageLevel: true as const } : {}),
|
|
97
105
|
};
|
|
98
106
|
|
|
99
107
|
documents.push(doc);
|