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
+92 -9
View File
@@ -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.