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. // search-suggest.js — instant typeahead dropdown.
// //
// Exposes window.attachSuggestions(inputEl, opts) used by both the header // Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Owns: debounced fetching, // search box and the editor's "Insert link" modal. Fetching is htmx: the input
// request ordering, DOM creation, keyboard handling, open/close lifecycle. // 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: // opts:
// onPick(result) — called when the user selects a row // onPick({name}) — optional; called when the user selects a row.
// onShowAll(query) — optional; called when the footer row activates // Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row // showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode) // container (Element) — optional parent (defaults to inputEl.parentNode)
(function () { (function () {
var DEBOUNCE_MS = 100; var seq = 0;
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;
}
function attachSuggestions(inputEl, opts) { function attachSuggestions(inputEl, opts) {
if (!inputEl) return; if (!inputEl) return;
@@ -68,237 +23,116 @@
var dropdown = document.createElement('div'); var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown'; dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown); host.appendChild(dropdown);
function makeRow(cls, tabbable) { // htmx sends the input's value under its name.
var tr = document.createElement('tr'); if (!inputEl.name) inputEl.name = 'q';
tr.className = cls; inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
if (tabbable) tr.setAttribute('tabindex', '0'); inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
var td = document.createElement('td'); inputEl.setAttribute('hx-sync', 'this:replace');
tr.appendChild(td); inputEl.setAttribute('hx-target', '#' + dropdown.id);
return { tr: tr, td: td }; htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
} }
var state = { function isOpen() {
results: [], return dropdown.classList.contains('is-open');
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 open() { function open() {
state.open = true; if (dropdown.firstElementChild) dropdown.classList.add('is-open');
render();
} }
function close() { function close() {
state.open = false; dropdown.classList.remove('is-open');
state.activeIdx = -1; setActive(-1);
render();
} }
function fetchResults(query) { function setActive(idx) {
var seq = ++state.reqSeq; var rs = rows();
fetch('/_search?q=' + encodeURIComponent(query), { if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
credentials: 'same-origin', activeIdx = idx;
headers: { 'Accept': 'application/json' }, if (rs[idx]) {
}).then(function (r) { rs[idx].classList.add('is-active');
if (!r.ok) throw new Error('HTTP ' + r.status); try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
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;
} }
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
} }
function moveActive(delta) { function moveActive(delta) {
var n = rowCount(); var n = rows().length;
if (n === 0) return; if (n === 0) return;
var next = state.activeIdx + delta; var next = activeIdx + delta;
if (next < 0) next = n - 1; if (next < 0) next = n - 1;
if (next >= n) next = 0; if (next >= n) next = 0;
state.activeIdx = next; setActive(next);
render(); }
// Keep the active row in view.
var active = dropdown.querySelector('tr.is-active'); // pick activates a row: onPick for data rows when given, otherwise
if (active && active.scrollIntoView) { // (and always for the footer) the row's link.
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} 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() { dropdown.addEventListener('htmx:afterSwap', function () {
if (state.activeIdx < 0) return false; activeIdx = -1;
if (isFooterIdx(state.activeIdx)) { if (document.activeElement === inputEl && dropdown.firstElementChild) {
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)) {
open(); open();
} else {
close();
} }
}); });
inputEl.addEventListener('blur', function () {
// Delay so click/mousedown on a row still resolves. // mousedown keeps focus in the input so blur doesn't close the
state.blurTimer = setTimeout(close, 150); // 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) { inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(1); moveActive(1);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(-1); moveActive(-1);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
close(); close();
} else if (e.key === 'Enter') { } 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(); e.preventDefault();
activateCurrent(); pick(row);
} }
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
if (!state.open || rowCount() === 0) return; if (rows().length === 0) return;
e.preventDefault(); e.preventDefault();
moveActive(e.shiftKey ? -1 : 1); 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 { return {
close: close, close: close,
destroy: function () { destroy: function () {
@@ -310,14 +144,7 @@
window.attachSuggestions = attachSuggestions; 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 () { document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('header .search-input'); attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
}); });
})(); })();
+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-dropdown.is-open { display: block; }
.suggest-row { cursor: pointer; } .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, .suggest-path { display: block; }
.suggest-name { color: var(--text); } .suggest-name { color: var(--text); }
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; } .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 === /* === Editor toolbar ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
+68
View File
@@ -270,3 +270,71 @@ test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
await overlay.getByRole("link", { name: "Projects" }).click(); await overlay.getByRole("link", { name: "Projects" }).click();
await expect(overlay).toHaveCount(0); 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");
});
+92 -9
View File
@@ -293,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
return score return score
} }
// handleSearchSuggest serves the JSON typeahead for the header dropdown and type suggestResult struct {
// the editor's link picker. Caps results at 5; reports total so the UI can Name string `json:"name"`
// surface a "show all" footer when more matches exist. Empty/whitespace query Path string `json:"path"`
// is a no-op (200 with empty results), not a 400 — every keystroke fires this. 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) { func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) { if !h.checkAuth(w, r) {
return return
} }
query := strings.TrimSpace(r.URL.Query().Get("q")) 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 { type suggestResp struct {
Query string `json:"query"` Query string `json:"query"`
Results []suggestResult `json:"results"` 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") w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("search suggest encode error: %v", err) 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. // handleReindex rebuilds the folder index synchronously and returns 204.
// The frontend reloads the page on success. Serialized via buildMu so a // The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel. // double-click waits rather than running two walks in parallel.
+21
View File
@@ -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&lt;<strong>b</strong>&gt;&amp;c"},
{"Notes", "zzz", "Notes"},
// Lowercasing changes the byte length: no highlight, still escaped.
{"İstanbul <x>", "stan", "İstanbul &lt;x&gt;"},
}
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)
}
}
}