starlight-telescope 0.0.1

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.
@@ -0,0 +1,1103 @@
1
+ import Fuse, { type FuseResult, type FuseResultMatch, type IFuseOptions } from 'fuse.js';
2
+ import type { TelescopeConfig, TelescopePage } from '../schemas/config.js';
3
+ import { getLocaleFromUrl } from './url.js';
4
+
5
+ declare global {
6
+ interface Window {
7
+ __telescopeInitialized?: boolean;
8
+ }
9
+ }
10
+
11
+ // Maximum number of pinned pages to prevent unbounded localStorage growth
12
+ const MAX_PINNED_PAGES = 50;
13
+
14
+ export default class TelescopeSearch {
15
+ private config: TelescopeConfig;
16
+ private isOpen: boolean = false;
17
+ private isLoading: boolean = true;
18
+ private searchQuery: string = '';
19
+ private allPages: TelescopePage[] = [];
20
+ private filteredPages: TelescopePage[] = [];
21
+ private selectedIndex: number = 0;
22
+ private fuseInstance: Fuse<TelescopePage> | null = null;
23
+ private recentPages: TelescopePage[];
24
+ private pinnedPages: TelescopePage[];
25
+ private currentTab: 'search' | 'recent' = 'search';
26
+ private debounceTimeout: ReturnType<typeof setTimeout> | null = null;
27
+ private currentOrigin: string | null = null;
28
+ private searchResultsWithMatches: FuseResult<TelescopePage>[] = [];
29
+ private currentLocale: string | undefined = undefined;
30
+ private hasMouseMovedSinceOpen: boolean = false;
31
+ private fetchInProgress: boolean = false;
32
+ private abortController: AbortController | null = null;
33
+ private isInNavigationMode: boolean = false;
34
+
35
+ // DOM elements
36
+ private dialogElement: HTMLDialogElement | null;
37
+ private liveRegion: HTMLElement | null;
38
+ private searchInputElement: HTMLInputElement | null;
39
+ private resultsContainerElement: HTMLElement | null;
40
+ private recentResultsContainerElement: HTMLElement | null;
41
+ private tabs: NodeListOf<HTMLElement>;
42
+ private closeButton: HTMLElement | null;
43
+
44
+ private getActiveResultsContainer(): HTMLElement | null {
45
+ if (this.currentTab === 'recent') {
46
+ return this.recentResultsContainerElement;
47
+ }
48
+ return this.resultsContainerElement;
49
+ }
50
+
51
+ constructor(config: TelescopeConfig) {
52
+ this.config = config;
53
+ this.recentPages = this.loadRecentPages();
54
+ this.pinnedPages = this.loadPinnedPages();
55
+
56
+ // DOM elements
57
+ this.dialogElement = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
58
+ this.liveRegion = document.getElementById('telescope-live-region');
59
+ this.searchInputElement = document.getElementById(
60
+ 'telescope-search-input'
61
+ ) as HTMLInputElement | null;
62
+ this.resultsContainerElement = document.getElementById('telescope-results');
63
+ this.recentResultsContainerElement = document.getElementById('telescope-recent-results');
64
+ this.tabs = document.querySelectorAll('.telescope__tab');
65
+ this.closeButton = document.getElementById('telescope-close-button');
66
+
67
+ // Bind methods
68
+ this.handleKeyDown = this.handleKeyDown.bind(this);
69
+ this.handleSearchKeyDown = this.handleSearchKeyDown.bind(this);
70
+ this.handleSearchInput = this.handleSearchInput.bind(this);
71
+ this.close = this.close.bind(this);
72
+ this.switchTab = this.switchTab.bind(this);
73
+ this.togglePinPage = this.togglePinPage.bind(this);
74
+ this.togglePinForSelectedItem = this.togglePinForSelectedItem.bind(this);
75
+
76
+ // Apply theme
77
+ this.applyTheme();
78
+
79
+ // Initialize
80
+ this.fetchPages();
81
+ this.setupListeners();
82
+ }
83
+
84
+ /**
85
+ * Validate if a string is a valid CSS color value.
86
+ */
87
+ private isValidCssColor(color: string): boolean {
88
+ const testEl = document.createElement('div');
89
+ testEl.style.color = color;
90
+ return testEl.style.color !== '';
91
+ }
92
+
93
+ /**
94
+ * Safely set a CSS custom property with color validation.
95
+ */
96
+ private setThemeColor(root: HTMLElement, property: string, color: string | undefined): void {
97
+ if (color && this.isValidCssColor(color)) {
98
+ root.style.setProperty(property, color);
99
+ }
100
+ }
101
+
102
+ private applyTheme(): void {
103
+ const { theme } = this.config;
104
+ const root = document.documentElement;
105
+
106
+ // Apply theme colors with validation
107
+ this.setThemeColor(root, '--telescope-overlay-bg', theme.overlayBackground);
108
+ this.setThemeColor(root, '--telescope-modal-bg', theme.modalBackground);
109
+ this.setThemeColor(root, '--telescope-modal-bg-alt', theme.modalBackgroundAlt);
110
+ this.setThemeColor(root, '--telescope-accent', theme.accentColor);
111
+ this.setThemeColor(root, '--telescope-accent-hover', theme.accentHover);
112
+ this.setThemeColor(root, '--telescope-accent-selected', theme.accentSelected);
113
+ this.setThemeColor(root, '--telescope-text-primary', theme.textPrimary);
114
+ this.setThemeColor(root, '--telescope-text-secondary', theme.textSecondary);
115
+ this.setThemeColor(root, '--telescope-border', theme.border);
116
+ this.setThemeColor(root, '--telescope-border-active', theme.borderActive);
117
+ this.setThemeColor(root, '--telescope-pin-color', theme.pinColor);
118
+ this.setThemeColor(root, '--telescope-tag-color', theme.tagColor);
119
+ }
120
+
121
+ private async fetchPages(): Promise<void> {
122
+ // Prevent concurrent fetches
123
+ if (this.fetchInProgress) return;
124
+ this.fetchInProgress = true;
125
+
126
+ this.isLoading = true;
127
+ this.updateLoadingState();
128
+
129
+ try {
130
+ const basePath = (import.meta.env.BASE_URL || '').replace(/\/$/, '');
131
+
132
+ // Detect current locale using shared utility (DRY)
133
+ this.currentLocale = getLocaleFromUrl(new URL(window.location.href));
134
+ const localePath = this.currentLocale ? `/${this.currentLocale}` : '';
135
+
136
+ const response = await fetch(`${basePath}${localePath}/pages.json`);
137
+ if (!response.ok) {
138
+ throw new Error(`Failed to fetch pages: ${response.status}`);
139
+ }
140
+ this.allPages = await response.json();
141
+
142
+ // Get the URL from the response object
143
+ this.currentOrigin = new URL(response.url).origin;
144
+
145
+ // Validate stored pages against current site's pages
146
+ // This filters out pages from other sites sharing the same localStorage
147
+ const validatedRecent = this.validateStoredPages(this.recentPages);
148
+ const validatedPinned = this.validateStoredPages(this.pinnedPages);
149
+
150
+ // Update and save if any pages were filtered out
151
+ if (validatedRecent.length !== this.recentPages.length) {
152
+ this.recentPages = validatedRecent;
153
+ localStorage.setItem(this.getStorageKey('recentPages'), JSON.stringify(this.recentPages));
154
+ }
155
+ if (validatedPinned.length !== this.pinnedPages.length) {
156
+ this.pinnedPages = validatedPinned;
157
+ localStorage.setItem(this.getStorageKey('pinnedPages'), JSON.stringify(this.pinnedPages));
158
+ }
159
+
160
+ // Initialize Fuse.js after fetching pages
161
+ this.initializeFuse();
162
+
163
+ // Re-render if dialog is open to show validated data
164
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
165
+ if (dialog?.open) {
166
+ this.filteredPages = [...this.allPages];
167
+ this.renderSearchResults();
168
+ this.renderRecentResults();
169
+ }
170
+ } catch (error) {
171
+ console.error('Error fetching pages:', error);
172
+ this.allPages = [];
173
+ // Clear stored pages on error since we can't validate them
174
+ this.recentPages = [];
175
+ this.pinnedPages = [];
176
+ } finally {
177
+ this.isLoading = false;
178
+ this.fetchInProgress = false;
179
+ this.updateLoadingState();
180
+ }
181
+ }
182
+
183
+ private updateLoadingState(): void {
184
+ const loadingEl = document.getElementById('telescope-loading');
185
+ const resultsEl = document.getElementById('telescope-results');
186
+ if (loadingEl) loadingEl.style.display = this.isLoading ? 'flex' : 'none';
187
+ if (resultsEl) resultsEl.style.display = this.isLoading ? 'none' : 'block';
188
+ }
189
+
190
+ private escapeHtml(text: string): string {
191
+ const escapeMap: Record<string, string> = {
192
+ '&': '&amp;',
193
+ '<': '&lt;',
194
+ '>': '&gt;',
195
+ '"': '&quot;',
196
+ "'": '&#39;',
197
+ };
198
+ return text.replace(/[&<>"']/g, (char) => escapeMap[char] || char);
199
+ }
200
+
201
+ private highlightMatches(
202
+ text: string,
203
+ matches: readonly FuseResultMatch[] | undefined,
204
+ key: string
205
+ ): string {
206
+ if (!matches || !text) return this.escapeHtml(text || '');
207
+
208
+ const match = matches.find((m) => m.key === key);
209
+ if (!match || !match.indices || match.indices.length === 0) {
210
+ return this.escapeHtml(text);
211
+ }
212
+
213
+ // Build highlighted string from indices
214
+ let result = '';
215
+ let lastIndex = 0;
216
+
217
+ // Sort indices and merge overlapping ones
218
+ const sortedIndices = [...match.indices].sort((a, b) => a[0] - b[0]);
219
+
220
+ for (const [start, end] of sortedIndices) {
221
+ if (start > lastIndex) {
222
+ result += this.escapeHtml(text.slice(lastIndex, start));
223
+ }
224
+ if (start >= lastIndex) {
225
+ result += `<mark class="telescope__highlight">${this.escapeHtml(text.slice(start, end + 1))}</mark>`;
226
+ lastIndex = end + 1;
227
+ }
228
+ }
229
+
230
+ result += this.escapeHtml(text.slice(lastIndex));
231
+ return result;
232
+ }
233
+
234
+ private getStorageKey(key: string): string {
235
+ return `telescope_${key}`;
236
+ }
237
+
238
+ private getMatchesForPage(page: TelescopePage): readonly FuseResultMatch[] | undefined {
239
+ const result = this.searchResultsWithMatches.find((r) => r.item.path === page.path);
240
+ return result?.matches;
241
+ }
242
+
243
+ private validateStoredPages(storedPages: TelescopePage[]): TelescopePage[] {
244
+ // Return empty array if allPages hasn't loaded yet - don't show unvalidated data
245
+ if (this.allPages.length === 0) return [];
246
+
247
+ const validPaths = new Set(this.allPages.map(p => p.path));
248
+ return storedPages.filter(page => validPaths.has(page.path));
249
+ }
250
+
251
+ private loadRecentPages(): TelescopePage[] {
252
+ try {
253
+ const recent = localStorage.getItem(this.getStorageKey('recentPages'));
254
+ return recent ? JSON.parse(recent) : [];
255
+ } catch (error) {
256
+ console.warn('Failed to parse recent pages from localStorage:', error);
257
+ return [];
258
+ }
259
+ }
260
+
261
+ private loadPinnedPages(): TelescopePage[] {
262
+ try {
263
+ const pinned = localStorage.getItem(this.getStorageKey('pinnedPages'));
264
+ return pinned ? JSON.parse(pinned) : [];
265
+ } catch (error) {
266
+ console.warn('Failed to parse pinned pages from localStorage:', error);
267
+ return [];
268
+ }
269
+ }
270
+
271
+ private saveRecentPage(page: TelescopePage): void {
272
+ // Remove if already exists
273
+ this.recentPages = this.recentPages.filter((p) => p.path !== page.path);
274
+ // Add to beginning
275
+ this.recentPages.unshift(page);
276
+ // Keep only configured number of items
277
+ this.recentPages = this.recentPages.slice(0, this.config.recentPagesCount);
278
+ // Save to localStorage
279
+ localStorage.setItem(this.getStorageKey('recentPages'), JSON.stringify(this.recentPages));
280
+ }
281
+
282
+ private savePinnedPages(): void {
283
+ localStorage.setItem(this.getStorageKey('pinnedPages'), JSON.stringify(this.pinnedPages));
284
+ }
285
+
286
+ private clearRecentPages(): void {
287
+ this.recentPages = [];
288
+ localStorage.removeItem(this.getStorageKey('recentPages'));
289
+ this.renderSearchResults();
290
+ this.renderRecentResults();
291
+ this.announce('Recent pages cleared');
292
+ }
293
+
294
+ private clearPinnedPages(): void {
295
+ this.pinnedPages = [];
296
+ localStorage.removeItem(this.getStorageKey('pinnedPages'));
297
+ this.renderSearchResults();
298
+ this.announce('Pinned pages cleared');
299
+ }
300
+
301
+ private isPagePinned(path: string): boolean {
302
+ return this.pinnedPages.some((page) => page.path === path);
303
+ }
304
+
305
+ private togglePinPage(page: TelescopePage): void {
306
+ const pageIndex = this.pinnedPages.findIndex((p) => p.path === page.path);
307
+
308
+ if (pageIndex > -1) {
309
+ // Remove from pinned
310
+ this.pinnedPages.splice(pageIndex, 1);
311
+ } else {
312
+ // Add to pinned with limit enforcement
313
+ if (this.pinnedPages.length >= MAX_PINNED_PAGES) {
314
+ // Remove oldest pinned page to make room
315
+ this.pinnedPages.shift();
316
+ }
317
+ this.pinnedPages.push(page);
318
+ }
319
+
320
+ this.savePinnedPages();
321
+
322
+ // Refresh the UI
323
+ this.renderSearchResults();
324
+ this.renderRecentResults();
325
+ }
326
+
327
+ private togglePinForSelectedItem(): void {
328
+ // Get the currently selected item from the DOM
329
+ const selectedItem = document.querySelector('.telescope__result-item--selected');
330
+
331
+ if (selectedItem && selectedItem.hasAttribute('data-path')) {
332
+ const path = selectedItem.getAttribute('data-path');
333
+ const page =
334
+ this.allPages.find((p) => p.path === path) ||
335
+ this.recentPages.find((p) => p.path === path);
336
+
337
+ if (page) {
338
+ this.togglePinPage(page);
339
+ }
340
+ }
341
+ }
342
+
343
+ private initializeFuse(): void {
344
+ // Only initialize if Fuse.js is available and we have pages
345
+ if (typeof Fuse !== 'undefined' && this.allPages.length > 0) {
346
+ const { fuseOptions } = this.config;
347
+
348
+ const options: IFuseOptions<TelescopePage> = {
349
+ keys: fuseOptions.keys,
350
+ threshold: fuseOptions.threshold,
351
+ includeScore: true,
352
+ ignoreLocation: fuseOptions.ignoreLocation,
353
+ distance: fuseOptions.distance,
354
+ minMatchCharLength: fuseOptions.minMatchCharLength,
355
+ findAllMatches: fuseOptions.findAllMatches,
356
+ useExtendedSearch: true,
357
+ includeMatches: true,
358
+ };
359
+
360
+ this.fuseInstance = new Fuse(this.allPages, options);
361
+ } else {
362
+ console.warn('Fuse.js not available or no pages to index');
363
+ }
364
+ }
365
+
366
+ private debounce<T extends (...args: unknown[]) => void>(
367
+ callback: T,
368
+ wait: number
369
+ ): (event: Event) => void {
370
+ return (event: Event) => {
371
+ if (this.debounceTimeout) {
372
+ clearTimeout(this.debounceTimeout);
373
+ }
374
+ this.debounceTimeout = setTimeout(() => callback.call(this, event), wait);
375
+ };
376
+ }
377
+
378
+ private setupListeners(): void {
379
+ // Create AbortController for cleanup
380
+ this.abortController = new AbortController();
381
+ const { signal } = this.abortController;
382
+
383
+ // Global keyboard shortcut
384
+ document.addEventListener('keydown', this.handleKeyDown, { signal });
385
+
386
+ // Add event listeners to the search input
387
+ if (this.searchInputElement) {
388
+ // Use debounced version of handleSearchInput to prevent excessive searches
389
+ const debouncedSearchInput = this.debounce(this.handleSearchInput, this.config.debounceMs);
390
+ this.searchInputElement.addEventListener('input', debouncedSearchInput, { signal });
391
+ this.searchInputElement.addEventListener('keydown', this.handleSearchKeyDown, { signal });
392
+ }
393
+
394
+ // Use event delegation for dialog interactions - works regardless of when elements exist
395
+ document.addEventListener('click', (event) => {
396
+ const target = event.target as HTMLElement;
397
+
398
+ // Close button clicked
399
+ if (target.closest('#telescope-close-button')) {
400
+ this.close();
401
+ return;
402
+ }
403
+
404
+ // Tab clicked
405
+ const tab = target.closest('.telescope__tab') as HTMLElement;
406
+ if (tab?.dataset.tab) {
407
+ this.switchTab(tab.dataset.tab as 'search' | 'recent');
408
+ return;
409
+ }
410
+
411
+ // Backdrop clicked (click on dialog but outside modal)
412
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
413
+ if (dialog?.open && event.target === dialog) {
414
+ this.close();
415
+ return;
416
+ }
417
+ }, { signal });
418
+ }
419
+
420
+ /**
421
+ * Cleanup method to remove event listeners and timers.
422
+ * Call this when the component is destroyed.
423
+ */
424
+ public destroy(): void {
425
+ // Abort all event listeners
426
+ if (this.abortController) {
427
+ this.abortController.abort();
428
+ this.abortController = null;
429
+ }
430
+
431
+ // Clear debounce timer
432
+ if (this.debounceTimeout) {
433
+ clearTimeout(this.debounceTimeout);
434
+ this.debounceTimeout = null;
435
+ }
436
+ }
437
+
438
+ private handleKeyDown(event: KeyboardEvent): void {
439
+ const { shortcut } = this.config;
440
+
441
+ // Check if the configured shortcut is pressed
442
+ const keyMatches = event.key === shortcut.key;
443
+ const ctrlMatches = shortcut.ctrl ? event.ctrlKey : !event.ctrlKey;
444
+ const metaMatches = shortcut.meta ? event.metaKey : !event.metaKey;
445
+ const shiftMatches = shortcut.shift ? event.shiftKey : !event.shiftKey;
446
+ const altMatches = shortcut.alt ? event.altKey : !event.altKey;
447
+
448
+ // For the default "/" shortcut, allow either Ctrl or Meta (Cmd on Mac)
449
+ const modifierMatches =
450
+ shortcut.ctrl && shortcut.meta
451
+ ? event.ctrlKey || event.metaKey
452
+ : ctrlMatches && metaMatches;
453
+
454
+ if (keyMatches && modifierMatches && shiftMatches && altMatches) {
455
+ event.preventDefault();
456
+ this.open();
457
+ }
458
+
459
+ // Escape to close - check actual dialog state
460
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
461
+ if (event.key === 'Escape' && dialog?.open) {
462
+ event.preventDefault();
463
+ this.close();
464
+ }
465
+ }
466
+
467
+ private handleSearchKeyDown(event: KeyboardEvent): void {
468
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement;
469
+ if (!dialog?.open) return;
470
+
471
+ switch (event.key) {
472
+ case 'Escape':
473
+ event.preventDefault();
474
+ this.close();
475
+ break;
476
+
477
+ case 'ArrowDown':
478
+ event.preventDefault();
479
+ this.isInNavigationMode = true;
480
+ this.navigateResults(1);
481
+ break;
482
+
483
+ case 'ArrowUp':
484
+ event.preventDefault();
485
+ this.isInNavigationMode = true;
486
+ this.navigateResults(-1);
487
+ break;
488
+
489
+ case 'Enter':
490
+ event.preventDefault();
491
+ this.selectCurrentItem();
492
+ break;
493
+
494
+ case ' ':
495
+ // Handle space for bookmarking if input is empty, cursor at start, or navigating results
496
+ if (
497
+ this.searchInputElement &&
498
+ (this.searchInputElement.value === '' ||
499
+ this.searchInputElement.selectionStart === 0 ||
500
+ this.isInNavigationMode)
501
+ ) {
502
+ event.preventDefault();
503
+ this.togglePinForSelectedItem();
504
+ }
505
+ // Otherwise, let the space be typed normally
506
+ break;
507
+
508
+ default:
509
+ break;
510
+ }
511
+ }
512
+
513
+ private navigateResults(direction: number): void {
514
+ const container = this.getActiveResultsContainer();
515
+ if (!container) return;
516
+
517
+ // Count actual rendered items - single source of truth
518
+ const totalItems = container.querySelectorAll('.telescope__result-item').length;
519
+ if (totalItems === 0) return;
520
+
521
+ if (direction > 0) {
522
+ this.selectedIndex = (this.selectedIndex + 1) % totalItems;
523
+ } else {
524
+ this.selectedIndex = (this.selectedIndex - 1 + totalItems) % totalItems;
525
+ }
526
+
527
+ this.updateSelectedResult();
528
+ }
529
+
530
+ private handleSearchInput(event: Event): void {
531
+ this.isInNavigationMode = false;
532
+ const target = event.target as HTMLInputElement;
533
+ this.searchQuery = target.value;
534
+
535
+ if (this.currentTab === 'recent') {
536
+ // Filter recent pages (use validated pages)
537
+ const validatedRecent = this.validateStoredPages(this.recentPages);
538
+ const filteredRecent = validatedRecent.filter(
539
+ (page) =>
540
+ page.title.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
541
+ page.path.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
542
+ (page.description &&
543
+ page.description.toLowerCase().includes(this.searchQuery.toLowerCase()))
544
+ );
545
+ this.filteredPages = filteredRecent;
546
+ this.selectedIndex = 0;
547
+ this.renderRecentResults();
548
+ this.updateSelectedResult();
549
+ } else {
550
+ // Filter all pages
551
+ this.filterPages();
552
+ }
553
+ }
554
+
555
+ private filterPages(): void {
556
+ const query = this.searchQuery.trim();
557
+ const lowerQuery = query.toLowerCase();
558
+
559
+ if (!query) {
560
+ this.filteredPages = [...this.allPages];
561
+ this.searchResultsWithMatches = [];
562
+ } else if (this.fuseInstance) {
563
+ // Fuse.js is available and initialized
564
+ const searchResults = this.fuseInstance.search(query);
565
+
566
+ // Custom sort: exact > prefix > word-start > fuzzy score
567
+ // This prioritizes quick navigation matches over deep fuzzy matches
568
+ searchResults.sort((a, b) => {
569
+ const aTitle = (a.item.title || '').toLowerCase();
570
+ const bTitle = (b.item.title || '').toLowerCase();
571
+
572
+ // Exact title match gets highest priority
573
+ const aExact = aTitle === lowerQuery;
574
+ const bExact = bTitle === lowerQuery;
575
+ if (aExact && !bExact) return -1;
576
+ if (bExact && !aExact) return 1;
577
+
578
+ // Title starts with query is next priority
579
+ const aPrefix = aTitle.startsWith(lowerQuery);
580
+ const bPrefix = bTitle.startsWith(lowerQuery);
581
+ if (aPrefix && !bPrefix) return -1;
582
+ if (bPrefix && !aPrefix) return 1;
583
+
584
+ // Word-start match (e.g., "auth" matches "OAuth Authentication")
585
+ const aWordStart = aTitle.split(/\s+/).some((w) => w.startsWith(lowerQuery));
586
+ const bWordStart = bTitle.split(/\s+/).some((w) => w.startsWith(lowerQuery));
587
+ if (aWordStart && !bWordStart) return -1;
588
+ if (bWordStart && !aWordStart) return 1;
589
+
590
+ // Fall back to Fuse.js score (lower = better)
591
+ return (a.score || 0) - (b.score || 0);
592
+ });
593
+
594
+ // Store results with matches for highlighting
595
+ this.searchResultsWithMatches = searchResults;
596
+
597
+ // Extract just the items after sorting
598
+ this.filteredPages = searchResults.map((result) => result.item);
599
+ } else {
600
+ // Fallback to basic filtering if Fuse.js is not available
601
+ this.filteredPages = this.allPages.filter((page) => {
602
+ const title = (page.title || '').toLowerCase();
603
+ const path = (page.path || '').toLowerCase();
604
+ const description = (page.description || '').toLowerCase();
605
+
606
+ return (
607
+ title.includes(lowerQuery) || path.includes(lowerQuery) || description.includes(lowerQuery)
608
+ );
609
+ });
610
+ }
611
+
612
+ this.selectedIndex = 0;
613
+ this.renderResults();
614
+ this.updateSelectedResult();
615
+
616
+ // Announce result count for screen readers
617
+ const count = this.filteredPages.length;
618
+ const maxResults = this.config.maxResults;
619
+ const hasMore = count > maxResults;
620
+ const announcement = count === 0
621
+ ? 'No results found'
622
+ : hasMore
623
+ ? `Showing ${maxResults} of ${count} results. Refine your search to see more.`
624
+ : `${count} result${count === 1 ? '' : 's'} found`;
625
+ this.announce(announcement);
626
+ }
627
+
628
+ private updateSelectedResult(): void {
629
+ const container = this.getActiveResultsContainer();
630
+ if (!container) return;
631
+
632
+ // Remove selection from all items in the active container
633
+ const items = container.querySelectorAll('.telescope__result-item');
634
+ items.forEach((item) => {
635
+ item.classList.remove('telescope__result-item--selected');
636
+ item.setAttribute('aria-selected', 'false');
637
+ });
638
+
639
+ // Add selection to current item within the active container
640
+ const selectedItem = container.querySelector(`[data-index="${this.selectedIndex}"]`);
641
+ if (selectedItem) {
642
+ selectedItem.classList.add('telescope__result-item--selected');
643
+ selectedItem.setAttribute('aria-selected', 'true');
644
+
645
+ // Update aria-activedescendant on the input
646
+ const itemId = selectedItem.id || `telescope-option-${this.selectedIndex}`;
647
+ selectedItem.id = itemId;
648
+ if (this.searchInputElement) {
649
+ this.searchInputElement.setAttribute('aria-activedescendant', itemId);
650
+ }
651
+
652
+ // Scroll into view if needed
653
+ selectedItem.scrollIntoView({
654
+ behavior: 'smooth',
655
+ block: 'nearest',
656
+ });
657
+ }
658
+ }
659
+
660
+ private getCompletePath(path: string): string {
661
+ // This includes protocol, domain, and port
662
+ const origin = this.currentOrigin || window.location.origin;
663
+
664
+ // Get the base path from the config or use a default
665
+ const basePath = (import.meta.env.BASE_URL || '').replace(/\/$/, '');
666
+
667
+ // Include locale if present (pages.json paths don't include locale prefix)
668
+ const localeSegment = this.currentLocale ? `/${this.currentLocale}` : '';
669
+
670
+ // Ensure path starts with /
671
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
672
+
673
+ return `${origin}${basePath}${localeSegment}${normalizedPath}`;
674
+ }
675
+
676
+ private navigateToPage(partialPath: string): void {
677
+ const page = this.allPages.find((p) => p.path === partialPath);
678
+ if (page) {
679
+ this.saveRecentPage(page);
680
+ }
681
+ this.close();
682
+
683
+ const path = this.getCompletePath(partialPath);
684
+ window.location.href = path;
685
+ }
686
+
687
+ public open(): void {
688
+ // Query dialog fresh (handles multiple instances)
689
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
690
+ if (!dialog || dialog.open) return;
691
+
692
+ this.isOpen = true;
693
+ this.searchQuery = '';
694
+ this.selectedIndex = 0;
695
+ this.filteredPages = [...this.allPages];
696
+
697
+ dialog.showModal();
698
+
699
+ // Disable pointer events until mouse moves to prevent false hover on open
700
+ dialog.classList.add('telescope--pointer-disabled');
701
+ this.hasMouseMovedSinceOpen = false;
702
+ dialog.addEventListener('mousemove', () => {
703
+ this.hasMouseMovedSinceOpen = true;
704
+ dialog.classList.remove('telescope--pointer-disabled');
705
+ }, { once: true });
706
+
707
+ this.switchTab('search');
708
+
709
+ // Render initial content
710
+ this.renderRecentResults();
711
+ this.renderSearchResults();
712
+
713
+ // Ensure first item is selected with proper ARIA
714
+ this.updateSelectedResult();
715
+
716
+ // Focus the search input
717
+ requestAnimationFrame(() => {
718
+ const input = document.getElementById('telescope-search-input') as HTMLInputElement | null;
719
+ input?.focus();
720
+ if (input) {
721
+ input.value = '';
722
+ }
723
+ });
724
+
725
+ this.announce('Search dialog opened');
726
+ }
727
+
728
+ public close(): void {
729
+ // Check actual dialog state, not internal flag (handles multiple instances)
730
+ const dialog = document.getElementById('telescope-dialog') as HTMLDialogElement | null;
731
+ if (!dialog?.open) return;
732
+
733
+ this.isOpen = false;
734
+ dialog.close();
735
+
736
+ // Clear debounce timer to prevent stale callbacks
737
+ if (this.debounceTimeout) {
738
+ clearTimeout(this.debounceTimeout);
739
+ this.debounceTimeout = null;
740
+ }
741
+
742
+ // Clear aria-activedescendant
743
+ const input = document.getElementById('telescope-search-input');
744
+ if (input) {
745
+ input.setAttribute('aria-activedescendant', '');
746
+ }
747
+ }
748
+
749
+ private renderResults(): void {
750
+ if (!this.resultsContainerElement) return;
751
+
752
+ // Clear current results
753
+ this.resultsContainerElement.innerHTML = '';
754
+
755
+ // Use a running index counter for sequential indexing
756
+ let currentIndex = 0;
757
+
758
+ // Use validated pages to prevent showing pages from other sites
759
+ const validatedRecent = this.validateStoredPages(this.recentPages);
760
+
761
+ // Show recent pages if no search query
762
+ if (!this.searchQuery.trim() && validatedRecent.length > 0) {
763
+ this.resultsContainerElement.appendChild(
764
+ this.createSectionHeader('Recently Visited', () => this.clearRecentPages())
765
+ );
766
+
767
+ validatedRecent.forEach((page) => {
768
+ const listItem = this.createResultItem(page, currentIndex);
769
+ this.resultsContainerElement!.appendChild(listItem);
770
+ currentIndex++;
771
+ });
772
+ }
773
+
774
+ if (this.filteredPages.length === 0) {
775
+ // Show no results message
776
+ const noResults = document.createElement('li');
777
+ noResults.className = 'telescope__no-results';
778
+ noResults.setAttribute('role', 'presentation');
779
+ noResults.textContent = 'No pages found matching your search';
780
+ this.resultsContainerElement.appendChild(noResults);
781
+ return;
782
+ }
783
+
784
+ // Limit results to maxResults for performance
785
+ const maxResults = this.config.maxResults;
786
+ const hasMoreResults = this.filteredPages.length > maxResults;
787
+ const displayResults = this.filteredPages.slice(0, maxResults);
788
+
789
+ // Append items directly to the ul (no nested ul)
790
+ displayResults.forEach((page) => {
791
+ const listItem = this.createResultItem(page, currentIndex);
792
+ this.resultsContainerElement!.appendChild(listItem);
793
+ currentIndex++;
794
+ });
795
+
796
+ // Show indicator when results are truncated
797
+ if (hasMoreResults) {
798
+ const indicator = document.createElement('li');
799
+ indicator.className = 'telescope__more-results';
800
+ indicator.setAttribute('role', 'presentation');
801
+ indicator.textContent = `Showing ${maxResults} of ${this.filteredPages.length} results. Refine your search to see more.`;
802
+ this.resultsContainerElement!.appendChild(indicator);
803
+ }
804
+ }
805
+
806
+ private createResultItem(page: TelescopePage, index: number): HTMLLIElement {
807
+ const listItem = document.createElement('li');
808
+ const itemId = `telescope-option-${index}`;
809
+
810
+ listItem.id = itemId;
811
+ listItem.className = `telescope__result-item${index === this.selectedIndex ? ' telescope__result-item--selected' : ''}`;
812
+ listItem.setAttribute('role', 'option');
813
+ listItem.setAttribute('aria-selected', index === this.selectedIndex ? 'true' : 'false');
814
+ listItem.setAttribute('data-index', String(index));
815
+ listItem.setAttribute('data-path', page.path);
816
+
817
+ // Add pin button
818
+ const isPinned = this.isPagePinned(page.path);
819
+ const pinButton = document.createElement('button');
820
+ pinButton.className = `telescope__pin-button${isPinned ? ' telescope__pin-button--pinned' : ''}`;
821
+ pinButton.innerHTML = `<svg aria-hidden="true" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
822
+ <path d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"></path>
823
+ </svg>`;
824
+ pinButton.title = isPinned ? 'Unpin page' : 'Pin page';
825
+ pinButton.setAttribute('aria-label', isPinned ? 'Unpin page' : 'Pin page');
826
+
827
+ // Stop event propagation to prevent navigation and flickering
828
+ pinButton.addEventListener('click', (event) => {
829
+ event.stopPropagation();
830
+ event.preventDefault();
831
+ this.togglePinPage(page);
832
+ });
833
+
834
+ // Prevent hover events from bubbling and causing flickering
835
+ pinButton.addEventListener('mouseenter', (event) => {
836
+ event.stopPropagation();
837
+ });
838
+
839
+ pinButton.addEventListener('mouseleave', (event) => {
840
+ event.stopPropagation();
841
+ });
842
+
843
+ // Single row content
844
+ const contentRow = document.createElement('div');
845
+ contentRow.className = 'telescope__result-content-row';
846
+
847
+ // Get matches for highlighting (only if there's an active search query)
848
+ const matches = this.searchQuery.trim() ? this.getMatchesForPage(page) : undefined;
849
+
850
+ // Title (with highlighting if matches exist)
851
+ const titleDiv = document.createElement('div');
852
+ titleDiv.className = 'telescope__result-title';
853
+ titleDiv.innerHTML = this.highlightMatches(page.title || '', matches, 'title');
854
+
855
+ contentRow.appendChild(titleDiv);
856
+
857
+ // Description (if available, with highlighting if matches exist)
858
+ if (page.description) {
859
+ const descriptionDiv = document.createElement('div');
860
+ descriptionDiv.className = 'telescope__result-description';
861
+ descriptionDiv.innerHTML = this.highlightMatches(page.description, matches, 'description');
862
+ contentRow.appendChild(descriptionDiv);
863
+ }
864
+
865
+ // Tags (if available)
866
+ if (page.tags && page.tags.length > 0) {
867
+ const tagsDiv = document.createElement('div');
868
+ tagsDiv.className = 'telescope__result-tags';
869
+
870
+ page.tags.forEach((tag) => {
871
+ const tagSpan = document.createElement('span');
872
+ tagSpan.className = 'telescope__tag';
873
+ tagSpan.textContent = tag;
874
+ tagsDiv.appendChild(tagSpan);
875
+ });
876
+
877
+ contentRow.appendChild(tagsDiv);
878
+ }
879
+
880
+ // Add content row to list item
881
+ listItem.appendChild(contentRow);
882
+ listItem.appendChild(pinButton);
883
+
884
+ // Add event listeners
885
+ listItem.addEventListener('click', () => {
886
+ this.navigateToPage(page.path);
887
+ });
888
+
889
+ listItem.addEventListener('mouseenter', () => {
890
+ // Ignore mouseenter until user has actually moved the mouse
891
+ // This prevents false selection when modal opens under cursor
892
+ if (!this.hasMouseMovedSinceOpen) return;
893
+
894
+ this.selectedIndex = index;
895
+ this.updateSelectedResult();
896
+ });
897
+
898
+ return listItem;
899
+ }
900
+
901
+ private switchTab(tabName: 'search' | 'recent'): void {
902
+ this.currentTab = tabName;
903
+ this.selectedIndex = 0;
904
+
905
+ // Reset search state when switching tabs
906
+ this.searchQuery = '';
907
+ if (this.searchInputElement) {
908
+ this.searchInputElement.value = '';
909
+ }
910
+ this.filteredPages = [...this.allPages];
911
+
912
+ // Update tab buttons
913
+ this.tabs.forEach((tab) => {
914
+ const isActive = tab.dataset.tab === tabName;
915
+ tab.classList.toggle('telescope__tab--active', isActive);
916
+ });
917
+
918
+ // Update sections
919
+ document.querySelectorAll('.telescope__section').forEach((section) => {
920
+ section.classList.toggle('telescope__section--active', section.id === `telescope-${tabName}-section`);
921
+ });
922
+
923
+ // Update search input placeholder
924
+ if (this.searchInputElement) {
925
+ this.searchInputElement.placeholder =
926
+ tabName === 'recent' ? 'Filter recent pages...' : 'Search pages...';
927
+ }
928
+
929
+ // Render appropriate content
930
+ if (tabName === 'recent') {
931
+ this.renderRecentResults();
932
+ } else {
933
+ this.renderSearchResults();
934
+ }
935
+
936
+ // Update selection after rendering
937
+ this.updateSelectedResult();
938
+ }
939
+
940
+ private renderRecentResults(): void {
941
+ if (!this.recentResultsContainerElement) return;
942
+
943
+ this.recentResultsContainerElement.innerHTML = '';
944
+
945
+ // Use filtered pages if there's a search query, otherwise show all validated recent pages
946
+ const pagesToRender = this.searchQuery.trim()
947
+ ? this.filteredPages
948
+ : this.validateStoredPages(this.recentPages);
949
+
950
+ if (pagesToRender.length === 0) {
951
+ const noResults = document.createElement('li');
952
+ noResults.className = 'telescope__no-results';
953
+ noResults.setAttribute('role', 'presentation');
954
+ noResults.textContent = this.searchQuery.trim()
955
+ ? 'No pages found matching your search'
956
+ : 'No recently visited pages';
957
+ this.recentResultsContainerElement.appendChild(noResults);
958
+ return;
959
+ }
960
+
961
+ // Append items directly to the ul (no nested ul)
962
+ pagesToRender.forEach((page, index) => {
963
+ const listItem = this.createResultItem(page, index);
964
+ this.recentResultsContainerElement!.appendChild(listItem);
965
+ });
966
+ }
967
+
968
+ private createSectionHeader(text: string, onClear?: () => void): HTMLLIElement {
969
+ const header = document.createElement('li');
970
+ header.className = 'telescope__section-separator';
971
+ header.setAttribute('role', 'presentation');
972
+
973
+ const title = document.createElement('span');
974
+ title.textContent = text;
975
+ header.appendChild(title);
976
+
977
+ if (onClear) {
978
+ const clearBtn = document.createElement('button');
979
+ clearBtn.className = 'telescope__clear-btn';
980
+ clearBtn.textContent = 'Clear';
981
+ clearBtn.setAttribute('aria-label', `Clear ${text.toLowerCase()}`);
982
+ clearBtn.addEventListener('click', (e) => {
983
+ e.stopPropagation();
984
+ onClear();
985
+ });
986
+ header.appendChild(clearBtn);
987
+ }
988
+
989
+ return header;
990
+ }
991
+
992
+ private renderSearchResults(): void {
993
+ if (!this.resultsContainerElement) return;
994
+
995
+ this.resultsContainerElement.innerHTML = '';
996
+
997
+ // Use validated pages to prevent showing pages from other sites
998
+ const validatedPinned = this.validateStoredPages(this.pinnedPages);
999
+ const validatedRecent = this.validateStoredPages(this.recentPages);
1000
+
1001
+ // Show pinned pages section
1002
+ if (validatedPinned.length > 0) {
1003
+ this.resultsContainerElement.appendChild(
1004
+ this.createSectionHeader('Pinned Pages', () => this.clearPinnedPages())
1005
+ );
1006
+
1007
+ validatedPinned.forEach((page, index) => {
1008
+ const listItem = this.createResultItem(page, index);
1009
+ this.resultsContainerElement!.appendChild(listItem);
1010
+ });
1011
+ }
1012
+
1013
+ // Show recent pages section
1014
+ if (validatedRecent.length > 0) {
1015
+ this.resultsContainerElement.appendChild(
1016
+ this.createSectionHeader('Recently Visited', () => this.clearRecentPages())
1017
+ );
1018
+
1019
+ // Get recent pages that aren't pinned
1020
+ const nonPinnedRecent = validatedRecent.filter((page) => !this.isPagePinned(page.path));
1021
+ const pinnedCount = validatedPinned.length;
1022
+
1023
+ nonPinnedRecent.slice(0, this.config.recentPagesCount).forEach((page, index) => {
1024
+ const realIndex = pinnedCount + index;
1025
+ const listItem = this.createResultItem(page, realIndex);
1026
+ this.resultsContainerElement!.appendChild(listItem);
1027
+ });
1028
+ }
1029
+
1030
+ // Filter out pinned and recent pages from search results to avoid duplicates
1031
+ const pinnedPaths = validatedPinned.map((p) => p.path);
1032
+ const recentPaths = validatedRecent.map((p) => p.path);
1033
+ const filteredResults = this.filteredPages.filter(
1034
+ (page) => !pinnedPaths.includes(page.path) && !recentPaths.includes(page.path)
1035
+ );
1036
+
1037
+ // Add search results separator if we have other sections
1038
+ if ((validatedRecent.length > 0 || validatedPinned.length > 0) && filteredResults.length > 0) {
1039
+ this.resultsContainerElement.appendChild(this.createSectionHeader('Search Results'));
1040
+ }
1041
+
1042
+ // Show search results or no results message
1043
+ if (filteredResults.length === 0 && validatedPinned.length === 0 && validatedRecent.length === 0) {
1044
+ const noResults = document.createElement('li');
1045
+ noResults.className = 'telescope__no-results';
1046
+ noResults.setAttribute('role', 'presentation');
1047
+ noResults.textContent = 'No pages found matching your search';
1048
+ this.resultsContainerElement.appendChild(noResults);
1049
+ return;
1050
+ }
1051
+
1052
+ const pinnedCount = validatedPinned.length;
1053
+ const recentCount = Math.min(
1054
+ validatedRecent.filter((p) => !this.isPagePinned(p.path)).length,
1055
+ this.config.recentPagesCount
1056
+ );
1057
+
1058
+ // Limit results to maxResults for performance
1059
+ const maxResults = this.config.maxResults;
1060
+ const hasMoreResults = filteredResults.length > maxResults;
1061
+ const displayResults = filteredResults.slice(0, maxResults);
1062
+
1063
+ displayResults.forEach((page, index) => {
1064
+ const realIndex = pinnedCount + recentCount + index;
1065
+ const listItem = this.createResultItem(page, realIndex);
1066
+ this.resultsContainerElement!.appendChild(listItem);
1067
+ });
1068
+
1069
+ // Show indicator when results are truncated
1070
+ if (hasMoreResults) {
1071
+ const indicator = document.createElement('li');
1072
+ indicator.className = 'telescope__more-results';
1073
+ indicator.setAttribute('role', 'presentation');
1074
+ indicator.textContent = `Showing ${maxResults} of ${filteredResults.length} results. Refine your search to see more.`;
1075
+ this.resultsContainerElement!.appendChild(indicator);
1076
+ }
1077
+ }
1078
+
1079
+ private selectCurrentItem(): void {
1080
+ // Get the currently selected item directly from the DOM
1081
+ const selectedItem = document.querySelector('.telescope__result-item--selected');
1082
+
1083
+ if (selectedItem && selectedItem.hasAttribute('data-path')) {
1084
+ // Navigate to the page using the path stored in the data-path attribute
1085
+ const path = selectedItem.getAttribute('data-path');
1086
+ if (path) {
1087
+ this.navigateToPage(path);
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ private announce(message: string): void {
1093
+ if (this.liveRegion) {
1094
+ // Clear first, then use requestAnimationFrame for better screen reader timing
1095
+ this.liveRegion.textContent = '';
1096
+ requestAnimationFrame(() => {
1097
+ if (this.liveRegion) {
1098
+ this.liveRegion.textContent = message;
1099
+ }
1100
+ });
1101
+ }
1102
+ }
1103
+ }