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 --- AGENTS.md | 28 +++++++--- README.md | 5 +- internal/db/migrations/blog/00005_search.sql | 11 ++++ internal/i18n/el.go | 8 +++ internal/store/posts.go | 15 ++++++ 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 +-- 18 files changed, 270 insertions(+), 23 deletions(-) create mode 100644 internal/db/migrations/blog/00005_search.sql create mode 100644 internal/web/search.go create mode 100644 internal/web/search_test.go create mode 100644 internal/web/templates/blog/search.html diff --git a/AGENTS.md b/AGENTS.md index 18bc043..5e94b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,8 @@ internal/web/ server.go (host router, middleware, render helpers) is a single segment on purpose (a `/static/` prefix pattern conflicts with `/{page}/{post}`); static files must stay flat. The public `GET /tag/{tag}` is a literal first segment, so it beats `/{page}/{post}` - on every host (`tag` is reserved too). A `{page}` wildcard never matches + on every host (`tag` is reserved too), and so is `GET /search` (`search` + reserved). A `{page}` wildcard never matches a trailing slash, so listings put `?p=N` straight after `base` (`/`, `/news`, `/tag/go`) — `/news/?p=2` would be a 404. - **Auth**: login lives only at `/webadmin` on the root domain (deliberately @@ -155,9 +156,11 @@ internal/web/ server.go (host router, middleware, render helpers) `handlers_layout.go`, `/b/{sub}/layout…`): a blog page is six areas — `header`, `left`, `above`, `below` (the two custom-HTML slots inside the main column, one module at most), `right`, `footer` — each an ordered - list of modules (`kind`: title, logo, menu, archive, recent, tags, - tagcloud, html, rss, text, sitemap; `moduleKinds` says which kinds an area - accepts). Modules have an optional `title` (heading), `body` (raw HTML or + list of modules (`kind`: title, logo, menu, search, archive, recent, tags, + tagcloud, html, rss, text, sitemap — the list is also a CHECK constraint + on `modules.kind`, so a new kind needs a migration; `moduleKinds` says + which kinds an area accepts; `moduleHasSettings` takes the module because a header search box + has nothing to edit). Modules have an optional `title` (heading), `body` (raw HTML or footer text) and `count` (recent posts; tags to list, 0 = all). `blogView` builds a `Layout` (`buildLayout`, areas that are switched off are dropped) and only fetches the archive index / recent posts / tag counts when a @@ -291,6 +294,19 @@ internal/web/ server.go (host router, middleware, render helpers) which also holds the article loop + pager shared by `blog/page.html` and `blog/tag.html`); the two modules are `tags` (by use, `count`) and `tagcloud` (alphabetical, `cloudSizes` → `tc-1…tc-5`). +- **Search** (`web/search.go`, `handleBlogSearch`, `GET /search?q=&p=`, + `blog/search.html`): the `search` module is a plain GET form (header bar or + side box, `searchform` in `layouts/blog.html`; the results page shows one + too). `searchPattern` turns the query into the regex both Postgres and Go + run: words `regexp.QuoteMeta`'d and joined with `.*`, so the reader's words + are literal, case does not matter (`~*` / `(?is)`) and spaces mean + "anything in between", in order. `SearchPublishedPosts` matches it against + `title || '\n' || body_md` of published posts from every page, 20 per page + (`searchPerPage`); queries are cut at 100 runes. Results are title, date + and `searchSnippet` (the Markdown around the first match, escaped, the + match in ``; the body's start when only the title matched). A blank + or unmatched query is a normal 200, not a 404; the pager is the page's + own (`&p=N` after `?q=`), not the `postlist` one. - **Slugs**: auto-generated from the title; on collision generated slugs get `-2`, `-3`…, user-typed slugs return a 409 with a message. `slug.Clean` is `Make` without the "untitled" fallback, for inputs that @@ -322,7 +338,7 @@ superadmin password to `admin`. Production refuses both. `internal/db/migrations/control/` for users and the registry; goose `-- +goose Up/Down` sections; they run automatically at startup. Never edit an applied migration. Blog chain so far: `00001_init`, `00002_language`, - `00003_files`, `00004_tags`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after + `00003_files`, `00004_tags`, `00005_search`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration @@ -338,5 +354,5 @@ superadmin password to `admin`. Production refuses both. ## Things deliberately not built (ask before adding) Drafts/preview, custom CSS, comments, multiple blogs per user, email, -user self-registration, custom domains per blog, image resizing, search, +user self-registration, custom domains per blog, image resizing, sanitising the custom HTML module (owner's decision, see above). diff --git a/README.md b/README.md index ea97880..901616c 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. with it. - **Layout** tab: the page is five areas — header, left column, main content, right column, footer — each holding modules in any order: blog title, logo, menu, archive - (posts by year and month), recent posts, tags (with counts), tag cloud, custom HTML, - footer text, RSS link, site map. + (posts by year and month), recent posts, tags (with counts), tag cloud, a search + box (finds posts by title and text, any case; several words match in order with + anything in between), custom HTML, footer text, RSS link, site map. Column widths are percentages; on phones the columns stack under the posts. The **menu** mixes blog pages and outside links. - **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon). diff --git a/internal/db/migrations/blog/00005_search.sql b/internal/db/migrations/blog/00005_search.sql new file mode 100644 index 0000000..59bb4bd --- /dev/null +++ b/internal/db/migrations/blog/00005_search.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- One more layout module: the search box. +ALTER TABLE modules DROP CONSTRAINT modules_kind_check; +ALTER TABLE modules ADD CONSTRAINT modules_kind_check + CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap', 'tags', 'tagcloud', 'search')); + +-- +goose Down +DELETE FROM modules WHERE kind = 'search'; +ALTER TABLE modules DROP CONSTRAINT modules_kind_check; +ALTER TABLE modules ADD CONSTRAINT modules_kind_check + CHECK (kind IN ('title', 'logo', 'menu', 'archive', 'recent', 'html', 'rss', 'text', 'sitemap', 'tags', 'tagcloud')); diff --git a/internal/i18n/el.go b/internal/i18n/el.go index c4f0fac..bb0fcd8 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -323,6 +323,7 @@ var el = map[string]string{ "Recent posts": "Πρόσφατες δημοσιεύσεις", "Tags": "Ετικέτες", "Tag cloud": "Σύννεφο ετικετών", + "Search box": "Πλαίσιο αναζήτησης", "Custom HTML": "Δικό σας HTML", "RSS link": "Σύνδεσμος RSS", "Footer text": "Κείμενο υποσέλιδου", @@ -344,6 +345,7 @@ var el = map[string]string{ "How many tags": "Πόσες ετικέτες", "Your tags with the number of posts in each, most used first. Only published posts count.": "Οι ετικέτες σας με τον αριθμό δημοσιεύσεων της καθεμίας, οι πιο χρησιμοποιημένες πρώτα. Μετρούν μόνο οι ορατές δημοσιεύσεις.", "Every tag in alphabetical order, bigger the more posts use it.": "Όλες οι ετικέτες αλφαβητικά, μεγαλύτερες όσο περισσότερες δημοσιεύσεις τις χρησιμοποιούν.", + "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.": "Ένα πλαίσιο που ψάχνει τις δημοσιευμένες δημοσιεύσεις κάθε σελίδας στον τίτλο και το κείμενο· τα αποτελέσματα ανοίγουν στη σελίδα αναζήτησης του ιστολογίου σας. Πεζά και κεφαλαία δεν έχουν σημασία, και περισσότερες λέξεις βρίσκουν δημοσιεύσεις που τις περιέχουν με αυτή τη σειρά, με οτιδήποτε ανάμεσά τους.", "Hello!": "Γεια σας!", "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.": "Μπαίνει στη σελίδα σας ακριβώς όπως γράφεται — ενσωματώσεις, σήματα και scripts δουλεύουν όλα. Ό,τι είναι λάθος εδώ (π.χ. μια ετικέτα που δεν κλείνει) μπορεί να χαλάσει τη σελίδα γύρω του, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση. Έως 20 KB.", "© 2026 Me": "© 2026 Εγώ", @@ -486,4 +488,10 @@ var el = map[string]string{ "RSS feed": "Ροή RSS", "No tags yet.": "Δεν υπάρχουν ετικέτες ακόμα.", "Tagged “%s”": "Με ετικέτα «%s»", + "Search…": "Αναζήτηση…", + "Results for “%s”": "Αποτελέσματα για «%s»", + "Type a word or two to find posts.": "Γράψτε μία ή δύο λέξεις για να βρείτε δημοσιεύσεις.", + "Nothing matched “%s”.": "Τίποτα δεν ταιριάζει με «%s».", + "One post matches.": "Μία δημοσίευση ταιριάζει.", + "%d posts match.": "%d δημοσιεύσεις ταιριάζουν.", } diff --git a/internal/store/posts.go b/internal/store/posts.go index 706d132..1bffd51 100644 --- a/internal/store/posts.go +++ b/internal/store/posts.go @@ -75,6 +75,21 @@ func (bs *BlogStore) PublishedPosts(ctx context.Context, pageID int64, limit, of return posts, total, err } +// SearchPublishedPosts returns a page of the published posts, from every page +// of the blog, whose title or Markdown body matches the case-insensitive +// regular expression; the caller builds it from the reader's words. Title and +// body are searched as one text so a query can span them. +func (bs *BlogStore) SearchPublishedPosts(ctx context.Context, pattern string, limit, offset int) ([]Post, int, error) { + posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id + WHERE p.published AND (p.title || E'\n' || p.body_md) ~* $1 ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, pattern, limit, offset) + if err != nil { + return nil, 0, err + } + var total int + err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE published AND (title || E'\n' || body_md) ~* $1`, pattern).Scan(&total) + return posts, total, err +} + // PostRef is the light-weight row the archive module lists. type PostRef struct { Title string 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}}

+
    +{{range .Data.hits}}
  • + {{.Title}}{{if $.Data.theme.ShowDates}} {{end}} +

    {{.Snippet}}

    +
  • +{{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