diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 0000000..d340a61 --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,207 @@ +// Configuration +const DEFAULT_CONFIG = { + search: { + minChars: 2, // Minimum characters before searching + maxResults: 5, // Maximum number of results to show + fields: { // Fields to search through + title: true, // Allow searching in title + description: true, // Allow searching in description + section: true // Allow searching in section + } + } +}; + +class FastSearch { + constructor({ + searchInput, resultsContainer, json, + searchResultTemplate = null, + noResultsText = null, + }) { + this.searchInput = searchInput; + this.resultsContainer = resultsContainer; + this.json = json; + this.searchResultTemplate = searchResultTemplate; + this.noResultsText = noResultsText; + + this.init(); + } + + init() { + this.loadSearchIndex(); + // this.initShortcutListener(); + this.searchInput.addEventListener('input', (event) => { + if (!this.searchIndex) { + this.resultsContainer.innerHTML = '
  • Loading search index...
  • '; + return; + } + this.performSearch(this.searchInput.value); + }); + } + + // Load the search index + async loadSearchIndex() { + try { + const response = await fetch(this.json); + if (!response.ok) throw new Error('Failed to load search index'); + const data = await response.json(); + + this.searchIndex = data.map(item => ({ + ...item, + searchableTitle: item.title?.toLowerCase() || '', + searchableDesc: item.desc?.toLowerCase() || '', + searchableSection: item.section?.toLowerCase() || '' + })); + } catch (error) { + console.error('Error loading search index:', error); + this.resultsContainer.innerHTML = '
  • Error loading search index...
  • '; + } + } + + escapeHtml(unsafe) { + if (!unsafe) return ''; + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + + // Simple fuzzy match for single words + simpleFuzzyMatch(text, term) { + if (text.includes(term)) return true; + if (term.length < 3) return false; + + let matches = 0; + let lastMatchIndex = -1; + + for (let i = 0; i < term.length; i++) { + const found = text.indexOf(term[i], lastMatchIndex + 1); + if (found > -1) { + matches++; + lastMatchIndex = found; + } + } + + return matches === term.length; + } + + // Check if keyboard event matches shortcut config + matchesShortcut(event, shortcutConfig) { + return event.key === shortcutConfig.key && + event.metaKey === shortcutConfig.metaKey && + event.altKey === shortcutConfig.altKey && + event.ctrlKey === shortcutConfig.ctrlKey && + event.shiftKey === shortcutConfig.shiftKey; + } + + initShortcutListener() { + // Keyboard shortcuts + document.addEventListener('keydown', (event) => { + // ESC to close search + if (event.key === 'Escape' && this.searchInput.focus) { + this.searchInput.blur(); + this.searchInput.value = ''; + this.resultsContainer.innerHTML = ''; + } + }); + } + + + performSearch(term) { + term = term.toLowerCase().trim(); + + if (!term || !this.searchIndex) { + this.resultsContainer.innerHTML = ''; + let resultsAvailable = false; + return; + } + + // Split search into terms + const searchTerms = term.split(/\s+/).filter(t => t.length > 0); + + // Search with scoring + const results = this.searchIndex + .map(item => { + let score = 0; + const matchesAllTerms = searchTerms.every(term => { + let matched = false; + + // Title matches (weighted higher) + if (DEFAULT_CONFIG.search.fields.title) { + if (item.searchableTitle.startsWith(term)) { + score += 3; // Highest score for prefix matches in title + matched = true; + } else if (this.simpleFuzzyMatch(item.searchableTitle, term)) { + score += 2; // Good score for fuzzy matches in title + matched = true; + } + } + + // Other field matches + if (!matched) { + if (DEFAULT_CONFIG.search.fields.description && item.searchableDesc.includes(term)) { + score += 0.5; // Lower score for description matches + matched = true; + } + if (DEFAULT_CONFIG.search.fields.section && item.searchableSection.includes(term)) { + score += 0.5; // Lower score for section matches + matched = true; + } + } + + return matched; + }); + + return { + item, + score: matchesAllTerms ? score : 0 + }; + }) + .filter(result => result.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, DEFAULT_CONFIG.search.maxResults) + .map(result => result.item); + + let resultsAvailable = results.length > 0; + + if (!resultsAvailable) { + this.resultsContainer.innerHTML = '

    Oops! No results found.

    '; + return; + } + + const searchItems = results.map( (item) => { + let categories = ''; + let tags = ''; + if (item.categories) { + categories = item.categories.join(', '); + categories = `
    ${categories}
    `; + } + if (item.tags) { + tags = item.tags.join(', '); + tags = `
    ${tags}
    ` + } + return ` +
    +
    +

    ${this.escapeHtml(item.title)}

    + +
    +

    ${this.escapeHtml(item.contents)}

    +
    + `; + }).join(''); + + this.resultsContainer.innerHTML = searchItems; + } +} + +const search = new FastSearch({ + searchInput: document.getElementById('search-input'), + resultsContainer: document.getElementById('search-results'), + json: '/index.json' +}); \ No newline at end of file diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index b0ed9b0..f030efe 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -38,6 +38,7 @@ {{ partialCached "footer.html" . }} + {{ partial "search-results.html" . }}