Add client-side search with / shortcut
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:
@@ -33,4 +33,7 @@ taxonomies:
|
|||||||
permalinks:
|
permalinks:
|
||||||
articles: "/s/articles/:year/:month/:day/:slug"
|
articles: "/s/articles/:year/:month/:day/:slug"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
home: ["HTML", "RSS", "JSON"]
|
||||||
|
|
||||||
ignoreLogs: [ "warning-goldmark-raw-html" ]
|
ignoreLogs: [ "warning-goldmark-raw-html" ]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{- $index := slice -}}
|
||||||
|
{{- range .Site.RegularPages -}}
|
||||||
|
{{- $index = $index | append (dict
|
||||||
|
"title" .Title
|
||||||
|
"desc" (.Summary | plainify)
|
||||||
|
"contents" .Plain
|
||||||
|
"section" .Section
|
||||||
|
"date" (.Date.Format "2006-01-02")
|
||||||
|
"permalink" .Permalink) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $index | jsonify -}}
|
||||||
@@ -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)} — ${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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
initSearch();
|
||||||
@@ -266,7 +266,7 @@ nav {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: auto 2% .6em auto;
|
margin: 0 2% 0 auto;
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +342,7 @@ nav li:hover {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
/* $mq-mini or smaller: */ margin: 0; width: 100%; font-size: $base-font-size * 0.8;
|
/* $mq-mini or smaller: */ margin: 0; width: 100%; font-size: $base-font-size * 0.8;
|
||||||
@media #{$mq-small} { margin: 0 5%; width: 90%; font-size: $base-font-size * 0.9; }
|
@media #{$mq-small} { margin: 0 5%; width: 90%; font-size: $base-font-size * 0.9; }
|
||||||
@@ -779,3 +780,96 @@ table {
|
|||||||
td {
|
td {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search — sits in the header, replaces nav when active
|
||||||
|
#fastSearch {
|
||||||
|
display: none; // toggled to flex by JS
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center; // vertically align with the header text
|
||||||
|
position: relative; // anchor for the results dropdown
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
input#searchInput {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: .25em .63em;
|
||||||
|
font-size: 1.1em; // matches nav a font-size
|
||||||
|
color: $text-dark;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none; // header border-bottom already provides the bottom line
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder { color: $text-very-light; font-style: italic; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#searchResults {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f7f7f7; // matches header bg
|
||||||
|
border: 1px solid $tab-border-color;
|
||||||
|
border-top: 2px solid $ipng-orange;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 200;
|
||||||
|
|
||||||
|
&:empty { display: none; }
|
||||||
|
|
||||||
|
li {
|
||||||
|
border-bottom: 1px solid $tab-border-color;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&.search-message {
|
||||||
|
padding: .5em .63em;
|
||||||
|
color: $text-normal;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
padding: .4em .63em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: $text-dark;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
outline: none;
|
||||||
|
background-color: $ipng-orange;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.meta, .desc { color: rgba(255,255,255,0.85); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: block;
|
||||||
|
font-size: .72em;
|
||||||
|
color: $text-normal;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
margin: .1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
display: block;
|
||||||
|
font-size: .85em;
|
||||||
|
color: $text-normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,5 +7,6 @@
|
|||||||
{{- block "main" . }}{{- end }}
|
{{- block "main" . }}{{- end }}
|
||||||
</div>
|
</div>
|
||||||
{{- partial "footer.html" . -}}
|
{{- partial "footer.html" . -}}
|
||||||
|
<script src="/js/fastsearch.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="myname">
|
<div class="myname">
|
||||||
<h2><a href="{{ default .Site.Home .Site.BaseURL }}">{{ default .Site.Params.Author .Site.Params.siteHeading }}</a></h2>
|
<h2><a href="{{ default .Site.Home .Site.BaseURL }}">{{ default .Site.Params.Author .Site.Params.siteHeading }}</a></h2>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav id="mainNav">
|
||||||
<ul class="navbar">
|
<ul class="navbar">
|
||||||
{{- /* info about current page */ -}}
|
{{- /* info about current page */ -}}
|
||||||
{{- $currentPage := . -}}
|
{{- $currentPage := . -}}
|
||||||
@@ -67,4 +67,8 @@
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div id="fastSearch">
|
||||||
|
<input id="searchInput" tabindex="0" placeholder="Search…" aria-label="Search" autocomplete="off">
|
||||||
|
<ul id="searchResults"></ul>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user