Render search suggestions on the server

The typeahead requests /_search via htmx (debounced, newer requests
replace in-flight ones) and gets rendered rows with the match
highlighting done in Go. Rows are links, so picking in the header is
plain navigation; the editor's link dialog reads data-name instead. The
editor's [[ autocomplete keeps the JSON response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 17:00:08 +02:00
co-authored by Claude Opus 5.5
parent 4365fa2567
commit a55a1c8ed4
6 changed files with 266 additions and 257 deletions
+73 -246
View File
@@ -1,63 +1,18 @@
// search-suggest.js — instant typeahead dropdown.
//
// Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Owns: debounced fetching,
// request ordering, DOM creation, keyboard handling, open/close lifecycle.
// search box and the editor's "Insert link" modal. Fetching is htmx: the input
// requests /_search as you type (debounced; a newer request replaces an
// in-flight one) and the server renders the rows (assets/search/suggest.html).
// This file owns the dropdown's open/close lifecycle and keyboard handling.
//
// opts:
// onPick(result) — called when the user selects a row
// onShowAll(query) — optional; called when the footer row activates
// onPick({name}) — optional; called when the user selects a row.
// Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode)
(function () {
var DEBOUNCE_MS = 100;
var MIN_QUERY_LEN = 2;
function escapeHTML(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function tokenize(s) {
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
// highlight bolds the substring spans in `name` that match any of the
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
// Returns a safe HTML string.
function highlight(name, tokens) {
if (!tokens.length) return escapeHTML(name);
var lower = name.toLowerCase();
var spans = [];
tokens.forEach(function (t) {
if (!t) return;
var idx = lower.indexOf(t);
if (idx >= 0) spans.push([idx, idx + t.length]);
});
if (!spans.length) return escapeHTML(name);
spans.sort(function (a, b) { return a[0] - b[0]; });
var merged = [spans[0].slice()];
for (var i = 1; i < spans.length; i++) {
var last = merged[merged.length - 1];
if (spans[i][0] <= last[1]) {
last[1] = Math.max(last[1], spans[i][1]);
} else {
merged.push(spans[i].slice());
}
}
var out = '';
var cursor = 0;
merged.forEach(function (sp) {
out += escapeHTML(name.slice(cursor, sp[0]));
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
cursor = sp[1];
});
out += escapeHTML(name.slice(cursor));
return out;
}
var seq = 0;
function attachSuggestions(inputEl, opts) {
if (!inputEl) return;
@@ -68,237 +23,116 @@
var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown);
function makeRow(cls, tabbable) {
var tr = document.createElement('tr');
tr.className = cls;
if (tabbable) tr.setAttribute('tabindex', '0');
var td = document.createElement('td');
tr.appendChild(td);
return { tr: tr, td: td };
// htmx sends the input's value under its name.
if (!inputEl.name) inputEl.name = 'q';
inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
inputEl.setAttribute('hx-sync', 'this:replace');
inputEl.setAttribute('hx-target', '#' + dropdown.id);
htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
}
var state = {
results: [],
total: 0,
query: '',
activeIdx: -1,
open: false,
reqSeq: 0,
debounceTimer: null,
blurTimer: null,
};
function rowCount() {
var n = state.results.length;
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
return 0; // "no matches" row is non-interactive
}
if (opts.showFooter && state.total > state.results.length) n += 1;
return n;
}
function isFooterIdx(idx) {
return opts.showFooter
&& state.total > state.results.length
&& idx === state.results.length;
}
function render() {
dropdown.textContent = '';
if (!state.open) {
dropdown.classList.remove('is-open');
return;
}
var table = document.createElement('table');
table.className = 'data-table';
var tbody = document.createElement('tbody');
table.appendChild(tbody);
var tokens = tokenize(state.query);
if (state.results.length === 0) {
var empty = makeRow('is-empty', false);
empty.td.textContent = 'No matches';
tbody.appendChild(empty.tr);
} else {
state.results.forEach(function (r, i) {
var row = makeRow('suggest-row', true);
row.tr.setAttribute('data-idx', String(i));
var nameEl = document.createElement('span');
nameEl.className = 'suggest-name';
nameEl.innerHTML = highlight(r.name, tokens);
var pathEl = document.createElement('span');
pathEl.className = 'suggest-path';
pathEl.textContent = '/' + r.path;
row.td.appendChild(nameEl);
row.td.appendChild(pathEl);
if (i === state.activeIdx) row.tr.classList.add('is-active');
row.tr.addEventListener('mousedown', function (e) {
// mousedown (not click) so the input doesn't blur-close
// the dropdown before the pick handler fires.
e.preventDefault();
pick(i);
});
tbody.appendChild(row.tr);
});
if (opts.showFooter && state.total > state.results.length) {
var footer = makeRow('suggest-row suggest-footer', true);
footer.td.textContent = 'Show all ' + state.total + ' matches';
var footerIdx = state.results.length;
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
footer.tr.addEventListener('mousedown', function (e) {
e.preventDefault();
pickFooter();
});
tbody.appendChild(footer.tr);
}
}
dropdown.appendChild(table);
dropdown.classList.add('is-open');
}
function pick(idx) {
var r = state.results[idx];
if (!r) return;
close();
if (opts.onPick) opts.onPick(r);
}
function pickFooter() {
close();
if (opts.onShowAll) {
opts.onShowAll(state.query);
} else if (inputEl.form) {
inputEl.form.submit();
} else {
window.location.href = '/?q=' + encodeURIComponent(state.query);
}
function isOpen() {
return dropdown.classList.contains('is-open');
}
function open() {
state.open = true;
render();
if (dropdown.firstElementChild) dropdown.classList.add('is-open');
}
function close() {
state.open = false;
state.activeIdx = -1;
render();
dropdown.classList.remove('is-open');
setActive(-1);
}
function fetchResults(query) {
var seq = ++state.reqSeq;
fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function (resp) {
if (seq !== state.reqSeq) return; // stale
state.results = resp.results || [];
state.total = resp.total || 0;
state.query = resp.query || query;
state.activeIdx = -1;
open();
}).catch(function () {
if (seq !== state.reqSeq) return;
state.results = [];
state.total = 0;
close();
});
}
function onInput() {
var q = inputEl.value.trim();
state.query = q;
if (state.debounceTimer) clearTimeout(state.debounceTimer);
if (q.length < MIN_QUERY_LEN) {
state.reqSeq++; // invalidate any in-flight response
state.results = [];
state.total = 0;
close();
return;
function setActive(idx) {
var rs = rows();
if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
activeIdx = idx;
if (rs[idx]) {
rs[idx].classList.add('is-active');
try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
}
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
}
function moveActive(delta) {
var n = rowCount();
var n = rows().length;
if (n === 0) return;
var next = state.activeIdx + delta;
var next = activeIdx + delta;
if (next < 0) next = n - 1;
if (next >= n) next = 0;
state.activeIdx = next;
render();
// Keep the active row in view.
var active = dropdown.querySelector('tr.is-active');
if (active && active.scrollIntoView) {
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {}
setActive(next);
}
// pick activates a row: onPick for data rows when given, otherwise
// (and always for the footer) the row's link.
function pick(row) {
close();
if (opts.onPick && !row.classList.contains('suggest-footer')) {
opts.onPick({ name: row.dataset.name });
} else {
row.querySelector('a').click();
}
}
function activateCurrent() {
if (state.activeIdx < 0) return false;
if (isFooterIdx(state.activeIdx)) {
pickFooter();
return true;
}
pick(state.activeIdx);
return true;
}
inputEl.addEventListener('input', onInput);
inputEl.addEventListener('focus', function () {
if (state.blurTimer) {
clearTimeout(state.blurTimer);
state.blurTimer = null;
}
if (inputEl.value.trim().length >= MIN_QUERY_LEN
&& (state.results.length || state.query)) {
dropdown.addEventListener('htmx:afterSwap', function () {
activeIdx = -1;
if (document.activeElement === inputEl && dropdown.firstElementChild) {
open();
} else {
close();
}
});
inputEl.addEventListener('blur', function () {
// Delay so click/mousedown on a row still resolves.
state.blurTimer = setTimeout(close, 150);
// mousedown keeps focus in the input so blur doesn't close the
// dropdown before the click lands.
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
dropdown.addEventListener('click', function (e) {
var row = e.target.closest('.suggest-row');
if (!row) return;
if (opts.onPick && !row.classList.contains('suggest-footer')) {
e.preventDefault();
pick(row);
} else {
close(); // the link navigates
}
});
inputEl.addEventListener('focus', open);
inputEl.addEventListener('blur', close);
inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault();
close();
} else if (e.key === 'Enter') {
if (state.open && state.activeIdx >= 0) {
// Without an active row: native submit (full results page).
var row = rows()[activeIdx];
if (row) {
e.preventDefault();
activateCurrent();
pick(row);
}
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') {
if (!state.open || rowCount() === 0) return;
if (rows().length === 0) return;
e.preventDefault();
moveActive(e.shiftKey ? -1 : 1);
}
});
// Click outside the host closes the dropdown.
document.addEventListener('mousedown', function (e) {
if (!state.open) return;
if (host.contains(e.target)) return;
close();
});
return {
close: close,
destroy: function () {
@@ -310,14 +144,7 @@
window.attachSuggestions = attachSuggestions;
// Auto-bind to the header search input. Header search submits the form
// for the "show all" action; we route to a navigate-on-pick handler.
document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('header .search-input');
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
});
})();
+8
View File
@@ -0,0 +1,8 @@
{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a
pick in the header is plain navigation; the editor's link dialog intercepts
the click and reads data-name. Empty output closes the dropdown. */}}
{{define "suggest"}}{{if .Show}}<table class="data-table"><tbody>
{{range .Results}}<tr class="suggest-row" data-name="{{.Name}}"><td><a class="suggest-link" href="{{.URL}}" tabindex="-1"><span class="suggest-name">{{.Highlighted}}</span><span class="suggest-path">/{{.Path}}</span></a></td></tr>
{{else}}<tr class="is-empty"><td>No matches</td></tr>
{{end}}{{if .AllURL}}<tr class="suggest-row suggest-footer"><td><a class="suggest-link" href="{{.AllURL}}" tabindex="-1">Show all {{.Total}} matches</a></td></tr>
{{end}}</tbody></table>{{end}}{{end}}
+4 -2
View File
@@ -447,11 +447,13 @@ main > h2 {
}
.suggest-dropdown.is-open { display: block; }
.suggest-row { cursor: pointer; }
.suggest-row > td { padding: 0.4rem 0.6rem; }
.suggest-row > td { padding: 0; }
/* The link fills the row, so every part of it is the click target. */
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
.suggest-name, .suggest-path { display: block; }
.suggest-name { color: var(--text); }
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); }
.suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); }
/* === Editor toolbar ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather