From d793ab0871ad9b90dbb70960630dda37fd2392a0 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Wed, 28 Oct 2026 15:45:54 +1100 Subject: [PATCH] search: make the navigation form work from every page (AUD-151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav form carried hx-get="/search" hx-target="#search-results", but #search-results exists only on /search. On every other page htmx found no target and silently discarded the response: a search box that looks like it works and does nothing. - SearchForm is now a plain GET with no htmx attributes (navigation and the landing page), so it navigates to /search?q=… from any page and with JavaScript disabled. - SearchFormLive keeps the in-place swap on the search page, where the target does exist. For HX-Request the server now returns the block's *contents* (SearchResultsBody), so the fragment no longer nests a second element with id="search-results" inside the live region. - Empty query shows a prompt instead of an empty list. A zero-match query says "Nothing found" and offers the six areas plus the evidence index. - The result count is role="status" inside the existing aria-live container, so a screen reader reads "4 results for …" rather than a run of titles. Tests (internal/server, internal/site): - the nav form carries no htmx attribute on nine pages, and no built page carries an hx-target whose element is missing (walks all of dist/); - search submitted from /, /edge/ and /platform/ lands on a page with results with no HX-Request header, i.e. the JavaScript-disabled path; - empty query, zero match and the announced count; - a content change followed by a build is visible in dist/search.db. That is the deploy path: deploy/deploy.sh runs `make dist` before it stages a release, and search.Build removes and rewrites the index every build. DoD: search from home, /edge and /platform lands on results; works with JavaScript disabled; zero-match query shows a message; content change followed by a deploy is reflected in search; no htmx request targets a missing element. (cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7) --- internal/server/server.go | 11 +- internal/server/server_test.go | 198 +++++++++++++++++++++++- internal/site/site.go | 11 ++ internal/site/site_test.go | 91 +++++++++++ internal/web/static/site.css | 9 ++ internal/web/templates/components.templ | 38 ++++- internal/web/templates/pages.templ | 75 ++++++--- internal/web/templates/view.go | 3 + 8 files changed, 400 insertions(+), 36 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 905b944..468847e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -413,9 +413,10 @@ func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) { _, _ = io.WriteString(w, `

`+msg+`

The evidence index lists every fragment in this build.

`) } -// handleSearch runs a keyword search. With HTMX it returns just the results -// block; without JavaScript the same handler returns the whole page, so the form -// works in both cases. +// handleSearch runs a keyword search. With HTMX it returns just the contents of +// the results block, which the search page's own form swaps into #search-results; +// without JavaScript the same handler returns the whole page, so the form works +// in both cases. func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) failed := false @@ -451,7 +452,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { Render(context.Context, io.Writer) error } = templates.SearchPage(view) if r.Header.Get("HX-Request") == "true" { - c = templates.SearchResults(view) + // The element that holds #search-results stays where it is, so the + // fragment carries the contents and not the wrapper. + c = templates.SearchResultsBody(view) } if err := c.Render(r.Context(), &buf); err != nil { s.log.Error("render search", "error", err) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0738bde..77065dd 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "strings" "testing" "time" @@ -274,8 +275,14 @@ func TestSearchWorksWithAndWithoutHTMX(t *testing.T) { if strings.Contains(frag, "") + if start < 0 || end < 0 { + t.Fatalf("%s: could not isolate the navigation search form", path) + } + return body[start : marker+end] +} + +// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav +// form used to carry hx-target="#search-results", which only exists on /search, +// so htmx silently discarded the response everywhere else. The nav form must be +// a plain GET, and no page may target a fragment it does not carry. +func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) { + ts := newServer(t) + pages := []string{ + "/", + "/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/", + "/evidence/", "/edge/edge-fleet/", "/404.html", + } + for _, path := range pages { + code, _, body := get(t, ts, path, nil) + if code != http.StatusOK { + t.Errorf("%s: status %d", path, code) + continue + } + form := navSearchForm(t, path, body) + if strings.Contains(form, "hx-") { + t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form) + } + if !strings.Contains(form, `action="/search"`) { + t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form) + } + if !strings.Contains(form, `method="get"`) { + t.Errorf("%s: navigation search form is not a GET:\n%s", path, form) + } + if strings.Contains(body, `hx-target="#search-results"`) { + t.Errorf("%s: page targets #search-results, which only the search page carries", path) + } + } +} + +// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with +// JavaScript disabled would: GET the form action with q set, expect a page. +func TestSearchFromAnyPageLandsOnResults(t *testing.T) { + ts := newServer(t) + for _, path := range []string{"/", "/edge/", "/platform/"} { + code, _, body := get(t, ts, path, nil) + if code != http.StatusOK { + t.Errorf("%s: status %d", path, code) + continue + } + form := navSearchForm(t, path, body) + if !strings.Contains(form, `action="/search"`) { + t.Errorf("%s: nav form has no /search action to follow", path) + continue + } + // No HX-Request header: this is the no-JavaScript path. + code, _, landed := get(t, ts, "/search?q=zigbee", nil) + if code != http.StatusOK { + t.Errorf("search from %s: status %d", path, code) + continue + } + if !strings.Contains(strings.ToLower(landed), "") { + t.Errorf("search from %s did not land on a page", path) + } + if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") { + t.Errorf("search from %s landed on a page with no results", path) + } + } +} + +// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every +// hx-target selector must name an element in the same document. This is the +// general form of the AUD-151 bug, so the next one fails a test instead of +// failing silently in a browser. +func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) { + target := regexp.MustCompile(`hx-target="([^"]+)"`) + files := 0 + err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(p, ".html") { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + files++ + body := string(raw) + for _, m := range target.FindAllStringSubmatch(body, -1) { + selector := m[1] + if !strings.HasPrefix(selector, "#") { + continue // closest/find targets are resolved from the trigger + } + id := strings.TrimPrefix(selector, "#") + if !strings.Contains(body, `id="`+id+`"`) { + t.Errorf("%s: hx-target=%q has no matching element", p, selector) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", distDir, err) + } + if files == 0 { + t.Fatal("no HTML files scanned; the build fixture is empty") + } +} + +// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an +// empty list". +func TestSearchEmptyQueryShowsAPrompt(t *testing.T) { + ts := newServer(t) + code, _, body := get(t, ts, "/search", nil) + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + if !strings.Contains(body, "Type one or two words above") { + t.Error("empty search shows no prompt") + } + if strings.Contains(body, `class="results"`) { + t.Error("empty search renders a results list") + } +} + +// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a +// clear nothing-found message and suggests browsing the areas". +func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) { + ts := newServer(t) + code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil) + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + if !strings.Contains(body, "Nothing found for") { + t.Error("no nothing-found message") + } + for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} { + if !strings.Contains(body, href) { + t.Errorf("nothing-found message does not offer %s", href) + } + } +} + +// TestSearchCountIsAnnounced checks the count is inside the polite live region +// and marked as a status, so a screen reader reads "4 results for …" rather than +// an undifferentiated block of titles. +func TestSearchCountIsAnnounced(t *testing.T) { + ts := newServer(t) + _, _, body := get(t, ts, "/search?q=zigbee", nil) + if !strings.Contains(body, `id="search-results" aria-live="polite"`) { + t.Error("results container is not a live region") + } + if !regexp.MustCompile(`

