search.js
1 /** 2 * Planar Documentation Search 3 * 4 * Provides full-text search functionality with: 5 * - Real-time search as you type 6 * - Auto-complete suggestions 7 * - Category and difficulty filtering 8 * - Result ranking and highlighting 9 */ 10 11 class DocumentationSearch { 12 constructor() { 13 this.searchIndex = null; 14 this.searchInput = null; 15 this.suggestionsContainer = null; 16 this.resultsContainer = null; 17 this.resultsCount = null; 18 this.currentQuery = ''; 19 this.debounceTimer = null; 20 21 // Search configuration 22 this.config = { 23 debounceDelay: 300, 24 maxSuggestions: 8, 25 maxResults: 50, 26 minQueryLength: 2, 27 excerptLength: 150 28 }; 29 30 this.init(); 31 } 32 33 async init() { 34 // Wait for DOM to be ready 35 if (document.readyState === 'loading') { 36 document.addEventListener('DOMContentLoaded', () => this.setupDOM()); 37 } else { 38 this.setupDOM(); 39 } 40 41 // Load search index 42 await this.loadSearchIndex(); 43 } 44 45 setupDOM() { 46 // Get DOM elements 47 this.searchInput = document.getElementById('search-input'); 48 this.suggestionsContainer = document.getElementById('search-suggestions'); 49 this.resultsContainer = document.getElementById('results-container'); 50 this.resultsCount = document.getElementById('results-count'); 51 52 if (!this.searchInput) { 53 console.warn('Search input not found - search functionality disabled'); 54 return; 55 } 56 57 // Setup event listeners 58 this.searchInput.addEventListener('input', (e) => this.handleSearchInput(e)); 59 this.searchInput.addEventListener('keydown', (e) => this.handleKeyDown(e)); 60 this.searchInput.addEventListener('focus', () => this.showSuggestions()); 61 this.searchInput.addEventListener('blur', () => this.hideSuggestions()); 62 63 // Setup filter listeners 64 this.setupFilterListeners(); 65 66 // Handle URL parameters 67 this.handleURLParams(); 68 } 69 70 setupFilterListeners() { 71 const filterInputs = document.querySelectorAll('input[type="checkbox"]'); 72 filterInputs.forEach(input => { 73 input.addEventListener('change', () => this.performSearch()); 74 }); 75 } 76 77 handleURLParams() { 78 const urlParams = new URLSearchParams(window.location.search); 79 const query = urlParams.get('q'); 80 if (query) { 81 this.searchInput.value = query; 82 this.currentQuery = query; 83 this.performSearch(); 84 } 85 } 86 87 async loadSearchIndex() { 88 try { 89 const response = await fetch('/assets/search-index.json'); 90 if (!response.ok) { 91 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 92 } 93 this.searchIndex = await response.json(); 94 console.log(`Loaded search index with ${this.searchIndex.documents.length} documents`); 95 } catch (error) { 96 console.error('Failed to load search index:', error); 97 this.showError('Search functionality is currently unavailable.'); 98 } 99 } 100 101 handleSearchInput(event) { 102 const query = event.target.value.trim(); 103 this.currentQuery = query; 104 105 // Clear previous timer 106 if (this.debounceTimer) { 107 clearTimeout(this.debounceTimer); 108 } 109 110 // Debounce search 111 this.debounceTimer = setTimeout(() => { 112 if (query.length >= this.config.minQueryLength) { 113 this.performSearch(); 114 this.updateSuggestions(); 115 } else { 116 this.clearResults(); 117 this.hideSuggestions(); 118 } 119 }, this.config.debounceDelay); 120 } 121 122 handleKeyDown(event) { 123 if (event.key === 'Escape') { 124 this.hideSuggestions(); 125 this.searchInput.blur(); 126 } else if (event.key === 'Enter') { 127 event.preventDefault(); 128 this.hideSuggestions(); 129 this.performSearch(); 130 } 131 } 132 133 performSearch() { 134 if (!this.searchIndex || this.currentQuery.length < this.config.minQueryLength) { 135 this.clearResults(); 136 return; 137 } 138 139 const filters = this.getActiveFilters(); 140 const results = this.searchDocuments(this.currentQuery, filters); 141 this.displayResults(results); 142 this.updateURL(); 143 } 144 145 getActiveFilters() { 146 const filters = { 147 categories: [], 148 difficulties: [] 149 }; 150 151 // Get active category filters 152 const categoryFilters = ['getting-started', 'guides', 'advanced', 'reference', 'troubleshooting']; 153 categoryFilters.forEach(category => { 154 const checkbox = document.getElementById(`filter-${category}`); 155 if (checkbox && checkbox.checked) { 156 filters.categories.push(category); 157 } 158 }); 159 160 // Get active difficulty filters 161 const difficultyFilters = ['beginner', 'intermediate', 'advanced']; 162 difficultyFilters.forEach(difficulty => { 163 const checkbox = document.getElementById(`difficulty-${difficulty}`); 164 if (checkbox && checkbox.checked) { 165 filters.difficulties.push(difficulty); 166 } 167 }); 168 169 return filters; 170 } 171 172 searchDocuments(query, filters) { 173 const queryTerms = query.toLowerCase().split(/\s+/).filter(term => term.length > 0); 174 const results = []; 175 176 for (const doc of this.searchIndex.documents) { 177 // Apply filters 178 if (filters.categories.length > 0 && !filters.categories.includes(doc.category)) { 179 continue; 180 } 181 if (filters.difficulties.length > 0 && !filters.difficulties.includes(doc.difficulty)) { 182 continue; 183 } 184 185 // Calculate relevance score 186 const score = this.calculateRelevanceScore(doc, queryTerms); 187 if (score > 0) { 188 results.push({ 189 document: doc, 190 score: score, 191 highlights: this.findHighlights(doc, queryTerms) 192 }); 193 } 194 } 195 196 // Sort by relevance score (descending) 197 results.sort((a, b) => b.score - a.score); 198 199 return results.slice(0, this.config.maxResults); 200 } 201 202 calculateRelevanceScore(doc, queryTerms) { 203 let score = 0; 204 const titleLower = doc.title.toLowerCase(); 205 const descriptionLower = doc.description.toLowerCase(); 206 const keywordsLower = doc.keywords.map(k => k.toLowerCase()); 207 const headingsLower = doc.headings.map(h => h.toLowerCase()); 208 209 for (const term of queryTerms) { 210 // Title matches (highest weight) 211 if (titleLower.includes(term)) { 212 score += titleLower === term ? 100 : 50; 213 } 214 215 // Heading matches (high weight) 216 for (const heading of headingsLower) { 217 if (heading.includes(term)) { 218 score += heading === term ? 40 : 20; 219 } 220 } 221 222 // Keyword matches (medium weight) 223 for (const keyword of keywordsLower) { 224 if (keyword.includes(term)) { 225 score += keyword === term ? 30 : 15; 226 } 227 } 228 229 // Description matches (medium weight) 230 if (descriptionLower.includes(term)) { 231 score += 10; 232 } 233 234 // Topic matches (low weight) 235 for (const topic of doc.topics) { 236 if (topic.toLowerCase().includes(term)) { 237 score += 5; 238 } 239 } 240 } 241 242 return score; 243 } 244 245 findHighlights(doc, queryTerms) { 246 const highlights = []; 247 const text = `${doc.title} ${doc.description} ${doc.excerpt}`.toLowerCase(); 248 249 for (const term of queryTerms) { 250 const regex = new RegExp(`\\b${this.escapeRegex(term)}\\b`, 'gi'); 251 const matches = [...text.matchAll(regex)]; 252 highlights.push(...matches.map(match => ({ 253 term: term, 254 start: match.index, 255 end: match.index + match[0].length 256 }))); 257 } 258 259 return highlights; 260 } 261 262 escapeRegex(string) { 263 return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'); 264 } 265 266 displayResults(results) { 267 if (!this.resultsContainer || !this.resultsCount) return; 268 269 // Update results count 270 const count = results.length; 271 const query = this.currentQuery; 272 this.resultsCount.textContent = count > 0 273 ? `Found ${count} result${count !== 1 ? 's' : ''} for "${query}"` 274 : `No results found for "${query}"`; 275 276 // Clear previous results 277 this.resultsContainer.innerHTML = ''; 278 279 if (count === 0) { 280 this.showNoResults(); 281 return; 282 } 283 284 // Display results 285 results.forEach(result => { 286 const resultElement = this.createResultElement(result); 287 this.resultsContainer.appendChild(resultElement); 288 }); 289 } 290 291 createResultElement(result) { 292 const { document: doc, score } = result; 293 294 const resultDiv = document.createElement('div'); 295 resultDiv.className = 'search-result'; 296 297 // Create title with link 298 const titleDiv = document.createElement('div'); 299 titleDiv.className = 'search-result-title'; 300 const titleLink = document.createElement('a'); 301 titleLink.href = doc.url; 302 titleLink.textContent = doc.title; 303 titleDiv.appendChild(titleLink); 304 305 // Create metadata 306 const metaDiv = document.createElement('div'); 307 metaDiv.className = 'search-result-meta'; 308 309 const categorySpan = document.createElement('span'); 310 categorySpan.className = 'search-result-category'; 311 categorySpan.textContent = this.formatCategory(doc.category); 312 313 const difficultySpan = document.createElement('span'); 314 difficultySpan.className = 'search-result-difficulty'; 315 difficultySpan.textContent = this.formatDifficulty(doc.difficulty); 316 317 metaDiv.appendChild(categorySpan); 318 metaDiv.appendChild(difficultySpan); 319 320 // Create excerpt 321 const excerptDiv = document.createElement('div'); 322 excerptDiv.className = 'search-result-excerpt'; 323 excerptDiv.innerHTML = this.highlightText(doc.description || doc.excerpt); 324 325 // Assemble result 326 resultDiv.appendChild(titleDiv); 327 resultDiv.appendChild(metaDiv); 328 resultDiv.appendChild(excerptDiv); 329 330 return resultDiv; 331 } 332 333 formatCategory(category) { 334 return category.split('-').map(word => 335 word.charAt(0).toUpperCase() + word.slice(1) 336 ).join(' '); 337 } 338 339 formatDifficulty(difficulty) { 340 return difficulty.charAt(0).toUpperCase() + difficulty.slice(1); 341 } 342 343 highlightText(text) { 344 if (!this.currentQuery) return text; 345 346 const queryTerms = this.currentQuery.toLowerCase().split(/\s+/); 347 let highlightedText = text; 348 349 queryTerms.forEach(term => { 350 const regex = new RegExp(`\\b(${this.escapeRegex(term)})\\b`, 'gi'); 351 highlightedText = highlightedText.replace(regex, '<span class="search-highlight">$1</span>'); 352 }); 353 354 return highlightedText; 355 } 356 357 updateSuggestions() { 358 if (!this.suggestionsContainer || !this.searchIndex) return; 359 360 const suggestions = this.generateSuggestions(this.currentQuery); 361 this.displaySuggestions(suggestions); 362 } 363 364 generateSuggestions(query) { 365 if (query.length < 2) return []; 366 367 const suggestions = new Set(); 368 const queryLower = query.toLowerCase(); 369 370 // Add matching keywords 371 for (const doc of this.searchIndex.documents) { 372 for (const keyword of doc.keywords) { 373 if (keyword.toLowerCase().includes(queryLower) && keyword.length > query.length) { 374 suggestions.add(keyword); 375 } 376 } 377 378 // Add matching titles 379 if (doc.title.toLowerCase().includes(queryLower) && doc.title.length > query.length) { 380 suggestions.add(doc.title); 381 } 382 } 383 384 return Array.from(suggestions).slice(0, this.config.maxSuggestions); 385 } 386 387 displaySuggestions(suggestions) { 388 if (!this.suggestionsContainer) return; 389 390 this.suggestionsContainer.innerHTML = ''; 391 392 if (suggestions.length === 0) { 393 this.hideSuggestions(); 394 return; 395 } 396 397 suggestions.forEach(suggestion => { 398 const suggestionDiv = document.createElement('div'); 399 suggestionDiv.className = 'search-suggestion'; 400 suggestionDiv.textContent = suggestion; 401 suggestionDiv.addEventListener('mousedown', (e) => { 402 e.preventDefault(); // Prevent blur event 403 this.selectSuggestion(suggestion); 404 }); 405 this.suggestionsContainer.appendChild(suggestionDiv); 406 }); 407 408 this.showSuggestions(); 409 } 410 411 selectSuggestion(suggestion) { 412 this.searchInput.value = suggestion; 413 this.currentQuery = suggestion; 414 this.hideSuggestions(); 415 this.performSearch(); 416 } 417 418 showSuggestions() { 419 if (this.suggestionsContainer) { 420 this.suggestionsContainer.style.display = 'block'; 421 } 422 } 423 424 hideSuggestions() { 425 if (this.suggestionsContainer) { 426 this.suggestionsContainer.style.display = 'none'; 427 } 428 } 429 430 showNoResults() { 431 const noResultsDiv = document.createElement('div'); 432 noResultsDiv.className = 'no-results'; 433 noResultsDiv.innerHTML = ` 434 <h3>No results found</h3> 435 <p>Try:</p> 436 <ul> 437 <li>Using different keywords</li> 438 <li>Checking your spelling</li> 439 <li>Using more general terms</li> 440 <li>Adjusting the filters above</li> 441 </ul> 442 <p>Or browse by category:</p> 443 <ul> 444 <li><a href="../getting-started/">Getting Started</a></li> 445 <li><a href="../guides/">User Guides</a></li> 446 <li><a href="../reference/">Reference</a></li> 447 <li><a href="../troubleshooting/">Troubleshooting</a></li> 448 </ul> 449 `; 450 this.resultsContainer.appendChild(noResultsDiv); 451 } 452 453 clearResults() { 454 if (this.resultsContainer) { 455 this.resultsContainer.innerHTML = ''; 456 } 457 if (this.resultsCount) { 458 this.resultsCount.textContent = 'Enter a search term to find relevant documentation'; 459 } 460 } 461 462 showError(message) { 463 if (this.resultsContainer) { 464 this.resultsContainer.innerHTML = ` 465 <div class="search-error"> 466 <p><strong>Error:</strong> ${message}</p> 467 <p>Please try again later or browse the documentation manually.</p> 468 </div> 469 `; 470 } 471 } 472 473 updateURL() { 474 if (this.currentQuery && window.history && window.history.replaceState) { 475 const url = new URL(window.location); 476 url.searchParams.set('q', this.currentQuery); 477 window.history.replaceState({}, '', url); 478 } 479 } 480 } 481 482 // Initialize search when the script loads 483 const documentationSearch = new DocumentationSearch();