/** * MkDocs Search Integration * Integrates MkDocs Material's search functionality into the map application */ export class MkDocsSearch { constructor(config = {}) { // Determine if we're in production based on current URL const isProduction = window.location.hostname !== 'localhost' && !window.location.hostname.includes('127.0.0.1'); // Use production URL if we're not on localhost if (isProduction && config.mkdocsUrl && config.mkdocsUrl.includes('localhost')) { // Extract the base domain from the current hostname // If we're on map.cmlite.org, we want cmlite.org const currentDomain = window.location.hostname.replace(/^map\./, ''); this.mkdocsUrl = `https://${currentDomain}`; } else { this.mkdocsUrl = config.mkdocsUrl || window.MKDOCS_URL || 'http://localhost:4002'; } this.searchIndex = null; this.searchDocs = null; this.lunr = null; this.debounceTimeout = null; this.minSearchLength = config.minSearchLength || 2; this.initialized = false; console.log('MkDocs Search initialized with URL:', this.mkdocsUrl); } async initialize() { try { console.log('Initializing MkDocs search...'); // Load Lunr.js dynamically await this.loadLunr(); // Try multiple approaches to get the search index let searchData = null; // First try the proxy endpoint try { console.log('Trying proxy endpoint: /api/docs-search'); const proxyResponse = await fetch('/api/docs-search'); if (proxyResponse.ok) { searchData = await proxyResponse.json(); console.log('Successfully loaded search index via proxy'); } } catch (proxyError) { console.warn('Proxy endpoint failed:', proxyError); } // If proxy fails, try direct access if (!searchData) { console.log(`Trying direct access: ${this.mkdocsUrl}/search/search_index.json`); const response = await fetch(`${this.mkdocsUrl}/search/search_index.json`); if (!response.ok) { throw new Error(`Failed to load search index: ${response.status}`); } searchData = await response.json(); console.log('Successfully loaded search index directly'); } // Build the Lunr index this.searchIndex = this.lunr(function() { this.ref('location'); this.field('title', { boost: 10 }); this.field('text'); searchData.docs.forEach(doc => { this.add(doc); }); }); this.searchDocs = searchData.docs; this.initialized = true; console.log('MkDocs search initialized with', this.searchDocs.length, 'documents'); return true; } catch (error) { console.error('Failed to initialize MkDocs search:', error); this.initialized = false; return false; } } async loadLunr() { if (window.lunr) { this.lunr = window.lunr; return; } // Load Lunr.js from CDN return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://unpkg.com/lunr@2.3.9/lunr.min.js'; script.onload = () => { this.lunr = window.lunr; resolve(); }; script.onerror = reject; document.head.appendChild(script); }); } search(query) { if (!this.initialized) { console.warn('Search not initialized'); return []; } if (!this.searchIndex || !query || query.length < this.minSearchLength) { return []; } try { // Perform the search const results = this.searchIndex.search(query); // Map results to include document data return results.slice(0, 10).map(result => { const doc = this.searchDocs.find(d => d.location === result.ref); if (!doc) return null; // Extract a snippet around the matched text const snippet = this.extractSnippet(doc.text, query); return { ...doc, score: result.score, url: `${this.mkdocsUrl}/${doc.location}`, snippet: snippet }; }).filter(Boolean); } catch (error) { console.error('Search error:', error); return []; } } extractSnippet(text, query, maxLength = 150) { const lowerText = text.toLowerCase(); const lowerQuery = query.toLowerCase(); const index = lowerText.indexOf(lowerQuery); if (index === -1) { return text.substring(0, maxLength) + '...'; } const start = Math.max(0, index - 50); const end = Math.min(text.length, index + query.length + 100); let snippet = text.substring(start, end); if (start > 0) snippet = '...' + snippet; if (end < text.length) snippet = snippet + '...'; // Highlight the search term const regex = new RegExp(`(${query})`, 'gi'); snippet = snippet.replace(regex, '$1'); return snippet; } bindToInput(inputElement, resultsElement) { const resultsContainer = resultsElement.querySelector('.docs-search-results-list'); const resultsCount = resultsElement.querySelector('.results-count'); const closeBtn = resultsElement.querySelector('.close-results'); // Handle input with debouncing inputElement.addEventListener('input', (e) => { clearTimeout(this.debounceTimeout); this.debounceTimeout = setTimeout(() => { this.performSearch(e.target.value, resultsContainer, resultsCount, resultsElement); }, 300); }); // Handle keyboard shortcuts inputElement.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeResults(inputElement, resultsElement); } }); // Global keyboard shortcut (Ctrl+K or Cmd+K) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); inputElement.focus(); inputElement.select(); } }); // Close button closeBtn.addEventListener('click', () => { this.closeResults(inputElement, resultsElement); }); // Click outside to close document.addEventListener('click', (e) => { if (!inputElement.contains(e.target) && !resultsElement.contains(e.target)) { resultsElement.classList.add('hidden'); } }); } performSearch(query, resultsContainer, resultsCount, resultsElement) { if (!query || query.length < this.minSearchLength) { resultsElement.classList.add('hidden'); return; } const results = this.search(query); if (results.length === 0) { resultsContainer.innerHTML = '
No results found
'; resultsCount.textContent = 'No results'; } else { resultsCount.textContent = `${results.length} result${results.length > 1 ? 's' : ''}`; resultsContainer.innerHTML = results.map((result, index) => `
${this.escapeHtml(result.title)}
${result.snippet}
${result.location}
`).join(''); // Add event listeners to QR buttons this.attachQRButtonListeners(); } resultsElement.classList.remove('hidden'); } // Add new method to handle QR button clicks attachQRButtonListeners() { const qrButtons = document.querySelectorAll('.make-qr-btn'); qrButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const url = button.getAttribute('data-url'); this.showQRCodeModal(url); }); }); } // Add method to show QR code modal showQRCodeModal(url) { // Create or get existing QR modal let modal = document.getElementById('qr-code-modal'); if (!modal) { modal = this.createQRModal(); document.body.appendChild(modal); } // Ensure modal has correct z-index and class modal.classList.add('qr-modal'); modal.style.zIndex = '11000'; // Force above all overlays // Update modal content const qrImage = modal.querySelector('.qr-code-image'); const qrUrl = modal.querySelector('.qr-code-url'); const qrLoading = modal.querySelector('.qr-loading'); // Show modal and loading state modal.classList.remove('hidden'); qrLoading.style.display = 'block'; qrImage.style.display = 'none'; // Update URL display qrUrl.textContent = url; qrUrl.href = url; // Generate QR code const qrApiUrl = `/api/qr?text=${encodeURIComponent(url)}&size=256`; qrImage.src = qrApiUrl; qrImage.onload = () => { qrLoading.style.display = 'none'; qrImage.style.display = 'block'; }; qrImage.onerror = () => { qrLoading.innerHTML = '

Failed to generate QR code

'; }; } // Add method to create QR modal createQRModal() { const modal = document.createElement('div'); modal.id = 'qr-code-modal'; modal.className = 'modal qr-modal'; // Ensure both classes for styling modal.innerHTML = ` `; // Add close functionality const closeBtn = modal.querySelector('#close-qr-modal'); closeBtn.addEventListener('click', () => { modal.classList.add('hidden'); }); // Close on background click modal.addEventListener('click', (e) => { if (e.target === modal) { modal.classList.add('hidden'); } }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modal.classList.contains('hidden')) { modal.classList.add('hidden'); } }); return modal; } closeResults(inputElement, resultsElement) { resultsElement.classList.add('hidden'); inputElement.value = ''; inputElement.blur(); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } }