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.
Files changed (55) hide show
  1. package/dist/__tests__/unit/dev-open-browser.test.d.ts +2 -0
  2. package/dist/__tests__/unit/dev-open-browser.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/dev-open-browser.test.js +123 -0
  4. package/dist/__tests__/unit/dev-open-browser.test.js.map +1 -0
  5. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  6. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  8. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  9. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  10. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  11. package/dist/__tests__/unit/language-filter.test.js +166 -0
  12. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  13. package/dist/__tests__/unit/migrate-detect.test.js +73 -1
  14. package/dist/__tests__/unit/migrate-detect.test.js.map +1 -1
  15. package/dist/__tests__/unit/migrate-resolve-theme.test.js +6 -0
  16. package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
  17. package/dist/commands/dev.d.ts +38 -0
  18. package/dist/commands/dev.d.ts.map +1 -1
  19. package/dist/commands/dev.js +88 -1
  20. package/dist/commands/dev.js.map +1 -1
  21. package/dist/commands/login.d.ts.map +1 -1
  22. package/dist/commands/login.js +2 -1
  23. package/dist/commands/login.js.map +1 -1
  24. package/dist/commands/migrate/detect.d.ts +11 -1
  25. package/dist/commands/migrate/detect.d.ts.map +1 -1
  26. package/dist/commands/migrate/detect.js +17 -4
  27. package/dist/commands/migrate/detect.js.map +1 -1
  28. package/dist/commands/migrate/index.d.ts.map +1 -1
  29. package/dist/commands/migrate/index.js +7 -3
  30. package/dist/commands/migrate/index.js.map +1 -1
  31. package/dist/index.js +9 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/lib/config.d.ts +2 -0
  34. package/dist/lib/config.d.ts.map +1 -1
  35. package/dist/lib/config.js.map +1 -1
  36. package/dist/lib/docs-config.d.ts +2 -0
  37. package/dist/lib/docs-config.d.ts.map +1 -1
  38. package/dist/lib/docs-config.js +2 -2
  39. package/dist/lib/docs-config.js.map +1 -1
  40. package/dist/lib/language-filter.d.ts +31 -0
  41. package/dist/lib/language-filter.d.ts.map +1 -0
  42. package/dist/lib/language-filter.js +14 -0
  43. package/dist/lib/language-filter.js.map +1 -0
  44. package/dist/lib/open-url.d.ts +12 -0
  45. package/dist/lib/open-url.d.ts.map +1 -0
  46. package/dist/lib/open-url.js +14 -0
  47. package/dist/lib/open-url.js.map +1 -0
  48. package/package.json +1 -1
  49. package/vendored/components/search/SearchModal.tsx +175 -67
  50. package/vendored/lib/analytics-client.ts +2 -0
  51. package/vendored/lib/search-client.ts +223 -2
  52. package/vendored/lib/search.ts +8 -0
  53. package/vendored/lib/static-artifacts.ts +11 -0
  54. package/vendored/scripts/build-search-index.cjs +1 -0
  55. 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 [results, setResults] = useState<SearchResult[]>([]);
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
- setResults([]);
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 { search, resolveActiveLocale } = await import('@/lib/search-client');
251
+ const { searchGrouped, resolveActiveLocale } = await import('@/lib/search-client');
206
252
  const activeLocale = resolveActiveLocale(pathname);
207
- const searchResults = await search(query, 15, activeLocale);
253
+ const searchResults = await searchGrouped(query, 15, activeLocale);
208
254
  if (!cancelled) {
209
- setResults(searchResults);
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
- lastSearchRef.current = { query: query.trim(), resultsCount: searchResults.length };
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 (results.length === 0) return;
236
- const el = document.getElementById(`search-result-${selectedIndex}`);
237
- el?.scrollIntoView({ block: 'nearest' });
238
- }, [selectedIndex, results.length]);
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
- (result: SearchResult, index: number) => {
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: results.length,
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: results.length,
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 url = `${linkPrefix}/${result.slug}${result.section ? `#${result.section.toLowerCase().replace(/\s+/g, '-')}` : ''}`;
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
- setResults([]);
344
+ setGroups([]);
275
345
  },
276
- [query, projectSlug, results, linkPrefix, onNavigate, router, onClose]
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 (results.length > 0) {
363
+ if (rows.length > 0) {
286
364
  if (e.key === 'ArrowDown') {
287
365
  e.preventDefault();
288
- setSelectedIndex((prev) => Math.min(prev + 1, results.length - 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' && results[selectedIndex]) {
370
+ } else if (e.key === 'Enter' && rows[selectedIndex]) {
293
371
  e.preventDefault();
294
- handleResultClick(results[selectedIndex], selectedIndex);
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() && results.length === 0 && !isSearching && e.key === 'Enter') {
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, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug, handleResultClick, isSearching, linkPrefix]);
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 (results.length > 0) return `${results.length} results found`;
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={results[selectedIndex] ? `search-result-${selectedIndex}` : undefined}
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
- ) : results.length > 0 ? (
530
+ ) : rows.length > 0 ? (
448
531
  <div className="py-2">
449
- {results.map((result, index) => (
450
- <button
451
- id={`search-result-${index}`}
452
- key={result.id}
453
- onClick={() => handleResultClick(result, index)}
454
- role="option"
455
- aria-selected={index === selectedIndex}
456
- 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 ${
457
- index === selectedIndex
458
- ? 'bg-[var(--color-bg-secondary)]'
459
- : 'hover:bg-[var(--color-bg-secondary)]'
460
- }`}
461
- >
462
- <i className="fa-solid fa-file-lines h-4 w-4 text-[var(--color-accent)] flex-shrink-0 mt-0.5" aria-hidden="true" />
463
- <div className="flex-1 text-left min-w-0">
464
- <div className="flex items-center gap-2 mb-0.5">
465
- <span className="text-sm text-[var(--color-text-primary)] font-medium truncate">
466
- {highlightMatch(result.title, query)}
467
- </span>
468
- <TypeBadge type={result.type} />
469
- {result.section && (
470
- <>
471
- <i className="fa-solid fa-chevron-right h-2.5 w-2.5 text-[var(--color-text-muted)] flex-shrink-0" aria-hidden="true" />
472
- <span className="text-xs text-[var(--color-text-tertiary)] truncate">
473
- {highlightMatch(result.section, query)}
474
- </span>
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
- {result.content && (
479
- <p className="text-xs text-[var(--color-text-tertiary)] line-clamp-2">
480
- {highlightMatch(result.content, query)}
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
- /** Language of the document. Empty string for default-language pages. */
20
- locale: string;
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;
@@ -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);