aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-15 23:12:51 +0300
committergrm <grm@eyesin.space>2026-09-15 23:12:51 +0300
commit2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff (patch)
treeecd5efd574b7d7463e4b2c4361c1994c4f28f561 /internal/web
parent55732bdfc25933bab26220ccd82fe5e172d9e673 (diff)
downloadblogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.tar.gz
blogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.tar.bz2
blogspace-2c85ca1a46ddf4652f8fffc53d6bcfbe32b2b4ff.zip
Add post tags, with a tag page and two side-column modules
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/<slug>, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/handlers_blog.go52
-rw-r--r--internal/web/handlers_layout.go13
-rw-r--r--internal/web/handlers_pages.go2
-rw-r--r--internal/web/handlers_posts.go25
-rw-r--r--internal/web/layout.go41
-rw-r--r--internal/web/routes.go1
-rw-r--r--internal/web/static/blog.css9
-rw-r--r--internal/web/static/dashboard.css4
-rw-r--r--internal/web/tags.go88
-rw-r--r--internal/web/templates/blog/page.html15
-rw-r--r--internal/web/templates/blog/post.html1
-rw-r--r--internal/web/templates/blog/tag.html5
-rw-r--r--internal/web/templates/dashboard/module_form.html9
-rw-r--r--internal/web/templates/dashboard/post_form.html5
-rw-r--r--internal/web/templates/dashboard/posts.html2
-rw-r--r--internal/web/templates/layouts/blog.html6
-rw-r--r--internal/web/templates/partials/postlist.html22
-rw-r--r--internal/web/web_test.go66
18 files changed, 322 insertions, 44 deletions
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}}<h1 class="page-title">{{.Data.page.Title}}</h1>{{end}}
{{if .Data.page.IntroHTML}}<div class="intro">{{html .Data.page.IntroHTML}}</div>{{end}}
-{{range .Data.posts}}
-<article class="post">
- <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2>
- {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}}
- <div class="post-body">{{html .BodyHTML}}</div>
-</article>
-{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}}{{end}}
-{{if gt .Data.lastPage 1}}
-<div class="pager">
- {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?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="{{.Data.base}}/?p={{add .Data.pageNum 1}}">{{t "Older"}} &rarr;</a>{{end}}
-</div>
-{{end}}
+{{if or .Data.posts .Data.page.IntroHTML}}{{template "postlist" .}}{{else}}<p class="muted">{{t "Nothing here yet."}}</p>{{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 @@
<article class="post single">
<h1 class="post-title">{{.Data.post.Title}}</h1>
<p class="post-date">{{if .Data.theme.ShowDates}}{{postdate .Data.theme.DateFormat .Data.post.CreatedAt}} &middot; {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p>
+ {{template "posttags" .Data.post.Tags}}
<div class="post-body">{{html .Data.post.BodyHTML}}</div>
</article>
{{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"}}
+<h1 class="page-title">{{tf "Tagged “%s”" .Data.tag.Name}}</h1>
+{{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 @@
<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") (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"}}{{end}}"></label>
+ {{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>
{{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>
@@ -15,6 +15,11 @@
<p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p>
{{else if eq $m.Kind "sitemap"}}
<p class="muted small">{{t "Links to every page of your blog."}}</p>
+ {{else if eq $m.Kind "tags"}}
+ <div class="row"><label>{{t "How many tags"}}<br><select name="count"><option value="0"{{if eq $m.Count 0}} selected{{end}}>{{t "All"}}</option>{{range $n := .Data.tagCounts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div>
+ <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 "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/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 @@
<label>{{t "Date"}}<br>
<input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label>
</div>
+ {{if .Data.allTags}}<p class="lbl">{{t "Tags"}}</p>
+ <div class="tagpick">{{range .Data.allTags}}<label class="check"><input type="checkbox" name="tag" value="{{.Name}}"{{if index $.Data.tagged .Slug}} checked{{end}}> {{.Name}}</label>{{end}}</div>{{end}}
+ <label>{{if .Data.allTags}}{{t "New tags"}}{{else}}{{t "Tags"}}{{end}} <span class="muted">({{t "comma-separated"}})</span><br>
+ <input name="new_tags" value="{{.Data.newTags}}" maxlength="900" placeholder="{{t "go, tomatoes, recipes"}}"></label>
+ <p class="muted small">{{t "Tags on hidden posts are not shown on the blog; a tag nobody uses disappears."}}</p>
<label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label>
{{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)}}
<label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label>
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 @@
<table>
<tr><th>{{t "Title"}}</th><th>{{t "Page"}}</th><th>{{t "Date"}}</th><th></th></tr>
{{range .Data.posts}}<tr>
- <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}</td>
+ <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}{{if .Tags}}<div class="muted small">{{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t.Name}}{{end}}</div>{{end}}</td>
<td>{{.PageTitle}}</td>
<td class="nowrap">{{date .CreatedAt}}</td>
<td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">{{t "view"}} &#8599;</a> &middot; <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">{{t "delete"}}</a></td>
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 @@
</details>{{end}}
</details>{{else}}<p class="muted">{{t "No posts yet."}}</p>{{end}}
</div>
+{{- else if eq $m.Kind "tags"}}<div class="mod mod-tags">{{template "modtitle" $m}}
+ <ul>{{range $i, $t := $r.Data.tags}}{{if or (eq $m.Count 0) (lt $i $m.Count)}}<li><a href="/tag/{{$t.Slug}}">{{$t.Name}}</a> <span class="muted">({{$t.Count}})</span></li>{{end}}{{else}}<li class="muted">{{t "No tags yet."}}</li>{{end}}</ul>
+</div>
+{{- 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 "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>
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}}
+<article class="post">
+ <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2>
+ {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}}
+ {{template "posttags" .Tags}}
+ <div class="post-body">{{html .BodyHTML}}</div>
+</article>
+{{end}}
+{{if gt .Data.lastPage 1}}
+<div class="pager">
+ {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}?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="{{.Data.base}}?p={{add .Data.pageNum 1}}">{{t "Older"}} &rarr;</a>{{end}}
+</div>
+{{end}}
+{{end}}
+
+{{define "posttags"}}{{if .}}<p class="post-tags">{{range .}}<a href="/tag/{{.Slug}}">{{.Name}}</a> {{end}}</p>{{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)