docyard 0.6.0 → 0.7.0

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/lib/docyard/build/static_generator.rb +2 -43
  4. data/lib/docyard/builder.rb +14 -4
  5. data/lib/docyard/cli.rb +6 -3
  6. data/lib/docyard/components/aliases.rb +29 -0
  7. data/lib/docyard/components/processors/callout_processor.rb +124 -0
  8. data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +106 -0
  9. data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +79 -0
  10. data/lib/docyard/components/processors/code_block_options_preprocessor.rb +78 -0
  11. data/lib/docyard/components/processors/code_block_processor.rb +175 -0
  12. data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +127 -0
  13. data/lib/docyard/components/processors/heading_anchor_processor.rb +39 -0
  14. data/lib/docyard/components/processors/icon_processor.rb +53 -0
  15. data/lib/docyard/components/processors/table_of_contents_processor.rb +68 -0
  16. data/lib/docyard/components/processors/table_wrapper_processor.rb +22 -0
  17. data/lib/docyard/components/processors/tabs_processor.rb +48 -0
  18. data/lib/docyard/components/support/code_block/feature_extractor.rb +117 -0
  19. data/lib/docyard/components/support/code_block/icon_detector.rb +44 -0
  20. data/lib/docyard/components/support/code_block/line_parser.rb +84 -0
  21. data/lib/docyard/components/support/code_block/line_wrapper.rb +50 -0
  22. data/lib/docyard/components/support/code_block/patterns.rb +55 -0
  23. data/lib/docyard/components/support/code_detector.rb +61 -0
  24. data/lib/docyard/components/support/tabs/icon_detector.rb +62 -0
  25. data/lib/docyard/components/support/tabs/parser.rb +195 -0
  26. data/lib/docyard/components/support/tabs/range_finder.rb +46 -0
  27. data/lib/docyard/config/branding_resolver.rb +74 -0
  28. data/lib/docyard/{constants.rb → config/constants.rb} +1 -0
  29. data/lib/docyard/config.rb +10 -1
  30. data/lib/docyard/{prev_next_builder.rb → navigation/prev_next_builder.rb} +2 -2
  31. data/lib/docyard/{sidebar → navigation/sidebar}/renderer.rb +3 -14
  32. data/lib/docyard/{sidebar → navigation/sidebar}/tree_builder.rb +9 -2
  33. data/lib/docyard/{sidebar_builder.rb → navigation/sidebar_builder.rb} +3 -15
  34. data/lib/docyard/{icons → rendering/icons}/phosphor.rb +4 -1
  35. data/lib/docyard/{markdown.rb → rendering/markdown.rb} +14 -13
  36. data/lib/docyard/{renderer.rb → rendering/renderer.rb} +20 -17
  37. data/lib/docyard/search/build_indexer.rb +74 -0
  38. data/lib/docyard/search/dev_indexer.rb +110 -0
  39. data/lib/docyard/search/pagefind_support.rb +31 -0
  40. data/lib/docyard/{asset_handler.rb → server/asset_handler.rb} +1 -1
  41. data/lib/docyard/{server.rb → server/dev_server.rb} +32 -9
  42. data/lib/docyard/{preview_server.rb → server/preview_server.rb} +1 -1
  43. data/lib/docyard/{rack_application.rb → server/rack_application.rb} +52 -49
  44. data/lib/docyard/server/resolution_result.rb +29 -0
  45. data/lib/docyard/{router.rb → server/router.rb} +4 -4
  46. data/lib/docyard/templates/assets/css/components/search.css +549 -0
  47. data/lib/docyard/templates/assets/css/layout.css +15 -1
  48. data/lib/docyard/templates/assets/js/components/search.js +685 -0
  49. data/lib/docyard/templates/layouts/default.html.erb +14 -2
  50. data/lib/docyard/templates/partials/_code_block.html.erb +1 -1
  51. data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -1
  52. data/lib/docyard/templates/partials/_prev_next.html.erb +1 -1
  53. data/lib/docyard/templates/partials/_search_modal.html.erb +45 -0
  54. data/lib/docyard/templates/partials/_search_trigger.html.erb +22 -0
  55. data/lib/docyard/utils/html_helpers.rb +14 -0
  56. data/lib/docyard/utils/path_resolver.rb +2 -1
  57. data/lib/docyard/utils/url_helpers.rb +20 -0
  58. data/lib/docyard/version.rb +1 -1
  59. data/lib/docyard.rb +22 -15
  60. metadata +57 -46
  61. data/lib/docyard/components/callout_processor.rb +0 -121
  62. data/lib/docyard/components/code_block_diff_preprocessor.rb +0 -104
  63. data/lib/docyard/components/code_block_feature_extractor.rb +0 -113
  64. data/lib/docyard/components/code_block_focus_preprocessor.rb +0 -77
  65. data/lib/docyard/components/code_block_icon_detector.rb +0 -40
  66. data/lib/docyard/components/code_block_line_wrapper.rb +0 -46
  67. data/lib/docyard/components/code_block_options_preprocessor.rb +0 -76
  68. data/lib/docyard/components/code_block_patterns.rb +0 -51
  69. data/lib/docyard/components/code_block_processor.rb +0 -176
  70. data/lib/docyard/components/code_detector.rb +0 -59
  71. data/lib/docyard/components/code_line_parser.rb +0 -80
  72. data/lib/docyard/components/code_snippet_import_preprocessor.rb +0 -125
  73. data/lib/docyard/components/heading_anchor_processor.rb +0 -34
  74. data/lib/docyard/components/icon_detector.rb +0 -57
  75. data/lib/docyard/components/icon_processor.rb +0 -51
  76. data/lib/docyard/components/table_of_contents_processor.rb +0 -64
  77. data/lib/docyard/components/table_wrapper_processor.rb +0 -18
  78. data/lib/docyard/components/tabs_parser.rb +0 -191
  79. data/lib/docyard/components/tabs_processor.rb +0 -44
  80. data/lib/docyard/components/tabs_range_finder.rb +0 -42
  81. data/lib/docyard/routing/resolution_result.rb +0 -31
  82. /data/lib/docyard/{sidebar → navigation/sidebar}/config_parser.rb +0 -0
  83. /data/lib/docyard/{sidebar → navigation/sidebar}/file_system_scanner.rb +0 -0
  84. /data/lib/docyard/{sidebar → navigation/sidebar}/item.rb +0 -0
  85. /data/lib/docyard/{sidebar → navigation/sidebar}/title_extractor.rb +0 -0
  86. /data/lib/docyard/{icons → rendering/icons}/LICENSE.phosphor +0 -0
  87. /data/lib/docyard/{icons → rendering/icons}/file_types.rb +0 -0
  88. /data/lib/docyard/{icons.rb → rendering/icons.rb} +0 -0
  89. /data/lib/docyard/{language_mapping.rb → rendering/language_mapping.rb} +0 -0
  90. /data/lib/docyard/{file_watcher.rb → server/file_watcher.rb} +0 -0
  91. /data/lib/docyard/{errors.rb → utils/errors.rb} +0 -0
  92. /data/lib/docyard/{logging.rb → utils/logging.rb} +0 -0
