if (!window.searchNamespace) window.searchNamespace = {}; class SearchEngine { constructor() { this.documents = []; this.tokenMap = new Map(); this.isLoaded = false; this.loadError = false; this.useWebWorker = typeof Worker !== 'undefined' && searchWorker !== null; this.fullDocuments = null; // for lazy loading this.rootPath = window.searchNamespace?.rootPath || ''; } // Load search data from JSON async loadData() { if (this.isLoaded && !this.loadError) return; // Clear previous error state on retry this.loadError = false; try { // Load JSON data, try multiple possible paths // FIXME: There is only one possible path for now, and this search data is guaranteed // to generate at this location, but we'll want to extend this in the future. const possiblePaths = ["/assets/search-data.json"]; let response = null; let usedPath = ""; for (const path of possiblePaths) { try { const testResponse = await fetch(path); if (testResponse.ok) { response = testResponse; usedPath = path; break; } } catch (e) { // Continue to next path } } if (!response) { throw new Error("Search data file not found at any expected location"); } console.log(`Loading search data from: ${usedPath}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Use optimized JSON parsing for large files const documents = await this.parseLargeJSON(response); if (!Array.isArray(documents)) { throw new Error("Invalid search data format"); } this.initializeFromDocuments(documents); this.isLoaded = true; console.log(`Loaded ${documents.length} documents for search`); } catch (error) { console.error("Error loading search data:", error); this.documents = []; this.tokenMap.clear(); this.loadError = true; } } // Initialize from documents array async initializeFromDocuments(documents) { if (!Array.isArray(documents)) { console.error("Invalid documents format:", typeof documents); this.documents = []; } else { this.documents = documents; console.log(`Initialized with ${documents.length} documents`); } try { await this.buildTokenMap(); } catch (error) { console.error("Error building token map:", error); } } // Initialize from search index structure initializeIndex(indexData) { this.documents = indexData.documents || []; this.tokenMap = new Map(Object.entries(indexData.tokenMap || {})); } // Build token map // This is helpful for faster searching with progressive loading buildTokenMap() { return new Promise((resolve, reject) => { this.tokenMap.clear(); if (!Array.isArray(this.documents)) { console.error("No documents to build token map"); resolve(); return; } const totalDocs = this.documents.length; let processedDocs = 0; try { // Process in chunks to avoid blocking UI const processChunk = (startIndex, chunkSize) => { try { const endIndex = Math.min(startIndex + chunkSize, totalDocs); for (let i = startIndex; i < endIndex; i++) { const doc = this.documents[i]; if (!doc || typeof doc.title !== 'string' || typeof doc.content !== 'string') { console.warn(`Invalid document at index ${i}:`, doc); continue; } const tokens = this.tokenize(doc.title + " " + doc.content); tokens.forEach(token => { if (!this.tokenMap.has(token)) { this.tokenMap.set(token, []); } this.tokenMap.get(token).push(i); }); processedDocs++; } // Update progress and yield control if (endIndex < totalDocs) { setTimeout(() => processChunk(endIndex, chunkSize), 0); } else { console.log(`Built token map with ${this.tokenMap.size} unique tokens from ${processedDocs} documents`); resolve(); } } catch (error) { reject(error); } }; // Start processing with small chunks processChunk(0, 100); } catch (error) { reject(error); } }); } // Tokenize text into searchable terms tokenize(text) { const tokens = new Set(); const words = text.toLowerCase().match(/\b[a-zA-Z0-9_-]+\b/g) || []; words.forEach(word => { if (word.length > 2) { tokens.add(word); } }); return Array.from(tokens); } // Advanced search with ranking async search(query, limit = 10) { if (!query.trim()) return []; // Wait for data to be loaded if (!this.isLoaded) { await this.loadData(); } if (!this.isLoaded || this.documents.length === 0) { console.log("Search data not available"); return []; } const searchTerms = this.tokenize(query); if (searchTerms.length === 0) return []; // Fallback to basic search if token map is empty if (this.tokenMap.size === 0) { return this.fallbackSearch(query, limit); } // Use Web Worker for large datasets to avoid blocking UI if (this.useWebWorker && this.documents.length > 1000) { return await this.searchWithWorker(query, limit); } // For very large datasets, implement lazy loading with candidate docIds if (this.documents.length > 10000) { const candidateDocIds = new Set(); searchTerms.forEach(term => { const docIds = this.tokenMap.get(term) || []; docIds.forEach(id => candidateDocIds.add(id)); }); const docIds = Array.from(candidateDocIds); return await this.lazyLoadDocuments(docIds, limit); } const docScores = new Map(); searchTerms.forEach(term => { const docIds = this.tokenMap.get(term) || []; docIds.forEach(docId => { const doc = this.documents[docId]; if (!doc) return; const currentScore = docScores.get(docId) || 0; // Calculate score based on term position and importance let score = 1; // Title matches get higher score if (doc.title.toLowerCase().includes(term)) { score += 10; // Exact title match gets even higher score if (doc.title.toLowerCase() === term) { score += 20; } } // Content matches if (doc.content.toLowerCase().includes(term)) { score += 2; } // Boost for multiple term matches docScores.set(docId, currentScore + score); }); }); // Sort by score and return top results const scoredResults = Array.from(docScores.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit); return scoredResults .map(([docId, score]) => ({ ...this.documents[docId], score })); } // Generate search preview with highlighting generatePreview(content, query, maxLength = 150) { const lowerContent = content.toLowerCase(); let bestIndex = -1; let bestScore = 0; let bestMatch = ""; // Find the best match position const queryWords = this.tokenize(query); queryWords.forEach(word => { const index = lowerContent.indexOf(word); if (index !== -1) { const score = word.length; // longer words get higher priority if (score > bestScore) { bestScore = score; bestIndex = index; bestMatch = word; } } }); if (bestIndex === -1) { return this.escapeHtml(content.slice(0, maxLength)) + "..."; } const start = Math.max(0, bestIndex - 50); const end = Math.min(content.length, bestIndex + bestMatch.length + 50); let preview = content.slice(start, end); if (start > 0) preview = "..." + preview; if (end < content.length) preview += "..."; // Escape HTML first, then highlight preview = this.escapeHtml(preview); preview = this.highlightTerms(preview, queryWords); return preview; } // Escape HTML to prevent XSS escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Highlight search terms in text highlightTerms(text, terms) { let highlighted = text; // Sort terms by length (longer first) to avoid overlapping highlights const sortedTerms = [...terms].sort((a, b) => b.length - a.length); sortedTerms.forEach(term => { const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi'); highlighted = highlighted.replace(regex, '$1'); }); return highlighted; } // Web Worker search for large datasets async searchWithWorker(query, limit) { return new Promise((resolve, reject) => { const messageId = `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const timeout = setTimeout(() => { cleanup(); reject(new Error('Web Worker search timeout')); }, 5000); // 5 second timeout const handleMessage = (e) => { if (e.data.messageId !== messageId) return; clearTimeout(timeout); cleanup(); if (e.data.type === 'results') { resolve(e.data.data); } else if (e.data.type === 'error') { reject(new Error(e.data.error || 'Unknown worker error')); } }; const handleError = (error) => { clearTimeout(timeout); cleanup(); reject(error); }; const cleanup = () => { searchWorker.removeEventListener('message', handleMessage); searchWorker.removeEventListener('error', handleError); }; searchWorker.addEventListener('message', handleMessage); searchWorker.addEventListener('error', handleError); searchWorker.postMessage({ messageId, type: 'search', data: { documents: this.documents, query, limit } }); }); } // Escape regex special characters escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Resolve path relative to current page location resolvePath(path) { // If path already starts with '/', it's absolute from domain root if (path.startsWith('/')) { return path; } // If path starts with '#', it's a fragment on current page if (path.startsWith('#')) { return path; } // Prepend root path for relative navigation return this.rootPath + path; } // Optimized JSON parser for large files async parseLargeJSON(response) { const contentLength = response.headers.get('content-length'); // For small files, use regular JSON parsing if (!contentLength || parseInt(contentLength) < 1024 * 1024) { // < 1MB return await response.json(); } // For large files, use streaming approach console.log(`Large search file detected (${contentLength} bytes), using streaming parser`); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process in chunks to avoid blocking main thread if (buffer.length > 100 * 1024) { // 100KB chunks await new Promise(resolve => setTimeout(resolve, 0)); } } return JSON.parse(buffer); } // Lazy loading for search results async lazyLoadDocuments(docIds, limit = 10) { if (!this.fullDocuments) { // Store full documents separately for memory efficiency this.fullDocuments = this.documents; // Create lightweight index documents this.documents = this.documents.map(doc => ({ id: doc.id, title: doc.title, path: doc.path })); } return docIds.slice(0, limit).map(id => this.fullDocuments[id]); } // Fallback search method (simple string matching) fallbackSearch(query, limit = 10) { const lowerQuery = query.toLowerCase(); const results = this.documents .map(doc => { const titleMatch = doc.title.toLowerCase().indexOf(lowerQuery); const contentMatch = doc.content.toLowerCase().indexOf(lowerQuery); let score = 0; if (titleMatch !== -1) { score += 10; if (doc.title.toLowerCase() === lowerQuery) { score += 20; } } if (contentMatch !== -1) { score += 2; } return { doc, score, titleMatch, contentMatch }; }) .filter(item => item.score > 0) .sort((a, b) => { if (a.score !== b.score) return b.score - a.score; if (a.titleMatch !== b.titleMatch) return a.titleMatch - b.titleMatch; return a.contentMatch - b.contentMatch; }) .slice(0, limit) .map(item => ({ ...item.doc, score: item.score })); return results; } } // Web Worker for background search processing // This is CLEARLY the best way to do it lmao. // Create Web Worker if supported let searchWorker = null; if (typeof Worker !== 'undefined') { try { searchWorker = new Worker('/assets/search-worker.js'); console.log('Web Worker initialized for background search'); } catch (error) { console.warn('Web Worker creation failed, using main thread:', error); } } // Global search engine instance window.searchNamespace.engine = new SearchEngine(); // Mobile search timeout for debouncing let mobileSearchTimeout = null; // Legacy search for backward compatibility // This could be removed, but I'm emotionally attached to it // and it could be used as a fallback. function filterSearchResults(data, searchTerm, limit = 10) { return data .filter( (doc) => doc.title.toLowerCase().includes(searchTerm) || doc.content.toLowerCase().includes(searchTerm), ) .slice(0, limit); } document.addEventListener("DOMContentLoaded", function() { // Initialize search engine immediately window.searchNamespace.engine.loadData().then(() => { console.log("Search data loaded successfully"); }).catch(error => { console.error("Failed to initialize search:", error); }); // Search page specific functionality const searchPageInput = document.getElementById("search-page-input"); if (searchPageInput) { // Set up event listener searchPageInput.addEventListener("input", function() { performSearch(this.value); }); // Perform search if URL has query const params = new URLSearchParams(window.location.search); const query = params.get("q"); if (query) { searchPageInput.value = query; performSearch(query); } } // Desktop Sidebar Toggle const searchInput = document.getElementById("search-input"); if (searchInput) { const searchResults = document.getElementById("search-results"); searchInput.addEventListener("input", async function() { const searchTerm = this.value.trim(); if (searchTerm.length < 2) { searchResults.innerHTML = ""; searchResults.style.display = "none"; return; } // Show loading state searchResults.innerHTML = '
Please enter at least 2 characters to search
"; return; } // Show loading state resultsContainer.innerHTML = "Searching...
"; try { const results = await window.searchNamespace.engine.search(query, 50); // Display results if (results.length > 0) { let html = 'No results found
"; } // Update URL with query const url = new URL(window.location.href); url.searchParams.set("q", query); window.history.replaceState({}, "", url.toString()); } catch (error) { console.error("Search error:", error); resultsContainer.innerHTML = "Search temporarily unavailable
"; } }