\d+ results for`).MatchString(body) { + t.Error("result count is not announced as a status inside the live region") + } +} + +// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with +// the header set, the handler returns the block and not a whole page. +func TestHTMXSearchStillReturnsTheFragment(t *testing.T) { + ts := newServer(t) + code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"}) + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + if strings.Contains(frag, " - - +

} +// SearchFormLive is the search page's own form. HTMX swaps the results block in +// place, which is safe here and only here: #search-results is part of the page. +templ SearchFormLive(q string, class string) { + +} + +// searchField is the label and input both forms share. The id is derived from the +// form class, so two forms on one page never collide. +templ searchField(q string, class string) { + + +} + // Stage is the presentation panel. Everything about how it is fed comes from // the binding: route mode swaps in pre-rendered fragments, stream mode listens // on an event stream. The markup is identical either way. diff --git a/internal/web/templates/pages.templ b/internal/web/templates/pages.templ index 0a93b33..4c8e781 100644 --- a/internal/web/templates/pages.templ +++ b/internal/web/templates/pages.templ @@ -135,40 +135,67 @@ templ SearchPage(v SearchView) {

Search { strconv.Itoa(v.Indexed) } pages

Full-text search over page titles and body text. Served from a local index — no model, no network call.

- @SearchForm(v.Query, "page-search") + @SearchFormLive(v.Query, "page-search") @SearchResults(v) } } -// SearchResults is swapped into #search-results by HTMX. +// SearchResults is the results block as a page element: the live region wrapper +// plus its contents, which is what the whole page renders. Without JavaScript the +// form reloads the page and this markup arrives in the document. templ SearchResults(v SearchView) {
- if v.Query == "" { -

Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.

- } else if v.Failed { -

That query could not be run. Try fewer or simpler words.

- } else if len(v.Results) == 0 { -

No page matches { v.Query }. The evidence index lists everything by area.

- } else { -

{ strconv.Itoa(len(v.Results)) } results for { v.Query }

-
    - for _, r := range v.Results { -
  1. -

    { r.Title }

    -

    - { r.Area } - L{ strconv.Itoa(r.Level) } - { r.Confidence } -

    -

    @SafeSnippet(r.Snippet)

    -
  2. - } -
- } + @SearchResultsBody(v)
} +// SearchResultsBody is the results block without the live region. This is what an +// htmx request returns: the search page's form swaps it into #search-results with +// innerHTML, so returning the wrapper too would nest a second element with the +// same id inside the first and make the live region announce twice. +// +// Everything here arrives inside the polite live region, and the count carries +// role="status" on top of that, so the number is read as one utterance instead of +// a run of result titles. +templ SearchResultsBody(v SearchView) { + if v.Query == "" { +

Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “guardrails”.

+ } else if v.Failed { +

That query could not be run. Try fewer or simpler words.

+ } else if len(v.Results) == 0 { +

Nothing found for { v.Query }. Try a shorter or more general word, or browse an area:

+ @searchAreaLinks(v.Areas) + } else { +

{ strconv.Itoa(len(v.Results)) } results for { v.Query }

+
    + for _, r := range v.Results { +
  1. +

    { r.Title }

    +

    + { r.Area } + L{ strconv.Itoa(r.Level) } + { r.Confidence } +

    +

    @SafeSnippet(r.Snippet) +

    +
  2. + } +
+ } +} + +// searchAreaLinks is the way out of an empty result: the six areas plus the +// evidence index, so a failed search never leaves the reader at a dead end. +templ searchAreaLinks(areas []LinkView) { + +} + // StreamPage proves the panel can be re-pointed at a stream. The layout and the // panel markup are the ones every other page uses; only the binding differs. templ StreamPage(v StreamView) { diff --git a/internal/web/templates/view.go b/internal/web/templates/view.go index f2b0d94..fb70a34 100644 --- a/internal/web/templates/view.go +++ b/internal/web/templates/view.go @@ -151,6 +151,9 @@ type ( Total int Indexed int Failed bool + // Areas are the six area landings, offered as the way out of an + // empty result. Nothing here was ever a search hit. + Areas []LinkView } // StreamView is the SSE-driven stage demonstration page.