From 839343a3108734a15e331b542a20ed9c77ef582b Mon Sep 17 00:00:00 2001 From: grm Date: Wed, 16 Sep 2026 00:00:57 +0300 Subject: Add a search box module and a search page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_blog.go | 35 +++++++++++++ internal/web/handlers_layout.go | 6 ++- internal/web/handlers_pages.go | 2 +- internal/web/layout.go | 16 +++--- internal/web/routes.go | 3 +- internal/web/search.go | 64 +++++++++++++++++++++++ internal/web/search_test.go | 44 ++++++++++++++++ internal/web/static/blog.css | 12 +++++ internal/web/templates/blog/search.html | 23 ++++++++ internal/web/templates/dashboard/layout.html | 2 +- internal/web/templates/dashboard/module_form.html | 6 ++- internal/web/templates/layouts/blog.html | 6 +++ internal/web/web_test.go | 7 +-- 13 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 internal/web/search.go create mode 100644 internal/web/search_test.go create mode 100644 internal/web/templates/blog/search.html (limited to 'internal/web') diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index c6030f2..e842e84 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -4,8 +4,11 @@ import ( "encoding/xml" "errors" "net/http" + "regexp" "strconv" + "strings" "time" + "unicode/utf8" "github.com/gramanas/blogspace/internal/store" ) @@ -143,6 +146,38 @@ func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { s.render(w, r, "blog/tag.html", v) } +// handleBlogSearch lists the published posts matching ?q= (see searchPattern), +// from every page of the blog. A blank query shows just the box; no matches +// is an ordinary result, not a 404. +func (s *Server) handleBlogSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if utf8.RuneCountInString(q) > maxSearchRunes { + q = string([]rune(q)[:maxSearchRunes]) + } + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + v["q"] = q + pattern := searchPattern(q) + if pattern != "" { + n := pageNum(r) + posts, total, err := blogStore(r).SearchPublishedPosts(r.Context(), pattern, searchPerPage, (n-1)*searchPerPage) + if err != nil { + s.serverError(w, err) + return + } + re := regexp.MustCompile("(?is)" + pattern) // (?s): "." crosses lines as it does in Postgres; QuoteMeta output always compiles + hits := make([]searchHit, 0, len(posts)) + for _, p := range posts { + hits = append(hits, searchHit{Post: p, Snippet: searchSnippet(p.BodyMD, re)}) + } + v["hits"], v["total"], v["pageNum"], v["lastPage"] = hits, total, n, (total+searchPerPage-1)/searchPerPage + } + s.render(w, r, "blog/search.html", v) +} + func (s *Server) handleBlogPost(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).PageBySlug(r.Context(), r.PathValue("page")) if err != nil { diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go index 64d8ff4..d89cd18 100644 --- a/internal/web/handlers_layout.go +++ b/internal/web/handlers_layout.go @@ -126,6 +126,10 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { m.Count = 10 case "tagcloud": m.Title = i18n.T(blogLang, "Tag cloud") + case "search": + if area != "header" { + m.Title = i18n.T(blogLang, "Search") + } } m, err := blogStore(r).CreateModule(r.Context(), m) if err != nil { @@ -166,7 +170,7 @@ func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { if m == nil { return } - if !moduleHasSettings(m.Kind) { + if !moduleHasSettings(*m) { http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) return } diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index bc74f71..6b467ce 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -13,7 +13,7 @@ import ( // Page slugs that would collide with blog routes, or with management routes on the root domain. var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true, "favicon.ico": true, - "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true, "tag": true} + "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true, "tag": true, "search": true} func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { pages, err := blogStore(r).ListPages(r.Context()) diff --git a/internal/web/layout.go b/internal/web/layout.go index b8069a0..0d51068 100644 --- a/internal/web/layout.go +++ b/internal/web/layout.go @@ -12,9 +12,9 @@ import ( // Modules a blogger can put in each area. Header modules are the big // blocks; the side columns take the small boxes; the footer its own set. var moduleKinds = map[string][]string{ - "header": {"title", "logo", "menu"}, - "left": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, - "right": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, + "header": {"title", "logo", "menu", "search"}, + "left": {"menu", "search", "recent", "archive", "tags", "tagcloud", "logo", "html"}, + "right": {"menu", "search", "recent", "archive", "tags", "tagcloud", "logo", "html"}, "above": {"html"}, "below": {"html"}, "footer": {"text", "rss", "sitemap", "html"}, @@ -29,6 +29,7 @@ var moduleNames = map[string]string{ "recent": "Recent posts", "tags": "Tags", "tagcloud": "Tag cloud", + "search": "Search box", "html": "Custom HTML", "rss": "RSS link", "text": "Footer text", @@ -52,11 +53,14 @@ func allowedKind(area, kind string) bool { return false } -// moduleHasSettings says whether the module has an edit page. -func moduleHasSettings(kind string) bool { - switch kind { +// moduleHasSettings says whether the module has an edit page. Header modules +// show no heading, so a search box there has nothing to edit. +func moduleHasSettings(m store.Module) bool { + switch m.Kind { case "html", "text", "recent", "archive", "sitemap", "tags", "tagcloud": return true + case "search": + return m.Area != "header" } return false } diff --git a/internal/web/routes.go b/internal/web/routes.go index a8a60d2..563639c 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -95,7 +95,8 @@ func (s *Server) rootRoutes() http.Handler { func (s *Server) blogRoutes(m *http.ServeMux, wrap func(http.HandlerFunc) http.HandlerFunc) { m.HandleFunc("GET /{$}", wrap(s.handleBlogHome)) m.HandleFunc("GET /feed.xml", wrap(s.handleBlogFeed)) - m.HandleFunc("GET /tag/{tag}", wrap(s.handleBlogTag)) // literal segment: wins over /{page}/{post} + m.HandleFunc("GET /tag/{tag}", wrap(s.handleBlogTag)) // literal segments: win over /{page} and /{page}/{post} + m.HandleFunc("GET /search", wrap(s.handleBlogSearch)) m.HandleFunc("GET /{page}", wrap(s.handleBlogPage)) m.HandleFunc("GET /{page}/{post}", wrap(s.handleBlogPost)) } diff --git a/internal/web/search.go b/internal/web/search.go new file mode 100644 index 0000000..ef554ae --- /dev/null +++ b/internal/web/search.go @@ -0,0 +1,64 @@ +package web + +import ( + "html/template" + "regexp" + "strings" + "unicode/utf8" + + "github.com/gramanas/blogspace/internal/store" +) + +const ( + searchPerPage = 20 + maxSearchRunes = 100 // longer queries are cut; nobody types more on purpose + snippetContext = 80 // runes kept on each side of the match +) + +// searchPattern turns what the reader typed into the regular expression both +// Postgres (~*) and Go run: each word literal, the spaces between them "anything +// in between", so "go tem" finds "Go templates". Blank → "". +func searchPattern(q string) string { + words := strings.Fields(q) + for i, w := range words { + words[i] = regexp.QuoteMeta(w) + } + return strings.Join(words, ".*") +} + +// searchHit is one result: the post plus the excerpt the list shows. +type searchHit struct { + store.Post + Snippet template.HTML +} + +// searchSnippet is a short piece of the Markdown body around the first match, +// with the match marked; when only the title matched it is the body's start. +func searchSnippet(body string, re *regexp.Regexp) template.HTML { + text := strings.Join(strings.Fields(body), " ") + loc := re.FindStringIndex(text) + if loc == nil { + loc = []int{0, 0} + } + before, match, after := text[:loc[0]], text[loc[0]:loc[1]], text[loc[1]:] + for n := utf8.RuneCountInString(before); n > snippetContext; n-- { // rune by rune: never cut inside one + _, size := utf8.DecodeRuneInString(before) + before = before[size:] + } + if len(before) < loc[0] { + before = "…" + before + } + if utf8.RuneCountInString(after) > snippetContext { + i := 0 + for n := 0; n < snippetContext; n++ { + _, size := utf8.DecodeRuneInString(after[i:]) + i += size + } + after = after[:i] + "…" + } + out := template.HTMLEscapeString(before) + if match != "" { + out += "" + template.HTMLEscapeString(match) + "" + } + return template.HTML(out + template.HTMLEscapeString(after)) +} 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 Templates\n\nare fine.", re)) + if got != "Go <b>Templates</b> 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)+" tem ") || !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) + } +} diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index e7a6c96..e2bf588 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -80,6 +80,18 @@ img { max-width: 100%; height: auto; } .tc-3 { font-size: 1.2em; } .tc-4 { font-size: 1.45em; } .tc-5 { font-size: 1.75em; } +.site-search { border-bottom: 1px solid rgba(0,0,0,0.1); } +.search-inner { padding: 0.5em 1em; } +.search-form { margin: 0; } +.search-form input { padding: 0.35em 0.5em; font: inherit; max-width: 100%; } +.search-form button { padding: 0.35em 0.8em; font: inherit; } +.mod-search input { width: 100%; margin-bottom: 0.4em; } +.search-page { margin-bottom: 1.5em; } +.search-results { list-style: none; margin: 0; padding: 0; } +.search-results li { margin: 0 0 1.2em; } +.search-results .post-date { display: inline; margin: 0 0 0 0.6em; } +.snippet { margin: 0.2em 0 0; font-size: 0.92em; opacity: 0.85; } +.snippet mark { background: rgba(255,220,0,0.45); color: inherit; } .mod-archive details { margin: 0.2em 0; } .mod-archive details details { margin-left: 1em; } .mod-archive summary { cursor: pointer; padding: 0.15em 0; } diff --git a/internal/web/templates/blog/search.html b/internal/web/templates/blog/search.html new file mode 100644 index 0000000..b146f5e --- /dev/null +++ b/internal/web/templates/blog/search.html @@ -0,0 +1,23 @@ +{{define "title"}}{{if .Data.q}}{{tf "Results for “%s”" .Data.q}}{{else}}{{t "Search"}}{{end}} · {{.Blog.Title}}{{end}} +{{define "content"}} +

