aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_blog.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/handlers_blog.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/handlers_blog.go')
-rw-r--r--internal/web/handlers_blog.go35
1 files changed, 35 insertions, 0 deletions
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 {