aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-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
13 files changed, 211 insertions, 15 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 {
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)