{{if .Data.q}}{{tf "Results for “%s”" .Data.q}}{{else}}{{t "Search"}}{{end}}

+
{{template "searchform" .}}
+{{if not .Data.q}}

{{t "Type a word or two to find posts."}}

+{{else if not .Data.hits}}

{{tf "Nothing matched “%s”." .Data.q}}

+{{else}} +

{{if eq .Data.total 1}}{{t "One post matches."}}{{else}}{{tf "%d posts match." .Data.total}}{{end}}

+ +{{if gt .Data.lastPage 1}} +
+ {{if gt .Data.pageNum 1}}← {{t "Newer"}}{{end}} + {{tf "Page %d of %d" .Data.pageNum .Data.lastPage}} + {{if lt .Data.pageNum .Data.lastPage}}{{t "Older"}} →{{end}} +
+{{end}} +{{end}} +{{end}} diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html index 48b6778..5c9332a 100644 --- a/internal/web/templates/dashboard/layout.html +++ b/internal/web/templates/dashboard/layout.html @@ -102,7 +102,7 @@ {{t (index $r.Data.names .Kind)}}{{with modsummary .}} — {{.}}{{end}} {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}{{t "no logo chosen"}} {{t "pick one on the"}} {{t "Design"}} {{t "tab."}}{{end}} - {{if modsettings .Kind}}{{t "edit"}} {{end}}{{t "remove"}} + {{if modsettings .}}{{t "edit"}} {{end}}{{t "remove"}} {{end}} {{else}}

