/ docs / src / assets / search.js
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();