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.
|
// 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, '&')
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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; },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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-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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
|
||||||
// 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.
|
|
||||||
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 {
|
type suggestResult struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
URL string `json:"url"`
|
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 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.
|
||||||
|
|||||||
@@ -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