aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/search.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.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.go')
-rw-r--r--internal/web/search.go64
1 files changed, 64 insertions, 0 deletions
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 += "<mark>" + template.HTMLEscapeString(match) + "</mark>"
+ }
+ return template.HTML(out + template.HTMLEscapeString(after))
+}