docyard 0.5.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +34 -1
- data/lib/docyard/build/static_generator.rb +3 -44
- data/lib/docyard/builder.rb +14 -4
- data/lib/docyard/cli.rb +6 -3
- data/lib/docyard/components/aliases.rb +29 -0
- data/lib/docyard/components/base_processor.rb +6 -0
- data/lib/docyard/components/processors/callout_processor.rb +124 -0
- data/lib/docyard/components/processors/code_block_diff_preprocessor.rb +106 -0
- data/lib/docyard/components/processors/code_block_focus_preprocessor.rb +79 -0
- data/lib/docyard/components/processors/code_block_options_preprocessor.rb +78 -0
- data/lib/docyard/components/processors/code_block_processor.rb +175 -0
- data/lib/docyard/components/processors/code_snippet_import_preprocessor.rb +127 -0
- data/lib/docyard/components/processors/heading_anchor_processor.rb +39 -0
- data/lib/docyard/components/processors/icon_processor.rb +53 -0
- data/lib/docyard/components/processors/table_of_contents_processor.rb +68 -0
- data/lib/docyard/components/processors/table_wrapper_processor.rb +22 -0
- data/lib/docyard/components/processors/tabs_processor.rb +48 -0
- data/lib/docyard/components/registry.rb +4 -4
- data/lib/docyard/components/support/code_block/feature_extractor.rb +117 -0
- data/lib/docyard/components/support/code_block/icon_detector.rb +44 -0
- data/lib/docyard/components/support/code_block/line_parser.rb +84 -0
- data/lib/docyard/components/support/code_block/line_wrapper.rb +50 -0
- data/lib/docyard/components/support/code_block/patterns.rb +55 -0
- data/lib/docyard/components/support/code_detector.rb +61 -0
- data/lib/docyard/components/support/tabs/icon_detector.rb +62 -0
- data/lib/docyard/components/support/tabs/parser.rb +195 -0
- data/lib/docyard/components/support/tabs/range_finder.rb +46 -0
- data/lib/docyard/config/branding_resolver.rb +74 -0
- data/lib/docyard/{constants.rb → config/constants.rb} +1 -0
- data/lib/docyard/config/validator.rb +8 -0
- data/lib/docyard/config.rb +17 -1
- data/lib/docyard/{prev_next_builder.rb → navigation/prev_next_builder.rb} +2 -2
- data/lib/docyard/{sidebar → navigation/sidebar}/renderer.rb +3 -14
- data/lib/docyard/{sidebar → navigation/sidebar}/tree_builder.rb +9 -2
- data/lib/docyard/{sidebar_builder.rb → navigation/sidebar_builder.rb} +3 -15
- data/lib/docyard/{icons → rendering/icons}/file_types.rb +0 -13
- data/lib/docyard/{icons → rendering/icons}/phosphor.rb +4 -1
- data/lib/docyard/{markdown.rb → rendering/markdown.rb} +23 -14
- data/lib/docyard/{renderer.rb → rendering/renderer.rb} +24 -20
- data/lib/docyard/search/build_indexer.rb +74 -0
- data/lib/docyard/search/dev_indexer.rb +110 -0
- data/lib/docyard/search/pagefind_support.rb +31 -0
- data/lib/docyard/{asset_handler.rb → server/asset_handler.rb} +1 -1
- data/lib/docyard/{server.rb → server/dev_server.rb} +32 -9
- data/lib/docyard/{preview_server.rb → server/preview_server.rb} +1 -1
- data/lib/docyard/{rack_application.rb → server/rack_application.rb} +53 -50
- data/lib/docyard/server/resolution_result.rb +29 -0
- data/lib/docyard/{router.rb → server/router.rb} +4 -4
- data/lib/docyard/templates/assets/css/code.css +12 -4
- data/lib/docyard/templates/assets/css/components/code-block.css +427 -24
- data/lib/docyard/templates/assets/css/components/navigation.css +12 -9
- data/lib/docyard/templates/assets/css/components/search.css +549 -0
- data/lib/docyard/templates/assets/css/components/tabs.css +50 -44
- data/lib/docyard/templates/assets/css/layout.css +15 -1
- data/lib/docyard/templates/assets/css/variables.css +44 -0
- data/lib/docyard/templates/assets/js/components/search.js +685 -0
- data/lib/docyard/templates/layouts/default.html.erb +14 -2
- data/lib/docyard/templates/partials/_code_block.html.erb +50 -2
- data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -1
- data/lib/docyard/templates/partials/_prev_next.html.erb +1 -1
- data/lib/docyard/templates/partials/_search_modal.html.erb +45 -0
- data/lib/docyard/templates/partials/_search_trigger.html.erb +22 -0
- data/lib/docyard/utils/html_helpers.rb +14 -0
- data/lib/docyard/utils/path_resolver.rb +2 -1
- data/lib/docyard/utils/url_helpers.rb +20 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +22 -15
- metadata +57 -36
- data/lib/docyard/components/callout_processor.rb +0 -121
- data/lib/docyard/components/code_block_processor.rb +0 -55
- data/lib/docyard/components/code_detector.rb +0 -59
- data/lib/docyard/components/heading_anchor_processor.rb +0 -34
- data/lib/docyard/components/icon_detector.rb +0 -57
- data/lib/docyard/components/icon_processor.rb +0 -51
- data/lib/docyard/components/table_of_contents_processor.rb +0 -64
- data/lib/docyard/components/table_wrapper_processor.rb +0 -18
- data/lib/docyard/components/tabs_parser.rb +0 -60
- data/lib/docyard/components/tabs_processor.rb +0 -44
- data/lib/docyard/routing/resolution_result.rb +0 -31
- /data/lib/docyard/{sidebar → navigation/sidebar}/config_parser.rb +0 -0
- /data/lib/docyard/{sidebar → navigation/sidebar}/file_system_scanner.rb +0 -0
- /data/lib/docyard/{sidebar → navigation/sidebar}/item.rb +0 -0
- /data/lib/docyard/{sidebar → navigation/sidebar}/title_extractor.rb +0 -0
- /data/lib/docyard/{icons → rendering/icons}/LICENSE.phosphor +0 -0
- /data/lib/docyard/{icons.rb → rendering/icons.rb} +0 -0
- /data/lib/docyard/{language_mapping.rb → rendering/language_mapping.rb} +0 -0
- /data/lib/docyard/{file_watcher.rb → server/file_watcher.rb} +0 -0
- /data/lib/docyard/{errors.rb → utils/errors.rb} +0 -0
- /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
|
+
}
|