From 2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff Mon Sep 17 00:00:00 2001 From: grm Date: Tue, 15 Sep 2026 23:12:51 +0300 Subject: Add post tags, with a tag page and two side-column modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Posts can now carry tags, set on the post form as a checklist of the blog's existing tags plus a comma-separated box for new ones (no JS). A tag is a name and a unique slug, so "Go" and "go" are one tag and Greek tags get readable URLs; tags no post uses any more are deleted. On the blog, tags appear under the post date and link to /tag/, which lists the published posts from every page, paginated like a page. Two new layout modules show them: a Tags list (with counts, by use) and a Tag cloud (alphabetical, sized by use). Both are only fetched when a visible module needs them. The article loop and pager move to a shared postlist partial; while there, the pager stops adding a trailing slash — /news/?p=2 was a 404 because a {page} wildcard never matches one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_blog.go | 52 ++++++++++++-- internal/web/handlers_layout.go | 13 +++- internal/web/handlers_pages.go | 2 +- internal/web/handlers_posts.go | 25 ++++++- internal/web/layout.go | 41 +++++++---- internal/web/routes.go | 1 + internal/web/static/blog.css | 9 +++ internal/web/static/dashboard.css | 4 ++ internal/web/tags.go | 88 +++++++++++++++++++++++ internal/web/templates/blog/page.html | 15 +--- internal/web/templates/blog/post.html | 1 + internal/web/templates/blog/tag.html | 5 ++ internal/web/templates/dashboard/module_form.html | 9 ++- internal/web/templates/dashboard/post_form.html | 5 ++ internal/web/templates/dashboard/posts.html | 2 +- internal/web/templates/layouts/blog.html | 6 ++ internal/web/templates/partials/postlist.html | 22 ++++++ internal/web/web_test.go | 66 +++++++++++++++-- 18 files changed, 322 insertions(+), 44 deletions(-) create mode 100644 internal/web/tags.go create mode 100644 internal/web/templates/blog/tag.html create mode 100644 internal/web/templates/partials/postlist.html (limited to 'internal/web') diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index f2e7713..c6030f2 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -51,10 +51,26 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) { } v["recent"] = recent } + if layout.NeedsTags() { + counts, err := blogStore(r).TagCounts(ctx) + if err != nil { + return nil, err + } + v["tags"], v["tagcloud"] = topTags(counts), cloudSizes(counts) + } v["notices"] = placeNotices(sections, layout) return v, nil } +// pageNum reads the ?p= of a paginated listing (1 when absent or silly). +func pageNum(r *http.Request) int { + n, _ := strconv.Atoi(r.URL.Query().Get("p")) + if n < 1 { + n = 1 + } + return n +} + func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) { page, err := blogStore(r).HomePage(r.Context()) if err != nil { @@ -83,10 +99,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. s.serverError(w, err) return } - n, _ := strconv.Atoi(r.URL.Query().Get("p")) - if n < 1 { - n = 1 - } + n := pageNum(r) posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, postsPerPage, (n-1)*postsPerPage) if err != nil { s.serverError(w, err) @@ -94,13 +107,42 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. } last := (total + postsPerPage - 1) / postsPerPage v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last + // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug if page.IsHome { - v["base"] = "" + v["base"] = "/" } s.render(w, r, "blog/page.html", v) } +// handleBlogTag lists the published posts carrying a tag, from every page of +// the blog. A tag that is only on hidden posts is as good as absent. +func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { + tag, err := blogStore(r).TagBySlug(r.Context(), r.PathValue("tag")) + if err != nil { + s.blogNotFound(w, r) + return + } + n := pageNum(r) + posts, total, err := blogStore(r).PublishedPostsByTag(r.Context(), tag.ID, postsPerPage, (n-1)*postsPerPage) + if err != nil { + s.serverError(w, err) + return + } + if total == 0 { + s.blogNotFound(w, r) + return + } + v, err := s.blogView(r) + if err != nil { + s.serverError(w, err) + return + } + last := (total + postsPerPage - 1) / postsPerPage + v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug + s.render(w, r, "blog/tag.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 94bf32f..64d8ff4 100644 --- a/internal/web/handlers_layout.go +++ b/internal/web/handlers_layout.go @@ -121,6 +121,11 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { m.Title = i18n.T(blogLang, "Recent posts") case "sitemap": m.Title = i18n.T(blogLang, "Pages") + case "tags": + m.Title = i18n.T(blogLang, "Tags") + m.Count = 10 + case "tagcloud": + m.Title = i18n.T(blogLang, "Tag cloud") } m, err := blogStore(r).CreateModule(r.Context(), m) if err != nil { @@ -169,7 +174,7 @@ func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { } func (s *Server) moduleFormData(r *http.Request, m *store.Module, errMsg string) map[string]any { - d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}} + d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}, "tagCounts": []int{5, 10, 20, 30, 50}} if errMsg != "" { d["error"] = errMsg } @@ -184,7 +189,11 @@ func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { m.Title = strings.TrimSpace(r.FormValue("title")) m.Body = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") if n, err := strconv.Atoi(r.FormValue("count")); err == nil { - m.Count = clampInt(n, 1, maxRecentCount) + if m.Kind == "tags" { // 0 = all of them + m.Count = clampInt(n, 0, maxTagListCount) + } else { + m.Count = clampInt(n, 1, maxRecentCount) + } } fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg)) diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 3b128e0..bc74f71 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} + "webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true, "tag": true} func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { pages, err := blogStore(r).ListPages(r.Context()) diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index d38c5f2..16d9cf2 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -54,6 +54,11 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + allTags, err := blogStore(r).ListTags(r.Context()) + if err != nil { + s.serverError(w, err) + return + } p := &store.Post{Published: true, CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -64,7 +69,7 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil { p.PageID = hp.ID } - s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages}) + s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "allTags": allTags, "tagged": tagSet(p.Tags), "newTags": ""}) } func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { @@ -74,6 +79,11 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + allTags, err := blogStore(r).ListTags(r.Context()) + if err != nil { + s.serverError(w, err) + return + } p := &store.Post{CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -85,12 +95,16 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n") p.Published = r.FormValue("published") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + // The ticked existing tags and the comma-separated new ones are one list. + tags, tagsOK := parseTags(append(r.Form["tag"], strings.Split(r.FormValue("new_tags"), ",")...)) + p.Tags = tags autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } fail := func(status int, msg string) { - s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) + s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg, + "allTags": allTags, "tagged": tagSet(p.Tags), "newTags": r.FormValue("new_tags")}) } if at := strings.TrimSpace(r.FormValue("posted_at")); at != "" { // blank keeps the current date var ok bool @@ -124,6 +138,9 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { case len(p.BodyMD) > 200_000: fail(http.StatusBadRequest, s.tr(r, "Post is too long (200 KB max).")) return + case !tagsOK: + fail(http.StatusBadRequest, s.tr(r, "Tags: at most 20 per post, 40 characters each.")) + return } p.BodyHTML = markdown.Render(p.BodyMD) base := p.Slug @@ -149,6 +166,10 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } + if err := blogStore(r).SetPostTags(r.Context(), p.ID, tags); err != nil { + s.serverError(w, err) + return + } redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } diff --git a/internal/web/layout.go b/internal/web/layout.go index ad55e82..b8069a0 100644 --- a/internal/web/layout.go +++ b/internal/web/layout.go @@ -13,8 +13,8 @@ import ( // 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", "logo", "html"}, - "right": {"menu", "recent", "archive", "logo", "html"}, + "left": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, + "right": {"menu", "recent", "archive", "tags", "tagcloud", "logo", "html"}, "above": {"html"}, "below": {"html"}, "footer": {"text", "rss", "sitemap", "html"}, @@ -22,15 +22,17 @@ var moduleKinds = map[string][]string{ // Friendly names for the dashboard (English; translate with t / s.tr at the point of use). var moduleNames = map[string]string{ - "title": "Blog title & tagline", - "logo": "Logo", - "menu": "Menu", - "archive": "Archive (posts by month)", - "recent": "Recent posts", - "html": "Custom HTML", - "rss": "RSS link", - "text": "Footer text", - "sitemap": "Site map", + "title": "Blog title & tagline", + "logo": "Logo", + "menu": "Menu", + "archive": "Archive (posts by month)", + "recent": "Recent posts", + "tags": "Tags", + "tagcloud": "Tag cloud", + "html": "Custom HTML", + "rss": "RSS link", + "text": "Footer text", + "sitemap": "Site map", } var areaNames = map[string]string{ @@ -53,7 +55,7 @@ func allowedKind(area, kind string) bool { // moduleHasSettings says whether the module has an edit page. func moduleHasSettings(kind string) bool { switch kind { - case "html", "text", "recent", "archive", "sitemap": + case "html", "text", "recent", "archive", "sitemap", "tags", "tagcloud": return true } return false @@ -64,6 +66,11 @@ func moduleSummary(lang string, m store.Module) string { switch m.Kind { case "recent": return i18n.Tf(lang, "%d posts", m.Count) + headingNote(lang, m) + case "tags": + if m.Count == 0 { + return i18n.T(lang, "all tags") + headingNote(lang, m) + } + return i18n.Tf(lang, "%d tags", m.Count) + headingNote(lang, m) case "html", "text": body := strings.Join(strings.Fields(m.Body), " ") if body == "" { @@ -160,6 +167,16 @@ func (l Layout) NeedsArchive() bool { return false } +// NeedsTags says whether any visible module shows the tags. +func (l Layout) NeedsTags() bool { + for _, m := range l.all() { + if m.Kind == "tags" || m.Kind == "tagcloud" { + return true + } + } + return false +} + // MaxRecent is the longest "recent posts" list any visible module wants (0 = none). func (l Layout) MaxRecent() int { n := 0 diff --git a/internal/web/routes.go b/internal/web/routes.go index 26264ef..a8a60d2 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -95,6 +95,7 @@ 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 /{page}", wrap(s.handleBlogPage)) m.HandleFunc("GET /{page}/{post}", wrap(s.handleBlogPost)) } diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index d4465a2..e7a6c96 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -71,6 +71,15 @@ img { max-width: 100%; height: auto; } .col .site-nav .mod-title { padding: 0 1em; } .col .site-nav .nav-inner { padding: 0; } .col .site-nav a { display: block; padding: 0.4em 1em; } +.post-tags { margin: -0.5em 0 0.8em; font-size: 0.85em; opacity: 0.8; } +.post-tags a { margin-right: 0.7em; } +.tagcloud { margin: 0; line-height: 1.8; } +.tagcloud a { display: inline-block; margin-right: 0.5em; text-decoration: none; } +.tc-1 { font-size: 0.85em; opacity: 0.75; } +.tc-2 { font-size: 1em; } +.tc-3 { font-size: 1.2em; } +.tc-4 { font-size: 1.45em; } +.tc-5 { font-size: 1.75em; } .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/static/dashboard.css b/internal/web/static/dashboard.css index b85d515..36de479 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -85,6 +85,10 @@ label.check { font-weight: normal; font-size: 0.95em; } label.check input { width: auto; margin: 0 0.45em 0 0; vertical-align: middle; accent-color: var(--accent); } input[type=radio], input[type=checkbox] { accent-color: var(--accent); } .row { display: flex; flex-wrap: wrap; gap: 0 1.5em; } +/* The post form's tag checklist: a label-like heading over a wrapped row of checkboxes. */ +p.lbl { margin: 0.9em 0 0.3em; font-size: 0.875em; font-weight: 500; } +.tagpick { display: flex; flex-wrap: wrap; gap: 0.2em 1.2em; margin: 0 0 0.3em; } +.tagpick label.check { margin: 0; } .row label { flex: 1 1 12em; min-width: 12em; } button, .btn { display: inline-block; font: inherit; font-size: 0.9em; font-weight: 600; padding: 0.5em 1.05em; border: 1px solid var(--accent); border-radius: var(--radius-sm); background: var(--accent); color: #fff; cursor: pointer; text-decoration: none; vertical-align: middle; line-height: 1.4; } button:hover, .btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } diff --git a/internal/web/tags.go b/internal/web/tags.go new file mode 100644 index 0000000..ba49260 --- /dev/null +++ b/internal/web/tags.go @@ -0,0 +1,88 @@ +package web + +import ( + "sort" + "strings" + "unicode/utf8" + + "github.com/gramanas/blogspace/internal/slug" + "github.com/gramanas/blogspace/internal/store" +) + +const ( + maxTagsPerPost = 20 + maxTagLen = 40 // runes: Greek names are two bytes a letter + maxTagListCount = 50 // the Tags module's "how many"; 0 = all +) + +// parseTags normalises what the post form sends (the ticked tags and the +// comma-separated new ones): whitespace trimmed and collapsed, blanks and +// names with nothing slug-worthy dropped, duplicates removed by slug with the +// first spelling kept. ok is false when a name is too long or there are too many. +func parseTags(raw []string) (tags []store.Tag, ok bool) { + seen := map[string]bool{} + for _, r := range raw { + name := strings.Join(strings.Fields(r), " ") + if name == "" { + continue + } + if utf8.RuneCountInString(name) > maxTagLen { + return nil, false + } + sl := slug.Clean(name) + if sl == "" || seen[sl] { + continue + } + seen[sl] = true + tags = append(tags, store.Tag{Name: name, Slug: sl}) + } + return tags, len(tags) <= maxTagsPerPost +} + +// tagSet is the post's tags by slug, for ticking the form's checklist. +func tagSet(tags []store.Tag) map[string]bool { + out := map[string]bool{} + for _, t := range tags { + out[t.Slug] = true + } + return out +} + +// topTags orders tags by use, then name: the Tags module's list. +func topTags(tags []store.TagCount) []store.TagCount { + out := append([]store.TagCount(nil), tags...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Name < out[j].Name + }) + return out +} + +// cloudTag is a tag with its font-size step for the tag cloud (class tc-1 … tc-5). +type cloudTag struct { + store.TagCount + Size int +} + +// cloudSizes spreads the counts linearly over five sizes between the least and +// most used tag; when every tag is used equally they all get the middle size. +func cloudSizes(tags []store.TagCount) []cloudTag { + out := make([]cloudTag, 0, len(tags)) + if len(tags) == 0 { + return out + } + lo, hi := tags[0].Count, tags[0].Count + for _, t := range tags { + lo, hi = min(lo, t.Count), max(hi, t.Count) + } + for _, t := range tags { + size := 3 + if hi > lo { + size = 1 + (t.Count-lo)*4/(hi-lo) + } + out = append(out, cloudTag{t, size}) + } + return out +} diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html index ac6a609..99403ef 100644 --- a/internal/web/templates/blog/page.html +++ b/internal/web/templates/blog/page.html @@ -2,18 +2,5 @@ {{define "content"}} {{if not .Data.page.IsHome}}

{{.Data.page.Title}}

{{end}} {{if .Data.page.IntroHTML}}
{{html .Data.page.IntroHTML}}
{{end}} -{{range .Data.posts}} -
-

{{.Title}}

- {{if $.Data.theme.ShowDates}}{{end}} -
{{html .BodyHTML}}
-
-{{else}}{{if not .Data.page.IntroHTML}}

{{t "Nothing here yet."}}

{{end}}{{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}} +{{if or .Data.posts .Data.page.IntroHTML}}{{template "postlist" .}}{{else}}

{{t "Nothing here yet."}}

{{end}} {{end}} diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html index d14f22a..a3b0375 100644 --- a/internal/web/templates/blog/post.html +++ b/internal/web/templates/blog/post.html @@ -3,6 +3,7 @@

{{.Data.post.Title}}

+ {{template "posttags" .Data.post.Tags}}
{{html .Data.post.BodyHTML}}
{{end}} diff --git a/internal/web/templates/blog/tag.html b/internal/web/templates/blog/tag.html new file mode 100644 index 0000000..fd3aa13 --- /dev/null +++ b/internal/web/templates/blog/tag.html @@ -0,0 +1,5 @@ +{{define "title"}}{{tf "Tagged “%s”" .Data.tag.Name}} · {{.Blog.Title}}{{end}} +{{define "content"}} +

{{tf "Tagged “%s”" .Data.tag.Name}}

+{{template "postlist" .}} +{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html index 0cd27fa..8b28f2e 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") (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") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} + {{end}} {{if eq $m.Kind "recent"}}
@@ -15,6 +15,11 @@