@@ -0,0 +1,685 @@
1
+ /**
2
+ * SearchManager - Handles search functionality with Pagefind integration
3
+ *
4
+ * Features:
5
+ * - Cmd+K / Ctrl+K keyboard shortcut
6
+ * - Lazy loading of Pagefind
7
+ * - Keyboard navigation in results
8
+ * - Debounced search input
9
+ *
10
+ * @class SearchManager
11
+ */
12
+ class SearchManager {
13
+ constructor() {
14
+ this.modal = document.querySelector('[data-search-modal]');
15
+ this.trigger = document.querySelector('[data-search-trigger]');
16
+ this.backdrop = document.querySelector('[data-search-backdrop]');
17
+ this.input = document.querySelector('[data-search-input]');
18
+ this.clearButton = document.querySelector('[data-search-clear]');
19
+ this.closeButton = document.querySelector('[data-search-close]');
20
+ this.body = document.querySelector('[data-search-body]');
21
+ this.resultsContainer = document.querySelector('[data-search-results]');
22
+ this.loadingState = document.querySelector('[data-search-loading]');
23
+ this.emptyState = document.querySelector('[data-search-empty]');
24
+
25
+ if (!this.modal) return;
26
+
27
+ this.pagefind = null;
28
+ this.isOpen = false;
29
+ this.selectedIndex = -1;
30
+ this.results = [];
31
+ this.searchTimeout = null;
32
+ this.DEBOUNCE_DELAY = 150;
33
+ this.RESULTS_PER_PAGE = 6;
34
+
35
+ // State for "load more" functionality
36
+ this.allSearchResults = [];
37
+ this.displayedCount = 0;
38
+ this.currentQuery = '';
39
+ this.groupedResults = [];
40
+
41
+ this.handleKeyDown = this.handleKeyDown.bind(this);
42
+ this.handleInput = this.handleInput.bind(this);
43
+ this.handleResultClick = this.handleResultClick.bind(this);
44
+ this.handleLoadMore = this.handleLoadMore.bind(this);
45
+
46
+ this.init();
47
+ }
48
+
49
+ init() {
50
+ this.attachEventListeners();
51
+ this.updateShortcutHint();
52
+ }
53
+
54
+ attachEventListeners() {
55
+ // Global keyboard shortcut
56
+ document.addEventListener('keydown', this.handleKeyDown);
57
+
58
+ // Trigger button
59
+ if (this.trigger) {
60
+ this.trigger.addEventListener('click', () => this.open());
61
+ }
62
+
63
+ // Backdrop click to close
64
+ if (this.backdrop) {
65
+ this.backdrop.addEventListener('click', () => this.close());
66
+ }
67
+
68
+ // Close button
69
+ if (this.closeButton) {
70
+ this.closeButton.addEventListener('click', () => this.close());
71
+ }
72
+
73
+ // Clear button
74
+ if (this.clearButton) {
75
+ this.clearButton.addEventListener('click', () => this.clearSearch());
76
+ }
77
+
78
+ // Search input
79
+ if (this.input) {
80
+ this.input.addEventListener('input', this.handleInput);
81
+ this.input.addEventListener('keydown', (e) => this.handleInputKeyDown(e));
82
+ }
83
+
84
+ // Results click delegation
85
+ if (this.resultsContainer) {
86
+ this.resultsContainer.addEventListener('click', this.handleResultClick);
87
+ }
88
+ }
89
+
90
+ updateShortcutHint() {
91
+ const shortcut = document.querySelector('[data-search-shortcut]');
92
+ if (shortcut) {
93
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ||
94
+ navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
95
+ if (!isMac) {
96
+ shortcut.setAttribute('data-os', 'windows');
97
+ }
98
+ }
99
+ }
100
+
101
+ handleKeyDown(event) {
102
+ // Cmd+K or Ctrl+K to open
103
+ if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
104
+ event.preventDefault();
105
+ this.toggle();
106
+ return;
107
+ }
108
+
109
+ // Escape to close
110
+ if (event.key === 'Escape' && this.isOpen) {
111
+ event.preventDefault();
112
+ this.close();
113
+ return;
114
+ }
115
+
116
+ // Forward slash to focus search (when not in an input)
117
+ if (event.key === '/' && !this.isOpen && !this.isInputFocused()) {
118
+ event.preventDefault();
119
+ this.open();
120
+ }
121
+ }
122
+
123
+ handleInputKeyDown(event) {
124
+ switch (event.key) {
125
+ case 'ArrowDown':
126
+ event.preventDefault();
127
+ this.selectNext();
128
+ break;
129
+ case 'ArrowUp':
130
+ event.preventDefault();
131
+ this.selectPrevious();
132
+ break;
133
+ case 'Enter':
134
+ event.preventDefault();
135
+ this.navigateToSelected();
136
+ break;
137
+ }
138
+ }
139
+
140
+ handleInput(event) {
141
+ const query = event.target.value.trim();
142
+
143
+ // Update clear button visibility
144
+ if (this.clearButton) {
145
+ this.clearButton.hidden = query.length === 0;
146
+ }
147
+
148
+ // Debounce search
149
+ if (this.searchTimeout) {
150
+ clearTimeout(this.searchTimeout);
151
+ }
152
+
153
+ if (query.length === 0) {
154
+ this.hideBody();
155
+ return;
156
+ }
157
+
158
+ this.searchTimeout = setTimeout(() => {
159
+ this.search(query);
160
+ }, this.DEBOUNCE_DELAY);
161
+ }
162
+
163
+ handleResultClick(event) {
164
+ const resultElement = event.target.closest('.search-result');
165
+ if (resultElement) {
166
+ const url = resultElement.getAttribute('href');
167
+ if (url) {
168
+ this.close();
169
+ window.location.href = url;
170
+ }
171
+ }
172
+ }
173
+
174
+ isInputFocused() {
175
+ const activeElement = document.activeElement;
176
+ return activeElement && (
177
+ activeElement.tagName === 'INPUT' ||
178
+ activeElement.tagName === 'TEXTAREA' ||
179
+ activeElement.isContentEditable
180
+ );
181
+ }
182
+
183
+ toggle() {
184
+ if (this.isOpen) {
185
+ this.close();
186
+ } else {
187
+ this.open();
188
+ }
189
+ }
190
+
191
+ async open() {
192
+ if (this.isOpen) return;
193
+
194
+ this.isOpen = true;
195
+ this.modal.hidden = false;
196
+ document.body.style.overflow = 'hidden';
197
+
198
+ // Trigger animation on next frame
199
+ requestAnimationFrame(() => {
200
+ this.modal.classList.add('is-open');
201
+ // Double rAF ensures focus works after CSS transition starts
202
+ requestAnimationFrame(() => {
203
+ if (this.input) {
204
+ this.input.focus();
205
+ }
206
+ });
207
+ });
208
+
209
+ // Initialize Pagefind if not already done
210
+ if (!this.pagefind) {
211
+ await this.initPagefind();
212
+ }
213
+ }
214
+
215
+ close() {
216
+ if (!this.isOpen) return;
217
+
218
+ this.isOpen = false;
219
+ this.modal.classList.remove('is-open');
220
+ document.body.style.overflow = '';
221
+ this.selectedIndex = -1;
222
+
223
+ // Hide modal after animation completes
224
+ setTimeout(() => {
225
+ if (!this.isOpen) {
226
+ this.modal.hidden = true;
227
+ // Reset body visibility for next open
228
+ if (this.body) {
229
+ this.body.hidden = true;
230
+ }
231
+ }
232
+ }, 200);
233
+
234
+ // Return focus to trigger
235
+ if (this.trigger) {
236
+ this.trigger.focus();
237
+ }
238
+ }
239
+
240
+ clearSearch() {
241
+ if (this.input) {
242
+ this.input.value = '';
243
+ this.input.focus();
244
+ }
245
+ if (this.clearButton) {
246
+ this.clearButton.hidden = true;
247
+ }
248
+ this.hideBody();
249
+ }
250
+
251
+ async initPagefind() {
252
+ try {
253
+ this.pagefind = await import('/pagefind/pagefind.js');
254
+ await this.pagefind.init();
255
+ } catch (error) {
256
+ console.warn('Pagefind not available:', error);
257
+ this.showErrorState('Search is not available. Run "docyard build" to generate the search index.');
258
+ }
259
+ }
260
+
261
+ async search(query) {
262
+ if (!this.pagefind) {
263
+ await this.initPagefind();
264
+ if (!this.pagefind) return;
265
+ }
266
+
267
+ this.showLoadingState();
268
+
269
+ try {
270
+ const searchResults = await this.pagefind.search(query);
271
+
272
+ if (searchResults.results.length === 0) {
273
+ this.showEmptyState(query);
274
+ return;
275
+ }
276
+
277
+ // Store state for "load more"
278
+ this.allSearchResults = searchResults.results;
279
+ this.currentQuery = query;
280
+ this.displayedCount = 0;
281
+ this.groupedResults = [];
282
+
283
+ // Load initial batch
284
+ await this.loadMoreResults();
285
+ } catch (error) {
286
+ console.error('Search error:', error);
287
+ this.showErrorState('An error occurred while searching.');
288
+ }
289
+ }
290
+
291
+ async loadMoreResults() {
292
+ const startIndex = this.displayedCount;
293
+ const endIndex = Math.min(startIndex + this.RESULTS_PER_PAGE, this.allSearchResults.length);
294
+
295
+ if (startIndex >= this.allSearchResults.length) return;
296
+
297
+ // Load the next batch of results
298
+ const resultsData = await Promise.all(
299
+ this.allSearchResults.slice(startIndex, endIndex).map(r => r.data())
300
+ );
301
+
302
+ // Group results by page with sections nested
303
+ const newGrouped = this.groupResults(resultsData);
304
+ this.groupedResults = [...this.groupedResults, ...newGrouped];
305
+ this.displayedCount = endIndex;
306
+
307
+ // Flatten for keyboard navigation
308
+ this.results = this.flattenForNavigation(this.groupedResults);
309
+ this.renderGroupedResults(this.groupedResults, this.currentQuery, this.allSearchResults.length);
310
+ }
311
+
312
+ handleLoadMore(event) {
313
+ event.preventDefault();
314
+ this.loadMoreResults();
315
+ }
316
+
317
+ groupResults(resultsData) {
318
+ const grouped = [];
319
+
320
+ for (const result of resultsData) {
321
+ const pageTitle = result.meta?.title || this.extractTitleFromUrl(result.url);
322
+
323
+ // Get sub-results (sections) for this page
324
+ // Filter out sections with same title as page (H1 heading duplicates)
325
+ const subResults = (result.sub_results || [])
326
+ .filter(sub => {
327
+ if (sub.url === result.url) return false;
328
+ if (!sub.title) return false;
329
+ const cleanedTitle = this.cleanSectionTitle(sub.title);
330
+ // Skip if section title matches page title
331
+ if (cleanedTitle.toLowerCase() === pageTitle.toLowerCase()) return false;
332
+ return true;
333
+ })
334
+ .slice(0, 3)
335
+ .map(sub => ({
336
+ url: sub.url,
337
+ title: this.cleanSectionTitle(sub.title),
338
+ excerpt: sub.excerpt || '',
339
+ type: 'section'
340
+ }));
341
+
342
+ grouped.push({
343
+ url: result.url,
344
+ title: pageTitle,
345
+ excerpt: result.excerpt || '',
346
+ type: 'page',
347
+ sections: subResults
348
+ });
349
+ }
350
+
351
+ return grouped;
352
+ }
353
+
354
+ cleanSectionTitle(title) {
355
+ // Remove trailing # that Pagefind sometimes includes
356
+ return title.replace(/#$/, '').trim();
357
+ }
358
+
359
+ flattenForNavigation(grouped) {
360
+ const flat = [];
361
+ for (const page of grouped) {
362
+ flat.push({ url: page.url, title: page.title });
363
+ for (const section of page.sections) {
364
+ flat.push({ url: section.url, title: section.title });
365
+ }
366
+ }
367
+ return flat;
368
+ }
369
+
370
+ renderGroupedResults(grouped, query, totalResults) {
371
+ this.hideAllStates();
372
+ if (this.body) {
373
+ this.body.hidden = false;
374
+ }
375
+ this.resultsContainer.hidden = false;
376
+ this.selectedIndex = 0;
377
+
378
+ let navIndex = 0;
379
+ const resultsHtml = grouped.map(page => {
380
+ const pageIndex = navIndex++;
381
+ const isPageSelected = pageIndex === 0;
382
+
383
+ const sectionsHtml = page.sections.map(section => {
384
+ const sectionIndex = navIndex++;
385
+ const isSectionSelected = sectionIndex === 0;
386
+ return this.renderSectionResult(section, sectionIndex, isSectionSelected, query);
387
+ }).join('');
388
+
389
+ return this.renderPageResult(page, pageIndex, isPageSelected, sectionsHtml, query);
390
+ }).join('');
391
+
392
+ // Add "View more results" link if there are more results
393
+ const hasMore = this.displayedCount < totalResults;
394
+ const loadMoreHtml = hasMore ? `
395
+ <li class="search-load-more">
396
+ <button type="button" class="search-load-more-btn" data-search-load-more>
397
+ View more results
398
+ </button>
399
+ </li>
400
+ ` : '';
401
+
402
+ this.resultsContainer.innerHTML = resultsHtml + loadMoreHtml;
403
+
404
+ // Attach event listener to "View more" button
405
+ const loadMoreBtn = this.resultsContainer.querySelector('[data-search-load-more]');
406
+ if (loadMoreBtn) {
407
+ loadMoreBtn.addEventListener('click', this.handleLoadMore);
408
+ }
409
+ }
410
+
411
+ renderPageResult(page, index, isSelected, sectionsHtml, query) {
412
+ // Article icon (Phosphor)
413
+ const pageIcon = `<svg class="search-result-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
414
+ <path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200ZM184,96a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,96Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,128Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,160Z"></path>
415
+ </svg>`;
416
+
417
+ const titleHtml = this.highlightTitle(page.title, query);
418
+ const excerptHtml = page.excerpt ? `<span class="search-result-excerpt">${this.highlightQuery(page.excerpt, query, page.title)}</span>` : '';
419
+
420
+ return `
421
+ <li class="search-result-group">
422
+ <a href="${page.url}" class="search-result search-result-page" role="option" aria-selected="${isSelected}" data-index="${index}">
423
+ ${pageIcon}
424
+ <div class="search-result-content">
425
+ <span class="search-result-title">${titleHtml}</span>
426
+ ${excerptHtml}
427
+ </div>
428
+ </a>
429
+ ${sectionsHtml ? `<ul class="search-result-sections">${sectionsHtml}</ul>` : ''}
430
+ </li>
431
+ `;
432
+ }
433
+
434
+ renderSectionResult(section, index, isSelected, query) {
435
+ const hashIcon = `<svg class="search-result-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
436
+ <path d="M224,88H175.4l8.47-46.57a8,8,0,0,0-15.74-2.86l-9,49.43H111.4l8.47-46.57a8,8,0,0,0-15.74-2.86L95.14,88H48a8,8,0,0,0,0,16H92.23L83.5,152H32a8,8,0,0,0,0,16H80.6l-8.47,46.57a8,8,0,0,0,6.44,9.3A7.79,7.79,0,0,0,80,224a8,8,0,0,0,7.86-6.57l9-49.43H144.6l-8.47,46.57a8,8,0,0,0,6.44,9.3A7.79,7.79,0,0,0,144,224a8,8,0,0,0,7.86-6.57l9-49.43H208a8,8,0,0,0,0-16H163.77l8.73-48H224a8,8,0,0,0,0-16Zm-76.5,64H99.77l8.73-48h47.73Z"></path>
437
+ </svg>`;
438
+
439
+ const titleHtml = this.highlightTitle(section.title, query);
440
+ const excerptHtml = section.excerpt ? `<span class="search-result-excerpt">${this.highlightQuery(section.excerpt, query, section.title)}</span>` : '';
441
+
442
+ return `
443
+ <li class="search-result-section-item">
444
+ <a href="${section.url}" class="search-result search-result-section" role="option" aria-selected="${isSelected}" data-index="${index}">
445
+ <span class="search-result-tree-line"></span>
446
+ ${hashIcon}
447
+ <div class="search-result-content">
448
+ <span class="search-result-title">${titleHtml}</span>
449
+ ${excerptHtml}
450
+ </div>
451
+ </a>
452
+ </li>
453
+ `;
454
+ }
455
+
456
+ extractTitleFromUrl(url) {
457
+ const path = url.replace(/\/$/, '').split('/').pop() || 'Home';
458
+ return path
459
+ .replace(/-/g, ' ')
460
+ .replace(/\b\w/g, c => c.toUpperCase());
461
+ }
462
+
463
+ escapeHtml(text) {
464
+ const div = document.createElement('div');
465
+ div.textContent = text;
466
+ return div.innerHTML;
467
+ }
468
+
469
+ highlightTitle(title, query) {
470
+ if (!query || !title) return this.escapeHtml(title);
471
+
472
+ const escaped = this.escapeHtml(title);
473
+ const terms = query.trim().split(/\s+/).filter(t => t.length > 1);
474
+ if (terms.length === 0) return escaped;
475
+
476
+ // Match exact search term only (like Stripe does)
477
+ const regex = new RegExp(`(${terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
478
+ .join('|')})`, 'gi');
479
+ return escaped.replace(regex, '<mark class="search-title-highlight">$1</mark>');
480
+ }
481
+
482
+ highlightQuery(text, query, title = '') {
483
+ if (!query || !text) return this.escapeHtml(text);
484
+
485
+ // Decode HTML entities first (Pagefind may return encoded HTML)
486
+ let cleanText = this.decodeHtmlEntities(text);
487
+
488
+ // Strip all HTML tags
489
+ cleanText = cleanText.replace(/<[^>]*>/g, '');
490
+
491
+ // Remove the title if it appears at the start of the excerpt (Pagefind often includes it)
492
+ if (title) {
493
+ // Remove "Title#" or "Title:" patterns at the start
494
+ const titlePattern = new RegExp(`^${title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[#:]?\\s*`, 'i');
495
+ cleanText = cleanText.replace(titlePattern, '');
496
+ }
497
+
498
+ // Clean markdown and special characters
499
+ cleanText = this.cleanMarkdown(cleanText);
500
+
501
+ // Escape for safe HTML
502
+ const escaped = this.escapeHtml(cleanText);
503
+
504
+ // Highlight exact search term only (like Stripe does)
505
+ const terms = query.trim().split(/\s+/).filter(t => t.length > 1);
506
+ if (terms.length === 0) return escaped;
507
+
508
+ // Match exact search term only
509
+ const regex = new RegExp(`(${terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
510
+ .join('|')})`, 'gi');
511
+ return escaped.replace(regex, '<mark>$1</mark>');
512
+ }
513
+
514
+ decodeHtmlEntities(text) {
515
+ const textarea = document.createElement('textarea');
516
+ textarea.innerHTML = text;
517
+ return textarea.value;
518
+ }
519
+
520
+ cleanMarkdown(text) {
521
+ return text
522
+ // Remove code block markers with optional language/title (```ruby, ```js title="foo")
523
+ .replace(/```\w*(?:\s+[^`\n]*)?\n?/g, '')
524
+ .replace(/```/g, '')
525
+ // Remove Kramdown/Jekyll directives like {:/nomarkdown}, {::nomarkdown}, {:.class}
526
+ .replace(/\{:?:?[^}]*\}/g, '')
527
+ // Remove heading anchors like "Dark Mode#" or "Styling#" (word followed by #)
528
+ .replace(/(\w)#(?=\s|$|[A-Z])/g, '$1')
529
+ // Remove markdown bold/italic
530
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
531
+ .replace(/\*([^*]+)\*/g, '$1')
532
+ .replace(/__([^_]+)__/g, '$1')
533
+ .replace(/_([^_]+)_/g, '$1')
534
+ // Remove markdown links [text](url)
535
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
536
+ // Remove inline code backticks
537
+ .replace(/`([^`]+)`/g, '$1')
538
+ // Remove standalone backticks
539
+ .replace(/`/g, '')
540
+ // Remove heading markers
541
+ .replace(/^#+\s*/gm, '')
542
+ // Remove title="..." and similar attributes
543
+ .replace(/\s*title=["'][^"']*["']/gi, '')
544
+ // Remove URLs (http, https, ftp)
545
+ .replace(/https?:\/\/[^\s<>"{}|\\^`[\]]+/gi, '')
546
+ .replace(/ftp:\/\/[^\s<>"{}|\\^`[\]]+/gi, '')
547
+ // Remove YAML-like patterns (key: value)
548
+ .replace(/\b\w+:\s*["']?[^"'\s,]+["']?(?=\s|,|$)/g, '')
549
+ // Remove common code syntax patterns
550
+ .replace(/\b(const|let|var|function|interface|class|import|export|return|if|else)\b/g, '')
551
+ .replace(/[=;{}()<>[\]]/g, ' ')
552
+ // Remove common unicode symbols
553
+ .replace(/[✓✔✗✘→←↑↓•·►▸▹▶]/g, '')
554
+ // Remove YAML-like frontmatter patterns
555
+ .replace(/^---[\s\S]*?---/m, '')
556
+ // Clean up navigation/menu text patterns
557
+ .replace(/Skip to main content/gi, '')
558
+ .replace(/On this page/gi, '')
559
+ .replace(/Menu/gi, '')
560
+ .replace(/Search\.\.\./gi, '')
561
+ // Remove list markers
562
+ .replace(/^[\s]*[-*+]\s+/gm, '')
563
+ .replace(/^[\s]*\d+\.\s+/gm, '')
564
+ // Clean up excessive whitespace
565
+ .replace(/\s+/g, ' ')
566
+ // Remove leading/trailing punctuation
567
+ .replace(/^[.\s,;:]+/, '')
568
+ .replace(/[.\s,;:]+$/, '')
569
+ .trim();
570
+ }
571
+
572
+ selectNext() {
573
+ if (this.results.length === 0) return;
574
+
575
+ const newIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
576
+ this.updateSelection(newIndex);
577
+ }
578
+
579
+ selectPrevious() {
580
+ if (this.results.length === 0) return;
581
+
582
+ const newIndex = Math.max(this.selectedIndex - 1, 0);
583
+ this.updateSelection(newIndex);
584
+ }
585
+
586
+ updateSelection(newIndex) {
587
+ const resultElements = this.resultsContainer.querySelectorAll('.search-result');
588
+
589
+ // Remove previous selection
590
+ if (this.selectedIndex >= 0 && resultElements[this.selectedIndex]) {
591
+ resultElements[this.selectedIndex].setAttribute('aria-selected', 'false');
592
+ }
593
+
594
+ // Add new selection
595
+ this.selectedIndex = newIndex;
596
+ if (resultElements[newIndex]) {
597
+ resultElements[newIndex].setAttribute('aria-selected', 'true');
598
+ resultElements[newIndex].scrollIntoView({ block: 'nearest' });
599
+ }
600
+ }
601
+
602
+ navigateToSelected() {
603
+ if (this.selectedIndex < 0 || this.results.length === 0) return;
604
+
605
+ const result = this.results[this.selectedIndex];
606
+ if (result && result.url) {
607
+ this.close();
608
+ window.location.href = result.url;
609
+ }
610
+ }
611
+
612
+ showLoadingState() {
613
+ this.hideAllStates();
614
+ if (this.body) {
615
+ this.body.hidden = false;
616
+ }
617
+ if (this.loadingState) {
618
+ this.loadingState.hidden = false;
619
+ }
620
+ }
621
+
622
+ showEmptyState(query = '') {
623
+ this.hideAllStates();
624
+ if (this.body) {
625
+ this.body.hidden = false;
626
+ }
627
+ if (this.emptyState) {
628
+ const titleEl = this.emptyState.querySelector('.search-empty-title');
629
+ if (titleEl && query) {
630
+ titleEl.textContent = `No results for "${query}"`;
631
+ } else if (titleEl) {
632
+ titleEl.textContent = 'No results found';
633
+ }
634
+ this.emptyState.hidden = false;
635
+ }
636
+ }
637
+
638
+ showErrorState(message) {
639
+ this.hideAllStates();
640
+ if (this.body) {
641
+ this.body.hidden = false;
642
+ }
643
+ if (this.emptyState) {
644
+ this.emptyState.querySelector('span').textContent = message;
645
+ this.emptyState.hidden = false;
646
+ }
647
+ }
648
+
649
+ hideBody() {
650
+ this.hideAllStates();
651
+ if (this.body) {
652
+ this.body.hidden = true;
653
+ }
654
+ this.results = [];
655
+ this.selectedIndex = -1;
656
+ }
657
+
658
+ hideAllStates() {
659
+ if (this.loadingState) this.loadingState.hidden = true;
660
+ if (this.emptyState) this.emptyState.hidden = true;
661
+ if (this.resultsContainer) this.resultsContainer.hidden = true;
662
+ }
663
+
664
+ destroy() {
665
+ document.removeEventListener('keydown', this.handleKeyDown);
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Initialize search on page load
671
+ */
672
+ function initializeSearch() {
673
+ new SearchManager();
674
+ }
675
+
676
+ // Initialize on DOM ready
677
+ if (document.readyState === 'loading') {
678
+ document.addEventListener('DOMContentLoaded', initializeSearch);
679
+ } else {
680
+ initializeSearch();
681
+ }
682
+
683
+ if (typeof module !== 'undefined' && module.exports) {
684
+ module.exports = { SearchManager };
685
+ }