/* Fast Search for Hugo — adapted from https://gist.github.com/cmod/5410eae147e4318164258742dd053993 MIT License */ const DEFAULT_CONFIG = { shortcuts: { open: { key: '/', metaKey: false, altKey: false, ctrlKey: false, shiftKey: false } }, search: { minChars: 2, maxResults: 8, fields: { title: true, description: true, section: true } } }; function initSearch(userConfig = {}) { const CONFIG = mergeConfigs(DEFAULT_CONFIG, userConfig); const mainNav = document.getElementById('mainNav'); const fastSearch = document.getElementById('fastSearch'); const searchInput = document.getElementById('searchInput'); const searchResults = document.getElementById('searchResults'); let searchIndex = null; let searchVisible = false; let resultsAvailable = false; let firstRun = true; async function loadSearchIndex() { try { const response = await fetch('/index.json'); if (!response.ok) throw new Error('Failed to load search index'); const data = await response.json(); searchIndex = data.map(item => ({ ...item, searchableTitle: item.title?.toLowerCase() || '', searchableDesc: item.desc?.toLowerCase() || '', searchableSection: item.section?.toLowerCase() || '', searchableContents: item.contents?.toLowerCase() || '' })); if (searchInput.value) performSearch(searchInput.value); } catch (error) { console.error('Error loading search index:', error); searchResults.innerHTML = '
'; } } function simpleFuzzyMatch(text, term) { if (text.includes(term)) return true; if (term.length < 3) return false; let matches = 0, 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; } function matchesShortcut(event, sc) { return event.key === sc.key && event.metaKey === sc.metaKey && event.altKey === sc.altKey && event.ctrlKey === sc.ctrlKey && event.shiftKey === sc.shiftKey; } function openSearch() { searchVisible = true; mainNav.style.display = 'none'; fastSearch.style.display = 'flex'; if (firstRun) { loadSearchIndex(); firstRun = false; } searchInput.focus(); searchInput.value = ''; searchResults.innerHTML = ''; } function closeSearch() { searchVisible = false; fastSearch.style.display = 'none'; mainNav.style.display = ''; searchInput.blur(); searchInput.value = ''; searchResults.innerHTML = ''; resultsAvailable = false; } document.addEventListener('keydown', (event) => { const tag = event.target.tagName; const inOtherInput = tag === 'TEXTAREA' || (tag === 'INPUT' && event.target.id !== 'searchInput'); const inSearchInput = tag === 'INPUT' && event.target.id === 'searchInput'; // Never steal keystrokes from other inputs/textareas if (inOtherInput) return; // Toggle shortcut — not when the user is already typing in the search box if (!inSearchInput && matchesShortcut(event, CONFIG.shortcuts.open)) { event.preventDefault(); searchVisible ? closeSearch() : openSearch(); return; } // ESC closes from anywhere, including the search input if (event.key === 'Escape' && searchVisible) { closeSearch(); return; } // Arrow navigation and Enter if (searchVisible && resultsAvailable) { const links = Array.from(searchResults.getElementsByTagName('a')); if (!links.length) return; const active = document.activeElement; const activeInResults = searchResults.contains(active) && active.tagName === 'A'; const i = activeInResults ? links.indexOf(active) : -1; if (event.key === 'Enter') { // Follow the focused result, not always the first one const target = activeInResults ? active : links[0]; event.preventDefault(); window.location.href = target.href; return; } if (event.key === 'ArrowDown') { event.preventDefault(); if (!activeInResults) { links[0].focus(); } // from input or stale focus → first result else if (i < links.length - 1) { links[i + 1].focus(); } return; } if (event.key === 'ArrowUp') { event.preventDefault(); if (!activeInResults || i === 0) { searchInput.focus(); } else { links[i - 1].focus(); } return; } } // Enter with no results: do nothing but swallow it so the form doesn't submit if (event.key === 'Enter' && searchVisible) { event.preventDefault(); return; } }); function performSearch(term) { term = term.toLowerCase().trim(); if (!term || !searchIndex) { searchResults.innerHTML = ''; resultsAvailable = false; return; } if (term.length < CONFIG.search.minChars) { searchResults.innerHTML = ''; resultsAvailable = false; return; } const searchTerms = term.split(/\s+/).filter(t => t.length > 0); const results = searchIndex .map(item => { let score = 0; const matchesAll = searchTerms.every(t => { let matched = false; if (CONFIG.search.fields.title) { if (item.searchableTitle.startsWith(t)) { score += 3; matched = true; } else if (simpleFuzzyMatch(item.searchableTitle, t)) { score += 2; matched = true; } } if (!matched && CONFIG.search.fields.description && item.searchableDesc.includes(t)) { score += 0.5; matched = true; } if (!matched && CONFIG.search.fields.section && item.searchableSection.includes(t)) { score += 0.5; matched = true; } if (!matched && item.searchableContents.includes(t)) { score += 0.1; matched = true; } return matched; }); return { item, score: matchesAll ? score : 0 }; }) .filter(r => r.score > 0) .sort((a, b) => b.score - a.score) .slice(0, CONFIG.search.maxResults) .map(r => r.item); resultsAvailable = results.length > 0; if (!resultsAvailable) { searchResults.innerHTML = ''; return; } searchResults.innerHTML = results.map(item => `