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/search.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 internal/web/search.go (limited to 'internal/web/search.go') 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)) +} -- cgit v1.2.3