{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}

{{else if eq $m.Kind "sitemap"}}

{{t "Links to every page of your blog."}}

+ {{else if eq $m.Kind "tags"}} +
+

{{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 "html"}} diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 9f188f7..fd47615 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -14,6 +14,11 @@ + {{if .Data.allTags}}

{{t "Tags"}}

+
{{range .Data.allTags}}{{end}}
{{end}} + +

{{t "Tags on hidden posts are not shown on the blog; a tag nobody uses disappears."}}

{{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html index 3a99a31..983fbae 100644 --- a/internal/web/templates/dashboard/posts.html +++ b/internal/web/templates/dashboard/posts.html @@ -17,7 +17,7 @@ {{range .Data.posts}} - + diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index f73f729..cf00272 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -70,6 +70,12 @@ {{end}} {{else}}

{{t "No posts yet."}}

{{end}} +{{- else if eq $m.Kind "tags"}}
{{template "modtitle" $m}} +
    {{range $i, $t := $r.Data.tags}}{{if or (eq $m.Count 0) (lt $i $m.Count)}}
  • {{$t.Name}} ({{$t.Count}})
  • {{end}}{{else}}
  • {{t "No tags yet."}}
  • {{end}}
+
+{{- 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 "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"}}

diff --git a/internal/web/templates/partials/postlist.html b/internal/web/templates/partials/postlist.html new file mode 100644 index 0000000..01e1037 --- /dev/null +++ b/internal/web/templates/partials/postlist.html @@ -0,0 +1,22 @@ +{{/* The article list and pager shared by a page and a tag listing. Expects + .Data.posts, .Data.pageNum, .Data.lastPage and .Data.base (the listing's + own path, which ?p=N is appended to). */}} +{{define "postlist"}} +{{range .Data.posts}} +
+

{{.Title}}

+ {{if $.Data.theme.ShowDates}}{{end}} + {{template "posttags" .Tags}} +
{{html .BodyHTML}}
+
+{{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}} + +{{define "posttags"}}{{if .}}{{end}}{{end}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9520953..5ab8219 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "testing" "time" @@ -73,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"} { + for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico", "tag"} { if !reservedPageSlugs[slug] { t.Errorf("page slug %q should be reserved", slug) } @@ -146,15 +147,15 @@ func TestLayoutTheme(t *testing.T) { func TestBuildLayout(t *testing.T) { mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, - {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} + {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}, {ID: 7, Area: "right", Kind: "tagcloud"}} th := DefaultTheme() l := buildLayout(th, mods) - if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { + if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() || !l.NeedsTags() { t.Errorf("layout: %+v", l) } th.RightOn, th.FooterOn = false, false l = buildLayout(th, mods) - if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { + if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() || l.NeedsTags() { t.Errorf("switched-off areas should drop their modules: %+v", l) } if buildLayout(th, nil).HasLeft { @@ -206,6 +207,61 @@ func TestModuleKinds(t *testing.T) { if got := moduleSummary("en", store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } + if got := moduleSummary("en", store.Module{Kind: "tags", Count: 8}); got != "8 tags" { + t.Errorf("tags summary: %q", got) + } + if got := moduleSummary("en", store.Module{Kind: "tags"}); got != "all tags" { + t.Errorf("all-tags summary: %q", got) + } +} + +func TestParseTags(t *testing.T) { + tags, ok := parseTags([]string{" Go ", "go", "GO", "", "tomatoes plants", "!!!", "Ντομάτες"}) + if !ok || len(tags) != 3 { + t.Fatalf("parseTags: ok=%v %+v", ok, tags) + } + for i, want := range []store.Tag{{Name: "Go", Slug: "go"}, {Name: "tomatoes plants", Slug: "tomatoes-plants"}, {Name: "Ντομάτες", Slug: "ntomates"}} { + if tags[i] != want { + t.Errorf("tag %d = %+v, want %+v", i, tags[i], want) + } + } + if tags, ok := parseTags(nil); !ok || len(tags) != 0 { + t.Errorf("no tags: ok=%v %+v", ok, tags) + } + if _, ok := parseTags([]string{strings.Repeat("α", 41)}); ok { + t.Error("41 runes should be too long") + } + if _, ok := parseTags([]string{strings.Repeat("α", 40)}); !ok { + t.Error("40 runes are fine") + } + many := make([]string, 21) + for i := range many { + many[i] = "t" + strconv.Itoa(i) + } + if _, ok := parseTags(many); ok { + t.Error("21 tags should be too many") + } + if !tagSet(tags)["ntomates"] || tagSet(tags)["nope"] { + t.Error("tagSet") + } +} + +func TestCloudSizes(t *testing.T) { + if got := cloudSizes(nil); len(got) != 0 { + t.Errorf("empty: %+v", got) + } + same := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 1}}) + if same[0].Size != 3 || same[1].Size != 3 { + t.Errorf("equal counts get the middle size: %+v", same) + } + spread := cloudSizes([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 9}}) + if spread[0].Size != 1 || spread[1].Size != 3 || spread[2].Size != 5 || spread[0].Name != "a" { + t.Errorf("spread: %+v", spread) + } + top := topTags([]store.TagCount{{Name: "a", Count: 1}, {Name: "b", Count: 5}, {Name: "c", Count: 5}}) + if top[0].Name != "b" || top[1].Name != "c" || top[2].Name != "a" { + t.Errorf("topTags: %+v", top) + } } func TestValidLinkURL(t *testing.T) { @@ -323,7 +379,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/404.html"} { + "blog/page.html", "blog/post.html", "blog/tag.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
{{t "Title"}}{{t "Page"}}{{t "Date"}}
{{.Title}}{{if not .Published}} {{t "hidden"}}{{end}}{{.Title}}{{if not .Published}} {{t "hidden"}}{{end}}{{if .Tags}}
{{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t.Name}}{{end}}
{{end}}
{{.PageTitle}} {{date .CreatedAt}} {{t "view"}} ↗ · {{t "delete"}}