Add client-side search with / shortcut
continuous-integration/drone/push Build encountered an error
continuous-integration/drone Build is failing

Lazy-loads /index.json on first keystroke, fuzzy-matches titles and
descriptions, keyboard-navigable results (arrows, Enter, Esc).
Based on https://gist.github.com/cmod/5410eae147e4318164258742dd053993

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 07:58:35 +02:00
parent 561c0fa320
commit 318c62c289
6 changed files with 355 additions and 2 deletions
+240
View File
@@ -0,0 +1,240 @@
/*
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 = '<li class="search-message">Error loading search index.</li>';
}
}
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 = '<li class="search-message">Type at least 2 characters…</li>';
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 = '<li class="search-message">No results found.</li>';
return;
}
searchResults.innerHTML = results.map(item => `
<li>
<a href="${escapeHtml(item.permalink)}" tabindex="0">
<span class="title">${escapeHtml(item.title)}</span>
<span class="meta">${escapeHtml(item.section)} &mdash; ${escapeHtml(item.date)}</span>
<span class="desc">${escapeHtml(item.desc)}</span>
</a>
</li>
`).join('');
}
searchInput.addEventListener('input', function () {
if (!searchIndex && !firstRun) {
searchResults.innerHTML = '<li class="search-message">Loading…</li>';
return;
}
performSearch(this.value);
});
}
function mergeConfigs(defaultConfig, userConfig) {
const merged = { ...defaultConfig };
for (const [key, value] of Object.entries(userConfig)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
merged[key] = mergeConfigs(defaultConfig[key] || {}, value);
} else {
merged[key] = value;
}
}
return merged;
}
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
initSearch();