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:
+73
-246
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -270,3 +270,71 @@ test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
|
||||
await overlay.getByRole("link", { name: "Projects" }).click();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function seedSearch(page) {
|
||||
for (const n of ["Zebra Crossing", "Zebra Finch", "Zeta1", "Zeta2", "Zeta3", "Zeta4", "Zeta5", "Zeta6"]) {
|
||||
mkpage(`Suggest/${n}`, n);
|
||||
}
|
||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
||||
}
|
||||
const suggest = (page) => page.locator("header .suggest-dropdown");
|
||||
|
||||
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
|
||||
await seedSearch(page);
|
||||
await page.goto("/");
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill("finch");
|
||||
await expect(suggest(page)).toBeVisible();
|
||||
await expect(suggest(page).locator(".suggest-row").first().locator("strong")).toHaveText("Finch");
|
||||
await input.press("ArrowDown");
|
||||
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText("Zebra Finch");
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/Suggest\/Zebra%20Finch\/$/);
|
||||
});
|
||||
|
||||
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
|
||||
await seedSearch(page);
|
||||
await page.goto("/");
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill("z");
|
||||
await page.waitForTimeout(300);
|
||||
await expect(suggest(page)).toBeHidden();
|
||||
await input.fill("zeta");
|
||||
const footer = suggest(page).locator(".suggest-footer");
|
||||
const { total } = await (await page.request.get("/_search?q=zeta")).json();
|
||||
expect(total).toBeGreaterThan(5);
|
||||
await expect(footer).toHaveText(`Show all ${total} matches`);
|
||||
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
|
||||
await input.press("Escape");
|
||||
await expect(suggest(page)).toBeHidden();
|
||||
await input.fill("zebra c");
|
||||
await suggest(page).locator(".suggest-name", { hasText: "Zebra Crossing" }).click();
|
||||
await expect(page).toHaveURL(/\/Suggest\/Zebra%20Crossing\/$/);
|
||||
await page.goto("/");
|
||||
await page.locator("header .search-input").fill("zeta");
|
||||
await suggest(page).locator(".suggest-footer").click();
|
||||
await expect(page).toHaveURL(/\/\?q=zeta$/);
|
||||
});
|
||||
|
||||
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
|
||||
await seedSearch(page);
|
||||
await page.goto("/Suggest/Zeta1/?edit");
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Alt+Shift+P");
|
||||
const query = modal(page).getByPlaceholder("Search query");
|
||||
await query.fill("crossing");
|
||||
await modal(page).locator(".suggest-row", { hasText: "Zebra Crossing" }).click();
|
||||
await expect(query).toHaveValue("Zebra Crossing");
|
||||
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
|
||||
await modal(page).getByRole("button", { name: "INSERT" }).click();
|
||||
await expect(page.locator(".cm-content")).toContainText("[[Zebra Crossing]]");
|
||||
});
|
||||
|
||||
test("editor [[ autocomplete still uses the JSON endpoint", async ({ page }) => {
|
||||
await seedSearch(page);
|
||||
await page.goto("/Suggest/Zeta2/?edit");
|
||||
await page.locator(".cm-content").click();
|
||||
await page.keyboard.press("Control+End");
|
||||
await page.keyboard.type("\n[[finc");
|
||||
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText("Zebra Finch");
|
||||
});
|
||||
|
||||
@@ -293,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
|
||||
return score
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
|
||||
// the editor's link picker. Caps results at 5; reports total so the UI can
|
||||
// surface a "show all" footer when more matches exist. Empty/whitespace query
|
||||
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
|
||||
// (header search, editor link dialog; htmx requests) and JSON for the
|
||||
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
|
||||
// can offer a "show all" footer (?footer) when more matches exist. An
|
||||
// empty/whitespace query is a no-op (200, no results), not a 400 — every
|
||||
// keystroke fires this.
|
||||
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(w, r) {
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
type suggestResult struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
type suggestResp struct {
|
||||
Query string `json:"query"`
|
||||
Results []suggestResult `json:"results"`
|
||||
@@ -328,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
|
||||
// editor's [[ autocomplete) keep the JSON.
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
if isHX(r) {
|
||||
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Printf("search suggest encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
|
||||
|
||||
// suggestMinLen is the shortest query (in characters) the typeahead answers;
|
||||
// shorter ones render nothing, which closes the dropdown.
|
||||
const suggestMinLen = 2
|
||||
|
||||
type suggestRow struct {
|
||||
Name, Path, URL string
|
||||
Highlighted template.HTML
|
||||
}
|
||||
|
||||
// renderSuggest writes the typeahead dropdown for query. footer adds a
|
||||
// "Show all" row linking to the full results page when some were cut off.
|
||||
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
|
||||
data := struct {
|
||||
Show bool
|
||||
Results []suggestRow
|
||||
Total int
|
||||
AllURL string
|
||||
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
|
||||
tokens := suggestTokens(query)
|
||||
for _, res := range results {
|
||||
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
|
||||
}
|
||||
if footer && total > len(results) {
|
||||
data.AllURL = "/?q=" + url.QueryEscape(query)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
|
||||
log.Printf("suggest template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestTokens lowercases query and splits it on anything that is not a
|
||||
// letter or digit.
|
||||
func suggestTokens(query string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// highlightMatches bolds the first occurrence of each token in name
|
||||
// (case-insensitive), merging overlapping spans, and escapes the rest.
|
||||
func highlightMatches(name string, tokens []string) template.HTML {
|
||||
lower := strings.ToLower(name)
|
||||
if len(lower) != len(name) {
|
||||
// Lowercasing changed byte lengths, so offsets would not line up.
|
||||
return template.HTML(template.HTMLEscapeString(name))
|
||||
}
|
||||
type span struct{ from, to int }
|
||||
var spans []span
|
||||
for _, t := range tokens {
|
||||
if i := strings.Index(lower, t); i >= 0 {
|
||||
spans = append(spans, span{i, i + len(t)})
|
||||
}
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
|
||||
var b strings.Builder
|
||||
cursor := 0
|
||||
for i := 0; i < len(spans); i++ {
|
||||
sp := spans[i]
|
||||
for i+1 < len(spans) && spans[i+1].from <= sp.to {
|
||||
i++
|
||||
sp.to = max(sp.to, spans[i].to)
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
|
||||
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
|
||||
cursor = sp.to
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(name[cursor:]))
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// handleReindex rebuilds the folder index synchronously and returns 204.
|
||||
// The frontend reloads the page on success. Serialized via buildMu so a
|
||||
// double-click waits rather than running two walks in parallel.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHighlightMatches(t *testing.T) {
|
||||
cases := []struct{ name, query, want string }{
|
||||
{"Project Alpha", "alpha", "Project <strong>Alpha</strong>"},
|
||||
// Overlapping and adjacent spans merge.
|
||||
{"Project Alpha", "pro ject", "<strong>Project</strong> Alpha"},
|
||||
{"Project Alpha", "roj proj", "<strong>Proj</strong>ect Alpha"},
|
||||
{"a<b>&c", "b", "a<<strong>b</strong>>&c"},
|
||||
{"Notes", "zzz", "Notes"},
|
||||
// Lowercasing changes the byte length: no highlight, still escaped.
|
||||
{"İstanbul <x>", "stan", "İstanbul <x>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want {
|
||||
t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user