241 lines
8.6 KiB
JavaScript
241 lines
8.6 KiB
JavaScript
/**
|
|
* 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, '<mark>$1</mark>');
|
|
|
|
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 = '<div class="no-results">No results found</div>';
|
|
resultsCount.textContent = 'No results';
|
|
} else {
|
|
resultsCount.textContent = `${results.length} result${results.length > 1 ? 's' : ''}`;
|
|
resultsContainer.innerHTML = results.map(result => `
|
|
<a href="${result.url}" class="search-result" target="_blank" rel="noopener">
|
|
<div class="search-result-title">${this.escapeHtml(result.title)}</div>
|
|
<div class="search-result-snippet">${result.snippet}</div>
|
|
<div class="search-result-path">${result.location}</div>
|
|
</a>
|
|
`).join('');
|
|
}
|
|
|
|
resultsElement.classList.remove('hidden');
|
|
}
|
|
|
|
closeResults(inputElement, resultsElement) {
|
|
resultsElement.classList.add('hidden');
|
|
inputElement.value = '';
|
|
inputElement.blur();
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
} |