{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}

{{end}}
diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html index 8b28f2e..a0fe95b 100644 --- a/internal/web/templates/dashboard/module_form.html +++ b/internal/web/templates/dashboard/module_form.html @@ -5,8 +5,8 @@

{{.Data.name}} {{tf "in the %s" (lower .Data.area)}}

- {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (eq $m.Kind "tags") (eq $m.Kind "tagcloud") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} - + {{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (eq $m.Kind "tags") (eq $m.Kind "tagcloud") (eq $m.Kind "search") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} + {{end}} {{if eq $m.Kind "recent"}}
@@ -20,6 +20,8 @@

{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}

{{else if eq $m.Kind "tagcloud"}}

{{t "Every tag in alphabetical order, bigger the more posts use it."}}

+ {{else if eq $m.Kind "search"}} +

{{t "A box that searches the published posts of every page by title and text; the results open on your blog’s search page. Case does not matter, and several words find posts that contain them in that order, with anything in between."}}

{{else if eq $m.Kind "html"}} diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index cf00272..3e854f6 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -76,6 +76,8 @@ {{- else if eq $m.Kind "tagcloud"}}
{{template "modtitle" $m}}

{{range $r.Data.tagcloud}}{{.Name}} {{else}}{{t "No tags yet."}}{{end}}

+{{- else if eq $m.Kind "search"}}{{if eq $m.Area "header"}} +{{else}}{{end}} {{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}
{{html $m.Body}}
{{else}}
{{template "modtitle" $m}}{{html $m.Body}}
{{end}} {{- else if eq $m.Kind "rss"}}

{{t "RSS feed"}}

@@ -85,6 +87,10 @@ {{- end}}{{end}} +{{/* The search box: a plain GET form, so it works without JavaScript. .Data.q + only exists on the search page, hence the with. */}} +{{define "searchform"}}
{{end}} + {{define "modtitle"}}{{if .Title}}

{{.Title}}

{{end}}{{end}} {{define "menulinks"}}{{range .Data.menu}}{{.Text}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index afb289a..b803003 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -74,7 +74,7 @@ func TestRootRoutePrecedence(t *testing.T) { t.Errorf("%s: got %d, want %d", path, rec.Code, want) } } - for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico", "tag"} { + for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico", "tag", "search"} { if !reservedPageSlugs[slug] { t.Errorf("page slug %q should be reserved", slug) } @@ -192,7 +192,8 @@ func TestModuleKinds(t *testing.T) { if !allowedKind("header", "menu") || allowedKind("header", "archive") || allowedKind("above", "menu") || allowedKind("attic", "html") { t.Error("allowedKind") } - if !moduleHasSettings("html") || moduleHasSettings("rss") { + if !moduleHasSettings(store.Module{Kind: "html"}) || moduleHasSettings(store.Module{Kind: "rss"}) || + !moduleHasSettings(store.Module{Kind: "search", Area: "left"}) || moduleHasSettings(store.Module{Kind: "search", Area: "header"}) { t.Error("moduleHasSettings") } if got := moduleSummary("en", store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { @@ -379,7 +380,7 @@ func TestAllTemplatesParse(t *testing.T) { for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", - "blog/page.html", "blog/post.html", "blog/tag.html", "blog/404.html"} { + "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} { for _, l := range i18n.Languages() { if _, err := tpl.get(l.Code, name); err != nil { t.Errorf("%s (%s): %v", name, l.Code, err) -- cgit v1.2.3