${this.highlightKeywords(this.escapeHtml(result.title), keywords)}
${this.highlightKeywords(this.escapeHtml(result.excerpt), keywords)}
/**
* Modern Local Search Implementation
* Compatible with hexo-generator-search
* Separated from UI control logic for better maintainability
*/
class ModernSearch {
constructor(options = {}) {
this.options = {
path: options.path || 'search.xml',
inputSelector: options.inputSelector || '#search-input',
resultsSelector: options.resultsSelector || '#search-results .search-results__list',
loadingSelector: options.loadingSelector || '.search-results__loading',
emptySelector: options.emptySelector || '.search-results__empty',
maxResults: options.maxResults || 50,
excerptLength: options.excerptLength || 200,
debounceDelay: options.debounceDelay || 300,
...options
};
this.searchData = [];
this.searchInput = null;
this.resultsContainer = null;
this.loadingElement = null;
this.emptyElement = null;
this.debounceTimer = null;
this.init();
}
async init() {
// Find DOM elements
this.searchInput = document.querySelector(this.options.inputSelector);
this.resultsContainer = document.querySelector(this.options.resultsSelector);
this.loadingElement = document.querySelector(this.options.loadingSelector);
this.emptyElement = document.querySelector(this.options.emptySelector);
if (!this.searchInput || !this.resultsContainer) {
console.warn('Search elements not found');
return;
}
// Load search data
await this.loadSearchData();
// Set up event listeners
this.setupEventListeners();
}
async loadSearchData() {
try {
this.showLoading();
const response = await fetch(this.options.path);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
// Check for parsing errors
const parseError = xmlDoc.querySelector('parsererror');
if (parseError) {
throw new Error('XML parsing failed');
}
// Extract search data
const entries = xmlDoc.querySelectorAll('entry');
this.searchData = Array.from(entries).map(entry => ({
title: this.getTextContent(entry, 'title'),
content: this.getTextContent(entry, 'content'),
url: this.getTextContent(entry, 'url')
})).filter(item => item.title && item.content); // Filter out empty entries
this.hideLoading();
console.log(`Loaded ${this.searchData.length} search entries`);
} catch (error) {
console.error('Failed to load search data:', error);
this.hideLoading();
this.showError('Failed to load search data');
}
}
getTextContent(parent, tagName) {
const element = parent.querySelector(tagName);
return element ? element.textContent.trim() : '';
}
setupEventListeners() {
// Debounced input handler
this.searchInput.addEventListener('input', (e) => {
clearTimeout(this.debounceTimer);
const query = e.target.value.trim();
this.debounceTimer = setTimeout(() => {
if (query.length === 0) {
this.clearResults();
} else if (query.length >= 2) { // Only search for 2+ characters
this.performSearch(query);
}
}, this.options.debounceDelay);
});
// Handle Enter key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const query = e.target.value.trim();
if (query.length >= 2) {
clearTimeout(this.debounceTimer);
this.performSearch(query);
}
}
});
}
performSearch(query) {
if (!query || this.searchData.length === 0) {
this.clearResults();
return;
}
this.showLoading();
// Use requestAnimationFrame for better performance with large datasets
requestAnimationFrame(() => {
const results = this.search(query);
this.displayResults(results, query);
this.hideLoading();
});
}
search(query) {
const keywords = query.toLowerCase()
.split(/[\s\-\+\(\)]+/)
.filter(word => word.length > 0);
if (keywords.length === 0) return [];
const results = [];
for (const item of this.searchData) {
const titleLower = item.title.toLowerCase();
const contentLower = this.stripHtml(item.content).toLowerCase();
let score = 0;
let matchedKeywords = 0;
const titleMatches = [];
const contentMatches = [];
for (const keyword of keywords) {
const titleIndex = titleLower.indexOf(keyword);
const contentIndex = contentLower.indexOf(keyword);
if (titleIndex >= 0 || contentIndex >= 0) {
matchedKeywords++;
// Higher score for title matches
if (titleIndex >= 0) {
score += titleIndex === 0 ? 10 : 5; // Boost for exact start matches
titleMatches.push({ keyword, index: titleIndex });
}
// Lower score for content matches
if (contentIndex >= 0) {
score += 1;
contentMatches.push({ keyword, index: contentIndex });
}
}
}
// Only include results that match all keywords
if (matchedKeywords === keywords.length) {
results.push({
...item,
score,
titleMatches,
contentMatches,
excerpt: this.generateExcerpt(contentLower, keywords)
});
}
}
// Sort by score (descending) and limit results
return results
.sort((a, b) => b.score - a.score)
.slice(0, this.options.maxResults);
}
stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
generateExcerpt(content, keywords) {
// Find the first keyword occurrence
let firstIndex = Infinity;
for (const keyword of keywords) {
const index = content.indexOf(keyword);
if (index >= 0 && index < firstIndex) {
firstIndex = index;
}
}
if (firstIndex === Infinity) return content.substring(0, this.options.excerptLength);
// Generate excerpt around the first match
const start = Math.max(0, firstIndex - 50);
const end = Math.min(content.length, start + this.options.excerptLength);
let excerpt = content.substring(start, end);
if (start > 0) excerpt = '...' + excerpt;
if (end < content.length) excerpt = excerpt + '...';
return excerpt;
}
highlightKeywords(text, keywords) {
let highlighted = text;
for (const keyword of keywords) {
const regex = new RegExp(`(${this.escapeRegex(keyword)})`, 'gi');
highlighted = highlighted.replace(regex, '$1');
}
return highlighted;
}
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
displayResults(results, query) {
if (results.length === 0) {
this.showEmpty();
return;
}
this.hideEmpty();
const keywords = query.toLowerCase().split(/[\s\-\+\(\)]+/).filter(word => word.length > 0);
const resultsHtml = results.map(result => `
${this.highlightKeywords(this.escapeHtml(result.excerpt), keywords)}
${this.highlightKeywords(this.escapeHtml(result.title), keywords)}
${this.escapeHtml(message)}