diff options
| author | grm <grm@eyesin.space> | 2026-09-16 00:00:57 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-16 00:00:57 +0300 |
| commit | 839343a3108734a15e331b542a20ed9c77ef582b (patch) | |
| tree | a6aabc8719c16398bf1ccd874a7efe865b364a9e /internal/web | |
| parent | 628dcb814ece6335667e2b719049728aa161d8c9 (diff) | |
| download | blogspace-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')
| -rw-r--r-- | internal/web/handlers_blog.go | 35 | ||||
| -rw-r--r-- | internal/web/handlers_layout.go | 6 | ||||
| -rw-r--r-- | internal/web/handlers_pages.go | 2 | ||||
| -rw-r--r-- | internal/web/layout.go | 16 | ||||
| -rw-r--r-- | internal/web/routes.go | 3 | ||||
| -rw-r--r-- | internal/web/search.go | 64 | ||||
| -rw-r--r-- | internal/web/search_test.go | 44 | ||||
| -rw-r--r-- | internal/web/static/blog.css | 12 | ||||
| -rw-r--r-- | internal/web/templates/blog/search.html | 23 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/layout.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/module_form.html | 6 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 6 | ||||
| -rw-r--r-- | internal/web/web_test.go | 7 |
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 <b><mark>Tem</mark>plates</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)+" <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}}&p={{sub .Data.pageNum 1}}">← {{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}}&p={{add .Data.pageNum 1}}">{{t "Older"}} →</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="<p>{{t "Hello!"}}</p>">{{$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) |
