aboutsummaryrefslogtreecommitdiffstats
path: root/internal
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
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')
-rw-r--r--internal/db/migrations/blog/00005_search.sql11
-rw-r--r--internal/i18n/el.go8
-rw-r--r--internal/store/posts.go15
-rw-r--r--internal/web/handlers_blog.go35
-rw-r--r--internal/web/handlers_layout.go6
-rw-r--r--internal/web/handlers_pages.go2
-rw-r--r--internal/web/layout.go16
-rw-r--r--internal/web/routes.go3
-rw-r--r--internal/web/search.go64
-rw-r--r--internal/web/search_test.go44
-rw-r--r--internal/web/static/blog.css12
-rw-r--r--internal/web/templates/blog/search.html23
-rw-r--r--internal/web/templates/dashboard/layout.html2
-rw-r--r--internal/web/templates/dashboard/module_form.html6
-rw-r--r--internal/web/templates/layouts/blog.html6
-rw-r--r--internal/web/web_test.go7
16 files changed, 245 insertions, 15 deletions
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 += "<mark>" + template.HTMLEscapeString(match) + "</mark>"
+ }
+ 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 <b>Templates</b>\n\nare fine.", re))
+ if got != "Go &lt;b&gt;<mark>Tem</mark>plates&lt;/b&gt; 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)+" <mark>tem</mark> ") || !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"}}
+<h1 class="page-title">{{if .Data.q}}{{tf "Results for “%s”" .Data.q}}{{else}}{{t "Search"}}{{end}}</h1>
+<div class="search-page">{{template "searchform" .}}</div>
+{{if not .Data.q}}<p class="muted">{{t "Type a word or two to find posts."}}</p>
+{{else if not .Data.hits}}<p class="muted">{{tf "Nothing matched “%s”." .Data.q}}</p>
+{{else}}
+<p class="muted small">{{if eq .Data.total 1}}{{t "One post matches."}}{{else}}{{tf "%d posts match." .Data.total}}{{end}}</p>
+<ul class="search-results">
+{{range .Data.hits}}<li>
+ <a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a>{{if $.Data.theme.ShowDates}} <span class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</span>{{end}}
+ <p class="snippet">{{.Snippet}}</p>
+</li>
+{{end}}</ul>
+{{if gt .Data.lastPage 1}}
+<div class="pager">
+ {{if gt .Data.pageNum 1}}<a href="/search?q={{.Data.q}}&amp;p={{sub .Data.pageNum 1}}">&larr; {{t "Newer"}}</a>{{end}}
+ <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span>
+ {{if lt .Data.pageNum .Data.lastPage}}<a href="/search?q={{.Data.q}}&amp;p={{add .Data.pageNum 1}}">{{t "Older"}} &rarr;</a>{{end}}
+</div>
+{{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 @@
</td>
<td>{{t (index $r.Data.names .Kind)}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}}
{{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <span class="muted small">{{t "pick one on the"}} <a href="/b/{{$r.Blog.Subdomain}}/design#logo">{{t "Design"}}</a> {{t "tab."}}</span>{{end}}</td>
- <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">{{t "edit"}}</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">{{t "remove"}}</a></td>
+ <td class="nowrap actions">{{if modsettings .}}<a href="{{$u}}/modules/{{.ID}}/edit">{{t "edit"}}</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">{{t "remove"}}</a></td>
</tr>{{end}}
</table>{{else}}<p class="muted">{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}</p>{{end}}
<form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod">
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 @@
<h1>{{.Data.name}} <span class="muted small">{{tf "in the %s" (lower .Data.area)}}</span></h1>
<form method="post">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- {{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")))}}
- <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{else if eq $m.Kind "tags"}}{{t "Tags"}}{{else if eq $m.Kind "tagcloud"}}{{t "Tag cloud"}}{{end}}"></label>
+ {{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")))}}
+ <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{else if eq $m.Kind "tags"}}{{t "Tags"}}{{else if eq $m.Kind "tagcloud"}}{{t "Tag cloud"}}{{else if eq $m.Kind "search"}}{{t "Search"}}{{end}}"></label>
{{end}}
{{if eq $m.Kind "recent"}}
<div class="row"><label>{{t "How many posts"}}<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
@@ -20,6 +20,8 @@
<p class="muted small">{{t "Your tags with the number of posts in each, most used first. Only published posts count."}}</p>
{{else if eq $m.Kind "tagcloud"}}
<p class="muted small">{{t "Every tag in alphabetical order, bigger the more posts use it."}}</p>
+ {{else if eq $m.Kind "search"}}
+ <p class="muted small">{{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."}}</p>
{{else if eq $m.Kind "html"}}
<label for="mod-body">HTML</label>
<textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;">{{$m.Body}}</textarea>
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"}}<div class="mod mod-tagcloud">{{template "modtitle" $m}}
<p class="tagcloud">{{range $r.Data.tagcloud}}<a class="tc-{{.Size}}" href="/tag/{{.Slug}}" title="{{.Count}}">{{.Name}}</a> {{else}}<span class="muted">{{t "No tags yet."}}</span>{{end}}</p>
</div>
+{{- else if eq $m.Kind "search"}}{{if eq $m.Area "header"}}<div class="site-search"><div class="wrap search-inner">{{template "searchform" $r}}</div></div>
+{{else}}<div class="mod mod-search">{{template "modtitle" $m}}{{template "searchform" $r}}</div>{{end}}
{{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}<div class="custom-html custom-html-{{$m.Area}}">{{html $m.Body}}</div>
{{else}}<div class="mod mod-html">{{template "modtitle" $m}}{{html $m.Body}}</div>{{end}}
{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">{{t "RSS feed"}}</a></p>
@@ -85,6 +87,10 @@
</div>
{{- 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"}}<form method="get" action="/search" class="search-form"><input type="search" name="q"{{with .Data.q}} value="{{.}}"{{end}} placeholder="{{t "Search…"}}" size="18"> <button type="submit">{{t "Search"}}</button></form>{{end}}
+
{{define "modtitle"}}{{if .Title}}<h3 class="mod-title">{{.Title}}</h3>{{end}}{{end}}
{{define "menulinks"}}{{range .Data.menu}}<a href="{{.Href}}"{{if and .PageID $.Data.page (eq $.Data.page.ID (deref64 .PageID))}} class="active"{{end}}>{{.Text}}</a>
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)