aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/search_test.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-16 00:00:57 +0300
committergrm <grm@eyesin.space>2026-09-16 00:00:57 +0300
commit839343a3108734a15e331b542a20ed9c77ef582b (patch)
treea6aabc8719c16398bf1ccd874a7efe865b364a9e /internal/web/search_test.go
parent628dcb814ece6335667e2b719049728aa161d8c9 (diff)
downloadblogspace-839343a3108734a15e331b542a20ed9c77ef582b.tar.gz
blogspace-839343a3108734a15e331b542a20ed9c77ef582b.tar.bz2
blogspace-839343a3108734a15e331b542a20ed9c77ef582b.zip
Add a search box module and a search page
Readers had no way to find a post. The new "search" module goes in the header (a bar under the nav) or a side column (a box with a heading) and is a plain GET form to /search, so it works without JavaScript. The results page lists the published posts of every page whose title or Markdown body matches the query — case-insensitive, each word literal, spaces meaning "anything in between", in order — as title, date and a short snippet with the match marked, 20 per page. modules.kind is a CHECK constraint, so a migration widens it; "search" becomes a reserved page slug so the literal route keeps winning over /{page}. moduleHasSettings now takes the module: a header search box has no heading, hence nothing to edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/search_test.go')
-rw-r--r--internal/web/search_test.go44
1 files changed, 44 insertions, 0 deletions
diff --git a/internal/web/search_test.go b/internal/web/search_test.go
new file mode 100644
index 0000000..6a9b0a5
--- /dev/null
+++ b/internal/web/search_test.go
@@ -0,0 +1,44 @@
+package web
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestSearchPattern(t *testing.T) {
+ for q, want := range map[string]string{
+ "go tem": "go.*tem",
+ " a.b (c) ": `a\.b.*\(c\)`,
+ "one": "one",
+ " ": "",
+ "": "",
+ } {
+ if got := searchPattern(q); got != want {
+ t.Errorf("searchPattern(%q) = %q, want %q", q, got, want)
+ }
+ }
+ // The pattern is what Postgres gets, and also what Go runs for the snippet.
+ re := regexp.MustCompile("(?is)" + searchPattern("go tem"))
+ if !re.MatchString("Go\ntemplates") || re.MatchString("templates for go") {
+ t.Error("words match in order, across lines, any case")
+ }
+}
+
+func TestSearchSnippet(t *testing.T) {
+ re := regexp.MustCompile("(?is)" + searchPattern("tem"))
+ got := string(searchSnippet("Go <b>Templates</b>\n\nare fine.", re))
+ if got != "Go &lt;b&gt;<mark>Tem</mark>plates&lt;/b&gt; are fine." {
+ t.Errorf("snippet: %q", got)
+ }
+ // Long bodies are trimmed around the match, rune by rune.
+ body := strings.Repeat("α", 200) + " tem " + strings.Repeat("ω", 200)
+ got = string(searchSnippet(body, re))
+ if !strings.HasPrefix(got, "…"+strings.Repeat("α", 79)+" <mark>tem</mark> ") || !strings.HasSuffix(got, strings.Repeat("ω", 79)+"…") {
+ t.Errorf("trimmed snippet: %q", got)
+ }
+ // Only the title matched: the body's start, no mark.
+ if got := string(searchSnippet("Nothing here.", re)); got != "Nothing here." {
+ t.Errorf("title-only snippet: %q", got)
+ }
+}