search: make the navigation form work from every page (AUD-151)

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)
This commit is contained in:
2026-10-28 15:45:54 +11:00
parent 286aa12bb0
commit d793ab0871
8 changed files with 400 additions and 36 deletions

View File

@@ -413,9 +413,10 @@ func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
_, _ = io.WriteString(w, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
}
// 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)

View File

@@ -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, "<html") {
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
}
if !strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q did not return #search-results", tc.q)
// The fragment is swapped into #search-results with innerHTML, so it
// must not carry the wrapper again: a second element with the same id
// inside the first is invalid and makes the live region announce twice.
if strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
}
if !strings.Contains(frag, "results for") {
t.Errorf("htmx search %q returned no results", tc.q)
}
}
}
@@ -369,3 +376,190 @@ func TestFirstByteBudget(t *testing.T) {
t.Errorf("first byte in %s, budget is 100ms", elapsed)
}
}
// navSearchForm isolates the navigation search form's opening tag and markup.
// The layout has one such form on every page; there is nothing to search for if
// it is missing.
func navSearchForm(t *testing.T, path, body string) string {
t.Helper()
marker := strings.Index(body, `class="search nav-search"`)
if marker < 0 {
t.Fatalf("%s: no navigation search form", path)
}
start := strings.LastIndex(body[:marker], "<form")
end := strings.Index(body[marker:], "</form>")
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), "<!doctype html>") {
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(`<p class="search-count" role="status">\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, "<html") {
t.Error("htmx search returned a whole page")
}
if strings.Contains(frag, `id="search-results"`) {
t.Error("htmx search re-declares #search-results inside itself")
}
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
t.Error("htmx search did not return the results block")
}
}

View File

@@ -66,6 +66,16 @@ func (Shell) Nav(area string) []templates.NavItem {
return items
}
// AreaLinks is the six areas as plain links. The search page offers them when a
// query matches nothing, so an empty result is still a way into the site.
func (Shell) AreaLinks() []templates.LinkView {
out := make([]templates.LinkView, 0, len(content.Areas))
for _, a := range content.Areas {
out = append(out, templates.LinkView{Label: a.Title, Href: "/" + a.Key + "/"})
}
return out
}
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
func (Shell) RouteBinding() stage.Binding {
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
@@ -114,6 +124,7 @@ func (sh Shell) SearchView(indexed int, q string, results []search.Result, faile
Results: results,
Indexed: indexed,
Failed: failed,
Areas: sh.AreaLinks(),
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
)
// newSite loads the repository's own content. If this fails, every other test
@@ -172,3 +173,93 @@ func TestBuildWritesEveryPublishedPage(t *testing.T) {
}
}
}
// copyTree copies a directory tree. The freshness test needs its own content
// tree so it can change a page without touching the repository.
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, raw, 0o644)
})
}
// TestSearchIndexFollowsContent is the AUD-151 freshness line: a content change
// followed by a build is reflected in search. The deploy path is exactly this —
// deploy/deploy.sh runs `make dist` before it stages a release, and the build
// rewrites dist/search.db from content/ every time (search.Build removes the old
// file), so search can never serve an index from a previous content tree.
func TestSearchIndexFollowsContent(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
contentDir := filepath.Join(dir, "content")
if err := copyTree(filepath.Join(root, "content"), contentDir); err != nil {
t.Fatalf("copy content: %v", err)
}
out := filepath.Join(dir, "dist")
build := func() {
t.Helper()
s, err := New(Config{Root: root, ContentDir: contentDir, OutDir: out})
if err != nil {
t.Fatalf("load site: %v", err)
}
if err := s.Build(); err != nil {
t.Fatalf("build: %v", err)
}
}
query := func(q string) []search.Result {
t.Helper()
idx, err := search.Open(filepath.Join(out, IndexFile))
if err != nil {
t.Fatalf("open index: %v", err)
}
defer idx.Close()
results, err := idx.Query(q, 20)
if err != nil {
t.Fatalf("query %q: %v", q, err)
}
return results
}
const token = "kumquatprotocol"
build()
if got := query(token); len(got) != 0 {
t.Fatalf("token is already indexed in %d results; the test proves nothing", len(got))
}
page := filepath.Join(contentDir, "edge", "edge-fleet.md")
raw, err := os.ReadFile(page)
if err != nil {
t.Fatal(err)
}
added := string(raw) + "\n\nA " + token + " harness keeps the nodes in step.\n"
if err := os.WriteFile(page, []byte(added), 0o644); err != nil {
t.Fatal(err)
}
build()
results := query(token)
if len(results) == 0 {
t.Fatal("content change is not in the rebuilt index")
}
if results[0].URL != "/edge/edge-fleet/" {
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
}
}

View File

@@ -882,6 +882,15 @@ samp {
color: var(--text-dim);
}
.search-areas {
margin: 0.6rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.1rem;
}
.results {
margin: 0;
padding: 0;

View File

@@ -101,17 +101,43 @@ templ Breadcrumbs(crumbs []Crumb) {
}
}
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
// SearchForm is the plain GET form used in the navigation and the landing page.
// It navigates to /search and carries no htmx attributes, because #search-results
// only exists on the search page: an hx-target that is not in the document makes
// htmx discard the response, which is a form that looks like it works and does
// nothing. A plain GET works from every page and with JavaScript disabled.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search"
hx-get="/search" hx-target="#search-results" hx-swap="innerHTML" hx-push-url="true">
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
<form class={ "search", class } action="/search" method="get" role="search">
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// 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) {
<form
class={ "search", class }
action="/search"
method="get"
role="search"
hx-get="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-push-url="true"
>
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// 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) {
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
}
// 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.

View File

@@ -135,40 +135,67 @@ templ SearchPage(v SearchView) {
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
</header>
@SearchForm(v.Query, "page-search")
@SearchFormLive(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// 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) {
<div id="search-results" aria-live="polite">
if v.Query == "" {
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
} else {
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
</li>
}
</ol>
}
@SearchResultsBody(v)
</div>
}
// 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 == "" {
<p class="search-empty">Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">Nothing found for <q>{ v.Query }</q>. Try a shorter or more general word, or browse an area:</p>
@searchAreaLinks(v.Areas)
} else {
<p class="search-count" role="status">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)
</p>
</li>
}
</ol>
}
}
// 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) {
<ul class="search-areas">
for _, a := range areas {
<li><a href={ templ.SafeURL(a.Href) }>{ a.Label }</a></li>
}
<li><a href="/evidence/">Evidence index</a></li>
</ul>
}
// 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) {

View File

@@ -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.