aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
committergrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
commite14a4250efb828262347f5a4bbc4067cd71d885f (patch)
tree3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web
parent282d2ab3e0fb3a74f32160a231225773001acd14 (diff)
downloadblogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can now pick the language of their blog; it switches the whole dashboard and the blog's fixed text — post dates, archive months, the RSS link, the pager, the 404 page — while what the blogger wrote is left alone. The new internal/i18n package keys translations by the English string, so an untranslated key renders as English rather than blank, and TestGreekCatalogComplete scans the templates and handlers to fail when the Greek catalog misses a key or keeps a stale one. Templates are compiled once per language with t/tf/date/postdate/month closed over the language, so they need no data plumbing. The language lives in settings.language (blog migration 00002), not in the theme, so "Reset design" does not touch it. Public pages use the blog's language; management pages use the logged-in user's own blog's, so a superadmin editing someone else's blog keeps theirs; the login page follows Accept-Language. 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_admin.go28
-rw-r--r--internal/web/handlers_auth.go12
-rw-r--r--internal/web/handlers_dashboard.go15
-rw-r--r--internal/web/handlers_design.go37
-rw-r--r--internal/web/handlers_layout.go54
-rw-r--r--internal/web/handlers_pages.go18
-rw-r--r--internal/web/handlers_posts.go18
-rw-r--r--internal/web/handlers_sections.go20
-rw-r--r--internal/web/layout.go20
-rw-r--r--internal/web/server.go72
-rw-r--r--internal/web/templates.go50
-rw-r--r--internal/web/templates/admin/delete_user.html10
-rw-r--r--internal/web/templates/admin/index.html28
-rw-r--r--internal/web/templates/admin/new_user.html14
-rw-r--r--internal/web/templates/auth/login.html10
-rw-r--r--internal/web/templates/blog/404.html6
-rw-r--r--internal/web/templates/blog/page.html10
-rw-r--r--internal/web/templates/blog/post.html2
-rw-r--r--internal/web/templates/dashboard/confirm.html14
-rw-r--r--internal/web/templates/dashboard/design.html178
-rw-r--r--internal/web/templates/dashboard/images.html14
-rw-r--r--internal/web/templates/dashboard/layout.html102
-rw-r--r--internal/web/templates/dashboard/module_form.html28
-rw-r--r--internal/web/templates/dashboard/overview.html24
-rw-r--r--internal/web/templates/dashboard/page_form.html18
-rw-r--r--internal/web/templates/dashboard/pages.html20
-rw-r--r--internal/web/templates/dashboard/password.html12
-rw-r--r--internal/web/templates/dashboard/post_form.html20
-rw-r--r--internal/web/templates/dashboard/posts.html20
-rw-r--r--internal/web/templates/dashboard/section_form.html40
-rw-r--r--internal/web/templates/dashboard/sections.html26
-rw-r--r--internal/web/templates/dashboard/settings.html15
-rw-r--r--internal/web/templates/layouts/blog.html8
-rw-r--r--internal/web/templates/layouts/dashboard.html2
-rw-r--r--internal/web/templates/partials/dashnav.html26
-rw-r--r--internal/web/templates/partials/editor.html14
-rw-r--r--internal/web/templates/partials/imagepick.html4
-rw-r--r--internal/web/templates/partials/mdhelp.html10
-rw-r--r--internal/web/theme.go14
-rw-r--r--internal/web/web_test.go111
40 files changed, 647 insertions, 497 deletions
diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go
index e0b3991..eb040c1 100644
--- a/internal/web/handlers_admin.go
+++ b/internal/web/handlers_admin.go
@@ -50,11 +50,11 @@ func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) {
var msg string
switch {
case !usernameRe.MatchString(username):
- msg = "Username: 2-40 letters, digits, dots, dashes or underscores."
+ msg = s.tr(r, "Username: 2-40 letters, digits, dots, dashes or underscores.")
case len(pw) < 8:
- msg = "Password must be at least 8 characters."
+ msg = s.tr(r, "Password must be at least 8 characters.")
case !subdomainRe.MatchString(sub) || reservedSubdomains[sub]:
- msg = "Subdomain: lowercase letters, digits and single dashes; not a reserved name."
+ msg = s.tr(r, "Subdomain: lowercase letters, digits and single dashes; not a reserved name.")
}
if msg != "" {
f["error"] = msg
@@ -69,19 +69,19 @@ func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) {
_, blog, err := s.st.CreateBlogger(r.Context(), username, hash, sub, title)
if err != nil {
if errors.Is(err, store.ErrConflict) {
- f["error"] = "Username or subdomain already taken."
+ f["error"] = s.tr(r, "Username or subdomain already taken.")
s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f)
return
}
if errors.Is(err, db.ErrDatabaseExists) {
- f["error"] = "A database " + db.DBName(sub) + " already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain."
+ f["error"] = s.trf(r, "A database %s already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain.", db.DBName(sub))
s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f)
return
}
s.serverError(w, err)
return
}
- redirectOK(w, r, "/admin/", "Created "+username+" with blog "+blog.Subdomain+".")
+ redirectOK(w, r, "/admin/", s.trf(r, "Created %s with blog %s.", username, blog.Subdomain))
}
func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.User {
@@ -96,7 +96,7 @@ func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.
return nil
}
if u.ID == currentUser(r).ID {
- s.plainError(w, http.StatusBadRequest, "You cannot do that to your own account here; use Account > Password.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "You cannot do that to your own account here; use Account > Password."))
return nil
}
return u
@@ -109,7 +109,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
}
pw := r.FormValue("password")
if len(pw) < 8 {
- s.plainError(w, http.StatusBadRequest, "Password must be at least 8 characters.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Password must be at least 8 characters."))
return
}
hash, err := auth.HashPassword(pw)
@@ -121,7 +121,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request
s.serverError(w, err)
return
}
- redirectOK(w, r, "/admin/", "Password reset for "+u.Username+".")
+ redirectOK(w, r, "/admin/", s.trf(r, "Password reset for %s.", u.Username))
}
func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc {
@@ -134,11 +134,11 @@ func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc {
s.serverError(w, err)
return
}
- verb := "enabled"
+ msg := s.trf(r, "%s enabled.", u.Username)
if disabled {
- verb = "disabled"
+ msg = s.trf(r, "%s disabled.", u.Username)
}
- redirectOK(w, r, "/admin/", u.Username+" "+verb+".")
+ redirectOK(w, r, "/admin/", msg)
}
}
@@ -156,12 +156,12 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if b, err := s.st.BlogByOwner(r.Context(), u.ID); err == nil && b.Subdomain == config.RootSubdomain {
- s.plainError(w, http.StatusBadRequest, "This user owns the root blog and cannot be deleted.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "This user owns the root blog and cannot be deleted."))
return
}
if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // drops their blog database too
s.serverError(w, err)
return
}
- redirectOK(w, r, "/admin/", "Deleted "+u.Username+" and their blog.")
+ redirectOK(w, r, "/admin/", s.trf(r, "Deleted %s and their blog.", u.Username))
}
diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go
index 7c1d1e8..3030f11 100644
--- a/internal/web/handlers_auth.go
+++ b/internal/web/handlers_auth.go
@@ -35,7 +35,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
next := safeNext(r.FormValue("next"))
fail := func() {
s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html",
- map[string]any{"error": "Wrong username or password.", "username": username, "next": next})
+ map[string]any{"error": s.tr(r, "Wrong username or password."), "username": username, "next": next})
}
u, err := s.st.UserByUsername(r.Context(), username)
if err != nil {
@@ -77,7 +77,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
- s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.")
+ s.plainError(w, http.StatusNotFound, s.tr(r, "You have no blog yet. Ask the administrator to create one."))
return
}
s.serverError(w, err)
@@ -96,11 +96,11 @@ func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) {
var msg string
switch {
case !auth.CheckPassword(u.PasswordHash, cur):
- msg = "Current password is wrong."
+ msg = s.tr(r, "Current password is wrong.")
case len(pw) < 8:
- msg = "New password must be at least 8 characters."
+ msg = s.tr(r, "New password must be at least 8 characters.")
case pw != pw2:
- msg = "New passwords do not match."
+ msg = s.tr(r, "New passwords do not match.")
}
if msg != "" {
s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg})
@@ -122,7 +122,7 @@ func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) {
return
}
auth.SetSessionCookie(w, tok)
- redirectOK(w, r, "/dashboard", "Password changed.")
+ redirectOK(w, r, "/dashboard", s.tr(r, "Password changed."))
}
// safeNext only allows local paths as post-login redirect targets.
diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go
index a1e0d89..506d28d 100644
--- a/internal/web/handlers_dashboard.go
+++ b/internal/web/handlers_dashboard.go
@@ -3,6 +3,8 @@ package web
import (
"net/http"
"strings"
+
+ "github.com/gramanas/blogspace/internal/i18n"
)
func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) {
@@ -23,20 +25,23 @@ func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSettingsForm(w http.ResponseWriter, r *http.Request) {
- s.render(w, r, "dashboard/settings.html", nil)
+ s.render(w, r, "dashboard/settings.html", map[string]any{"languages": i18n.Languages()})
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
title := strings.TrimSpace(r.FormValue("title"))
tagline := strings.TrimSpace(r.FormValue("tagline"))
- if title == "" || len(title) > 120 || len(tagline) > 300 {
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", map[string]any{"error": "Title is required (max 120 chars); tagline max 300."})
+ language := r.FormValue("language")
+ if title == "" || len(title) > 120 || len(tagline) > 300 || !i18n.Valid(language) {
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html",
+ map[string]any{"languages": i18n.Languages(), "error": s.tr(r, "Title is required (max 120 chars); tagline max 300.")})
return
}
- if err := blogStore(r).UpdateSettings(r.Context(), title, tagline); err != nil {
+ if err := blogStore(r).UpdateSettings(r.Context(), title, tagline, language); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", "Saved. Refresh your blog to see it.")
+ // The flash speaks the language just chosen, not the one the form was in.
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", i18n.T(language, "Saved. Refresh your blog to see it."))
}
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 9f98e9e..7424eca 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "time"
"github.com/google/uuid"
"github.com/gramanas/blogspace/internal/store"
@@ -25,13 +26,18 @@ func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images, "presets": Presets()})
+ s.render(w, r, "dashboard/design.html", designData(ParseTheme(blog.ThemeJSON), images))
+}
+
+// designData is what design.html shows; "today" is the sample for the date formats.
+func designData(theme Theme, images []store.Image) map[string]any {
+ return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()}
}
func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
- s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Upload too large or malformed form."))
return
}
theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
@@ -40,7 +46,9 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
img, err := s.readUpload(r, field)
if err != nil {
images, _ := blogStore(r).ListImages(r.Context())
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "images": images, "presets": Presets(), "error": err.Error()})
+ d := designData(theme, images)
+ d["error"] = err.Error()
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d)
return
}
if img != nil {
@@ -51,7 +59,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design saved. Refresh your blog to see it."))
}
// handleDesignPreset applies a colour scheme straight away; it is a separate
@@ -60,7 +68,7 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
p, ok := PresetByKey(r.FormValue("preset"))
if !ok {
- s.plainError(w, http.StatusBadRequest, "Unknown colour scheme.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Unknown colour scheme."))
return
}
theme := ParseTheme(blog.ThemeJSON).WithPreset(p)
@@ -68,7 +76,7 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Applied the "+p.Name+" scheme. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.trf(r, "Applied the %s scheme. Refresh your blog to see it.", s.tr(r, p.Name)))
}
// handleDesignReset restores the defaults, layout modules included; images
@@ -83,7 +91,7 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design and layout reset to the defaults.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults."))
}
// readUpload stores the file from a multipart field, returning nil if the field is empty.
@@ -99,8 +107,9 @@ func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error)
}
func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) {
+ tooBig := errors.New(s.trf(r, "Image is too large (max %s).", kbString(s.cfg.MaxUploadBytes)))
if fh.Size > s.cfg.MaxUploadBytes {
- return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ return nil, tooBig
}
f, err := fh.Open()
if err != nil {
@@ -112,11 +121,11 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.
return nil, err
}
if int64(buf.Len()) > s.cfg.MaxUploadBytes {
- return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ return nil, tooBig
}
ct := http.DetectContentType(buf.Bytes())
if !allowedImageTypes[ct] {
- return nil, errors.New("only PNG, JPEG, GIF, WebP and ICO images are accepted")
+ return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images are accepted."))
}
name := filepath.Base(fh.Filename)
if name == "" || name == "." || len(name) > 120 {
@@ -174,7 +183,7 @@ func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) {
s.plainError(w, http.StatusBadRequest, msg)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
- fail("Upload too large or malformed form.")
+ fail(s.tr(r, "Upload too large or malformed form."))
return
}
img, err := s.readUpload(r, "file")
@@ -183,14 +192,14 @@ func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) {
return
}
if img == nil {
- fail("Choose a file first.")
+ fail(s.tr(r, "Choose a file first."))
return
}
if wantJSON {
writeJSON(w, http.StatusOK, map[string]string{"id": img.ID.String(), "filename": img.Filename, "markdown": imageMarkdown(img)})
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.trf(r, "Uploaded %s.", img.Filename))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -232,5 +241,5 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
return
}
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.tr(r, "Image deleted."))
}
diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go
index 1046a19..94bf32f 100644
--- a/internal/web/handlers_layout.go
+++ b/internal/web/handlers_layout.go
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
+ "github.com/gramanas/blogspace/internal/i18n"
"github.com/gramanas/blogspace/internal/store"
)
@@ -67,7 +68,7 @@ func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#columns", "Columns saved. Refresh your blog to see them.")
+ redirectOK(w, r, s.layoutURL(r)+"#columns", s.tr(r, "Columns saved. Refresh your blog to see them."))
}
// handleAreaToggle shows or hides a whole area, keeping its modules.
@@ -85,9 +86,9 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- msg := areaNames[area] + " hidden."
+ msg := s.trf(r, "%s hidden.", s.tr(r, areaNames[area]))
if on {
- msg = areaNames[area] + " shown again. Refresh your blog to see it."
+ msg = s.trf(r, "%s shown again. Refresh your blog to see it.", s.tr(r, areaNames[area]))
}
redirectOK(w, r, s.layoutURL(r)+"#"+area, msg)
}
@@ -95,7 +96,7 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
area, kind := r.PathValue("area"), r.FormValue("kind")
if !allowedKind(area, kind) {
- s.plainError(w, http.StatusBadRequest, "That module cannot go in that area.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "That module cannot go in that area."))
return
}
if singleModuleAreas[area] {
@@ -106,19 +107,20 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
}
for _, m := range mods {
if m.Area == area {
- redirectOK(w, r, s.layoutURL(r)+"#main", "There is already a module there; edit or remove it first.")
+ redirectOK(w, r, s.layoutURL(r)+"#main", s.tr(r, "There is already a module there; edit or remove it first."))
return
}
}
}
m := &store.Module{Area: area, Kind: kind, Count: 5}
- switch kind { // sensible headings so the module reads well before it is edited
+ blogLang := currentBlog(r).Language // headings are content: the blog's language, not the viewer's
+ switch kind { // sensible headings so the module reads well before it is edited
case "archive":
- m.Title = "Archive"
+ m.Title = i18n.T(blogLang, "Archive")
case "recent":
- m.Title = "Recent posts"
+ m.Title = i18n.T(blogLang, "Recent posts")
case "sitemap":
- m.Title = "Pages"
+ m.Title = i18n.T(blogLang, "Pages")
}
m, err := blogStore(r).CreateModule(r.Context(), m)
if err != nil {
@@ -126,10 +128,10 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) {
return
}
if kind == "html" || kind == "text" {
- redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", "Added. Now write what it should show.")
+ redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", s.tr(r, "Added. Now write what it should show."))
return
}
- redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), moduleNames[kind]+" added. Refresh your blog to see it.")
+ redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), s.trf(r, "%s added. Refresh your blog to see it.", s.tr(r, moduleNames[kind])))
}
// anchorFor is the layout.html card an area is edited in.
@@ -163,11 +165,11 @@ func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther)
return
}
- s.render(w, r, "dashboard/module_form.html", moduleFormData(m, ""))
+ s.render(w, r, "dashboard/module_form.html", s.moduleFormData(r, m, ""))
}
-func moduleFormData(m *store.Module, errMsg string) map[string]any {
- d := map[string]any{"module": m, "name": moduleNames[m.Kind], "area": areaNames[m.Area], "counts": []int{3, 5, 8, 10, 15, 20}}
+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}}
if errMsg != "" {
d["error"] = errMsg
}
@@ -185,21 +187,21 @@ func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) {
m.Count = clampInt(n, 1, maxRecentCount)
}
fail := func(msg string) {
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", moduleFormData(m, msg))
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg))
}
switch {
case len(m.Title) > 120:
- fail("Heading is too long (max 120 characters).")
+ fail(s.tr(r, "Heading is too long (max 120 characters)."))
return
case len(m.Body) > maxModuleBytes:
- fail("Content is too long (20 KB max).")
+ fail(s.tr(r, "Content is too long (20 KB max)."))
return
}
if err := blogStore(r).UpdateModule(r.Context(), m); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), "Saved. Refresh your blog to see it.")
+ redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.tr(r, "Saved. Refresh your blog to see it."))
}
func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) {
@@ -229,7 +231,7 @@ func (s *Server) handleModuleDeleteConfirm(w http.ResponseWriter, r *http.Reques
s.deleteModule(w, r, m)
return
}
- s.render(w, r, "dashboard/confirm.html", map[string]any{"what": "the \"" + moduleNames[m.Kind] + "\" module and its text", "action": r.URL.Path, "back": s.layoutURL(r)})
+ s.render(w, r, "dashboard/confirm.html", map[string]any{"what": s.trf(r, "the “%s” module and its text", s.tr(r, moduleNames[m.Kind])), "action": r.URL.Path, "back": s.layoutURL(r)})
}
func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) {
@@ -243,7 +245,7 @@ func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.M
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), moduleNames[m.Kind]+" removed.")
+ redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.trf(r, "%s removed.", s.tr(r, moduleNames[m.Kind])))
}
// ---- menu --------------------------------------------------------------------
@@ -251,14 +253,14 @@ func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.M
func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) {
pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64)
if pageID == 0 {
- redirectOK(w, r, s.layoutURL(r)+"#menu", "Pick a page first.")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Pick a page first."))
return
}
if err := blogStore(r).AddMenuPage(r.Context(), pageID); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#menu", "Page added to the menu. Refresh your blog to see it.")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Page added to the menu. Refresh your blog to see it."))
}
func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) {
@@ -268,17 +270,17 @@ func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) {
}
switch {
case label == "" || len(label) > maxMenuLabel:
- redirectOK(w, r, s.layoutURL(r)+"#menu", "Give the link a short text (max 60 characters).")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Give the link a short text (max 60 characters)."))
return
case !validLinkURL(link):
- redirectOK(w, r, s.layoutURL(r)+"#menu", "That address does not look right. Use a full web address like https://example.org or a path like /about.")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "That address does not look right. Use a full web address like https://example.org or a path like /about."))
return
}
if err := blogStore(r).AddMenuLink(r.Context(), label, link); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#menu", "Link added to the menu. Refresh your blog to see it.")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Link added to the menu. Refresh your blog to see it."))
}
func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) {
@@ -300,5 +302,5 @@ func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.layoutURL(r)+"#menu", "Removed from the menu.")
+ redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Removed from the menu."))
}
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index 09257a3..fdd1bc8 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -69,11 +69,11 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
var msg string
switch {
case err != nil:
- msg = "Image not added: " + err.Error()
+ msg = s.tr(r, "Image not added:") + " " + err.Error()
case p.Title == "" || len(p.Title) > 120:
- msg = "Title is required (max 120 characters)."
+ msg = s.tr(r, "Title is required (max 120 characters).")
case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]:
- msg = "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word)."
+ msg = s.tr(r, "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).")
}
if msg != "" {
s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg})
@@ -98,13 +98,13 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
if errors.Is(err, store.ErrConflict) {
- s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": "A page with that slug already exists."})
+ s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": s.tr(r, "A page with that slug already exists.")})
return
}
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it."))
}
func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) {
@@ -113,7 +113,7 @@ func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request)
return
}
s.render(w, r, "dashboard/confirm.html", map[string]any{
- "what": "the page \"" + p.Title + "\" and all " + strconv.Itoa(p.PostCount) + " of its posts",
+ "what": s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount),
"action": r.URL.Path,
"back": "/b/" + currentBlog(r).Subdomain + "/pages",
"isHome": p.IsHome,
@@ -126,14 +126,14 @@ func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) {
return
}
if p.IsHome {
- s.plainError(w, http.StatusBadRequest, "The home page cannot be deleted. Make another page the home page first.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "The home page cannot be deleted. Make another page the home page first."))
return
}
if err := blogStore(r).DeletePage(r.Context(), p.ID); err != nil {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "Page deleted.")
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.tr(r, "Page deleted."))
}
func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) {
@@ -161,5 +161,5 @@ func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "\""+p.Title+"\" is now the home page.")
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.trf(r, "“%s” is now the home page.", p.Title))
}
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index 02a3fd7..88d5c20 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -86,7 +86,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
}
img, err := s.readUpload(r, "inline_image")
if err != nil {
- fail(http.StatusBadRequest, "Image not added: "+err.Error())
+ fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error())
return
}
p.BodyMD = appendImageMD(p.BodyMD, img)
@@ -98,16 +98,16 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
}
switch {
case p.Title == "" || len(p.Title) > 200:
- fail(http.StatusBadRequest, "Title is required (max 200 characters).")
+ fail(http.StatusBadRequest, s.tr(r, "Title is required (max 200 characters)."))
return
case !slug.Valid(p.Slug):
- fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.")
+ fail(http.StatusBadRequest, s.tr(r, "Slug may only contain lowercase letters, digits and dashes."))
return
case !pageOK:
- fail(http.StatusBadRequest, "Pick a page for this post.")
+ fail(http.StatusBadRequest, s.tr(r, "Pick a page for this post."))
return
case len(p.BodyMD) > 200_000:
- fail(http.StatusBadRequest, "Post is too long (200 KB max).")
+ fail(http.StatusBadRequest, s.tr(r, "Post is too long (200 KB max)."))
return
}
p.BodyHTML = markdown.Render(p.BodyMD)
@@ -128,13 +128,13 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
if errors.Is(err, store.ErrConflict) {
- fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.")
+ fail(http.StatusConflict, s.tr(r, "A post with that slug already exists on this page; choose another slug."))
return
}
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it."))
}
func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) {
@@ -143,7 +143,7 @@ func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request)
return
}
s.render(w, r, "dashboard/confirm.html", map[string]any{
- "what": "the post \"" + p.Title + "\"",
+ "what": s.trf(r, "the post “%s”", p.Title),
"action": r.URL.Path,
"back": "/b/" + currentBlog(r).Subdomain + "/posts",
})
@@ -158,5 +158,5 @@ func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.")
+ redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", s.tr(r, "Post deleted."))
}
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
index 1239545..325192e 100644
--- a/internal/web/handlers_sections.go
+++ b/internal/web/handlers_sections.go
@@ -76,16 +76,16 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
img, err := s.readUpload(r, "inline_image")
switch {
case err != nil:
- fail("Image not added: " + err.Error())
+ fail(s.tr(r, "Image not added:") + " " + err.Error())
return
case len(sec.Title) > 120:
- fail("Title is too long (max 120 characters).")
+ fail(s.tr(r, "Title is too long (max 120 characters)."))
return
case strings.TrimSpace(sec.BodyMD) == "" && img == nil:
- fail("Write some text for the announcement.")
+ fail(s.tr(r, "Write some text for the announcement."))
return
case len(sec.BodyMD) > maxSectionBytes:
- fail("Announcement is too long (20 KB max).")
+ fail(s.tr(r, "Announcement is too long (20 KB max)."))
return
}
sec.BodyMD = appendImageMD(sec.BodyMD, img)
@@ -99,7 +99,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+ redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it."))
}
func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Request) {
@@ -107,9 +107,9 @@ func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Reque
if sec == nil {
return
}
- what := "this announcement"
+ what := s.tr(r, "this announcement")
if sec.Title != "" {
- what = "the announcement \"" + sec.Title + "\""
+ what = s.trf(r, "the announcement “%s”", sec.Title)
}
s.render(w, r, "dashboard/confirm.html", map[string]any{"what": what, "action": r.URL.Path, "back": s.sectionsURL(r)})
}
@@ -123,7 +123,7 @@ func (s *Server) handleSectionDelete(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, s.sectionsURL(r), "Announcement deleted.")
+ redirectOK(w, r, s.sectionsURL(r), s.tr(r, "Announcement deleted."))
}
func (s *Server) handleSectionMove(w http.ResponseWriter, r *http.Request) {
@@ -152,9 +152,9 @@ func (s *Server) handleSectionToggle(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- msg := "Announcement hidden."
+ msg := s.tr(r, "Announcement hidden.")
if !sec.Enabled {
- msg = "Announcement shown. Refresh your blog to see it."
+ msg = s.tr(r, "Announcement shown. Refresh your blog to see it.")
}
redirectOK(w, r, s.sectionsURL(r), msg)
}
diff --git a/internal/web/layout.go b/internal/web/layout.go
index 521cb63..ad55e82 100644
--- a/internal/web/layout.go
+++ b/internal/web/layout.go
@@ -2,10 +2,10 @@ package web
import (
"net/url"
- "strconv"
"strings"
"time"
+ "github.com/gramanas/blogspace/internal/i18n"
"github.com/gramanas/blogspace/internal/store"
)
@@ -20,7 +20,7 @@ var moduleKinds = map[string][]string{
"footer": {"text", "rss", "sitemap", "html"},
}
-// Friendly names for the dashboard.
+// 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",
@@ -60,28 +60,28 @@ func moduleHasSettings(kind string) bool {
}
// moduleSummary is the one-liner the Layout page shows next to a module's name.
-func moduleSummary(m store.Module) string {
+func moduleSummary(lang string, m store.Module) string {
switch m.Kind {
case "recent":
- return strconv.Itoa(m.Count) + " posts" + headingNote(m)
+ return i18n.Tf(lang, "%d posts", m.Count) + headingNote(lang, m)
case "html", "text":
body := strings.Join(strings.Fields(m.Body), " ")
if body == "" {
- return "empty"
+ return i18n.T(lang, "empty")
}
if len(body) > 60 {
body = body[:57] + "…"
}
return body
}
- return strings.TrimPrefix(headingNote(m), ", ")
+ return strings.TrimPrefix(headingNote(lang, m), ", ")
}
-func headingNote(m store.Module) string {
+func headingNote(lang string, m store.Module) string {
if m.Title == "" {
return ""
}
- return ", heading “" + m.Title + "”"
+ return ", " + i18n.Tf(lang, "heading “%s”", m.Title)
}
// validLinkURL accepts what a menu link may point to: a web address, a mail
@@ -174,7 +174,7 @@ func (l Layout) MaxRecent() int {
// ---- archive ---------------------------------------------------------------
type ArchiveMonth struct {
- Name string // "September"
+ Month time.Month // named by the template in the blog's language
Posts []store.PostRef
}
@@ -196,7 +196,7 @@ func groupArchive(posts []store.PostRef) []ArchiveYear {
}
yr := &years[len(years)-1]
if len(yr.Months) == 0 || m != lastM {
- yr.Months = append(yr.Months, ArchiveMonth{Name: time.Month(m).String()})
+ yr.Months = append(yr.Months, ArchiveMonth{Month: time.Month(m)})
}
mo := &yr.Months[len(yr.Months)-1]
mo.Posts = append(mo.Posts, p)
diff --git a/internal/web/server.go b/internal/web/server.go
index a7af06b..4bb408e 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -12,6 +12,7 @@ import (
"github.com/gramanas/blogspace/internal/auth"
"github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/i18n"
"github.com/gramanas/blogspace/internal/store"
)
@@ -27,7 +28,7 @@ type Server struct {
}
func NewServer(cfg *config.Config, st *store.Store) *Server {
- s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)}
+ s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev)}
s.root = s.rootRoutes()
s.blog = s.subdomainRoutes()
return s
@@ -70,6 +71,7 @@ const (
ctxBlog // the blog a handler operates on (public page, or /b/{sub}/ management)
ctxBlogStore // that blog's store, bound to its own database
ctxHostSub // subdomain derived from the Host header
+ ctxLang // language of the page, once a middleware has settled it
)
func currentUser(r *http.Request) *store.User {
@@ -104,7 +106,8 @@ func (s *Server) resolveBlog(r *http.Request, sub string) (*http.Request, error)
return r.WithContext(context.WithValue(ctx, ctxBlogStore, bs)), nil
}
-// hostBlog resolves the blog named by the Host header into the context for public pages.
+// hostBlog resolves the blog named by the Host header into the context for
+// public pages, which speak the blog's language.
func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sub, _ := r.Context().Value(ctxHostSub).(string)
@@ -117,8 +120,47 @@ func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc {
s.serverError(w, err)
return
}
- next(w, r)
+ next(w, withLang(r, currentBlog(r).Language))
+ }
+}
+
+func withLang(r *http.Request, lang string) *http.Request {
+ return r.WithContext(context.WithValue(r.Context(), ctxLang, lang))
+}
+
+// lang is the language of the page being served: settled by hostBlog/withBlog,
+// otherwise the user's own blog's, otherwise whatever the browser asks for.
+func (s *Server) lang(r *http.Request) string {
+ if l, ok := r.Context().Value(ctxLang).(string); ok {
+ return l
+ }
+ if u := currentUser(r); u != nil {
+ return s.userLang(r, u)
+ }
+ return i18n.Match(r.Header.Get("Accept-Language"))
+}
+
+// userLang is the language of the user's own blog (a user has one blog), so
+// the dashboard reads the same on every page, including another user's blog.
+func (s *Server) userLang(r *http.Request, u *store.User) string {
+ blog, err := s.st.BlogByOwner(r.Context(), u.ID)
+ if err == nil {
+ _, err = s.st.Open(r.Context(), blog)
}
+ if err != nil {
+ if !errors.Is(err, store.ErrNotFound) {
+ log.Printf("language of %s: %v", u.Username, err)
+ }
+ return i18n.Default
+ }
+ return blog.Language
+}
+
+// tr translates a message for the request's language; trf also formats it.
+func (s *Server) tr(r *http.Request, key string) string { return i18n.T(s.lang(r), key) }
+
+func (s *Server) trf(r *http.Request, key string, args ...any) string {
+ return i18n.Tf(s.lang(r), key, args...)
}
// session loads the user from the JWT cookie (if any) into the context.
@@ -159,14 +201,14 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
if err := parseForm(r); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
- s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20))
+ s.plainError(w, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20))
return
}
- s.plainError(w, http.StatusBadRequest, "Could not read the form.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form."))
return
}
if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) {
- s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.")
+ s.plainError(w, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again."))
return
}
}
@@ -186,7 +228,7 @@ func parseForm(r *http.Request) error {
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
if !currentUser(r).IsSuperadmin() {
- s.plainError(w, http.StatusForbidden, "Superadmin only.")
+ s.plainError(w, http.StatusForbidden, s.tr(r, "Superadmin only."))
return
}
next(w, r)
@@ -206,11 +248,16 @@ func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc {
s.serverError(w, err)
return
}
- if blog := currentBlog(r); blog.OwnerID != u.ID && !u.IsSuperadmin() {
- s.plainError(w, http.StatusForbidden, "This is not your blog.")
+ blog := currentBlog(r)
+ if blog.OwnerID != u.ID && !u.IsSuperadmin() {
+ s.plainError(w, http.StatusForbidden, s.tr(r, "This is not your blog."))
return
}
- next(w, r)
+ lang := blog.Language
+ if blog.OwnerID != u.ID {
+ lang = s.userLang(r, u)
+ }
+ next(w, withLang(r, lang))
})
}
@@ -226,6 +273,7 @@ type view struct {
BlogURL string
RootURL string
Path string
+ Lang string // i18n code, also the <html lang>
Data map[string]any
}
@@ -237,7 +285,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int
if data == nil {
data = map[string]any{}
}
- v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data}
+ v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Data: data}
if v.User != nil {
v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion)
}
@@ -250,7 +298,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
- if err := s.tpl.render(w, name, v); err != nil {
+ if err := s.tpl.render(w, v.Lang, name, v); err != nil {
log.Printf("render %s: %v", name, err)
fmt.Fprintf(w, "<pre>template error: %v</pre>", err)
}
diff --git a/internal/web/templates.go b/internal/web/templates.go
index e7e35f5..7f71304 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -12,23 +12,27 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/gramanas/blogspace/internal/i18n"
+ "github.com/gramanas/blogspace/internal/store"
)
//go:embed templates static
var assets embed.FS
-// templates compiles each page together with its layout. In dev mode it
-// re-reads from disk on every render so edits show up without a restart.
+// templates compiles each page together with its layout, once per language
+// (the translating funcs are closed over the language, so templates just
+// write {{t "…"}}). In dev mode it re-reads from disk on every render so
+// edits show up without a restart.
type templates struct {
dev bool
fs fs.FS
- funcs template.FuncMap
mu sync.Mutex
- cache map[string]*template.Template
+ cache map[string]*template.Template // "lang/dir/file.html"
}
-func newTemplates(dev bool, funcs template.FuncMap) *templates {
- t := &templates{dev: dev, funcs: funcs, cache: map[string]*template.Template{}}
+func newTemplates(dev bool) *templates {
+ t := &templates{dev: dev, cache: map[string]*template.Template{}}
t.fs = assets
if dev {
// locate the package directory so `go run` from anywhere still finds the files
@@ -54,38 +58,55 @@ func layoutFor(name string) string {
return "layouts/dashboard.html"
}
-func (t *templates) get(name string) (*template.Template, error) {
+func (t *templates) get(lang, name string) (*template.Template, error) {
+ key := lang + "/" + name
if !t.dev {
t.mu.Lock()
- if tpl, ok := t.cache[name]; ok {
+ if tpl, ok := t.cache[key]; ok {
t.mu.Unlock()
return tpl, nil
}
t.mu.Unlock()
}
- tpl, err := template.New("").Funcs(t.funcs).ParseFS(t.fs,
+ tpl, err := template.New("").Funcs(funcsFor(lang)).ParseFS(t.fs,
"templates/"+layoutFor(name), "templates/partials/*.html", "templates/"+name)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", name, err)
}
if !t.dev {
t.mu.Lock()
- t.cache[name] = tpl
+ t.cache[key] = tpl
t.mu.Unlock()
}
return tpl, nil
}
-func (t *templates) render(w io.Writer, name string, data any) error {
- tpl, err := t.get(name)
+func (t *templates) render(w io.Writer, lang, name string, data any) error {
+ tpl, err := t.get(lang, name)
if err != nil {
return err
}
return tpl.ExecuteTemplate(w, "layout", data)
}
+// funcsFor is the shared funcs plus the ones that speak the page's language.
+func funcsFor(lang string) template.FuncMap {
+ m := make(template.FuncMap, len(funcs)+6)
+ for k, v := range funcs {
+ m[k] = v
+ }
+ // t translates fixed text; tf also formats: {{tf "Page %d of %d" .N .Last}}
+ m["t"] = func(key string) string { return i18n.T(lang, key) }
+ m["tf"] = func(key string, args ...any) string { return i18n.Tf(lang, key, args...) }
+ // date is for the dashboard; postdate follows the theme's date style
+ m["date"] = func(t time.Time) string { return i18n.FormatDate(lang, "long", t) }
+ m["postdate"] = func(style string, t time.Time) string { return i18n.FormatDate(lang, style, t) }
+ m["month"] = func(mo time.Month) string { return i18n.Month(lang, mo) }
+ m["modsummary"] = func(mod store.Module) string { return moduleSummary(lang, mod) }
+ return m
+}
+
var funcs = template.FuncMap{
- "date": func(t time.Time) string { return t.Format("2 January 2006") },
"rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) },
"html": func(s string) template.HTML { return template.HTML(s) },
"css": func(s string) template.CSS { return template.CSS(s) },
@@ -107,8 +128,7 @@ var funcs = template.FuncMap{
// split turns a plain-text field into lines: {{range split .Body "\n"}}<p>{{.}}</p>{{end}}
"split": strings.Split,
"lower": strings.ToLower,
- // layout modules: one-line description and whether the kind has an edit page
- "modsummary": moduleSummary,
+ // layout modules: whether the kind has an edit page (modsummary is per language)
"modsettings": moduleHasSettings,
// hasPrefix marks the current dashboard tab: {{if hasPrefix .Path "/b/x/posts"}}
"hasPrefix": strings.HasPrefix,
diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html
index e673b64..886ff1a 100644
--- a/internal/web/templates/admin/delete_user.html
+++ b/internal/web/templates/admin/delete_user.html
@@ -1,12 +1,12 @@
-{{define "title"}}Delete user · Blogspace{{end}}
+{{define "title"}}{{t "Delete user"}} · Blogspace{{end}}
{{define "content"}}
<div class="card narrow">
- <h1>Delete {{.Data.target.Username}}?</h1>
- <p>This permanently deletes the user <strong>{{.Data.target.Username}}</strong>, their blog, and every page, post and image in it.</p>
+ <h1>{{tf "Delete %s?" .Data.target.Username}}</h1>
+ <p>{{t "This permanently deletes the user"}} <strong>{{.Data.target.Username}}</strong>{{t ", their blog, and every page, post and image in it."}}</p>
<form method="post" action="/admin/users/{{.Data.target.ID}}/delete">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <p class="actionrow"><button type="submit" class="danger">Yes, delete everything</button>
- <a class="btn secondary" href="/admin/">Cancel</a></p>
+ <p class="actionrow"><button type="submit" class="danger">{{t "Yes, delete everything"}}</button>
+ <a class="btn secondary" href="/admin/">{{t "Cancel"}}</a></p>
</form>
</div>
{{end}}
diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html
index 8293368..0e49e62 100644
--- a/internal/web/templates/admin/index.html
+++ b/internal/web/templates/admin/index.html
@@ -1,29 +1,29 @@
-{{define "title"}}Admin · Blogspace{{end}}
+{{define "title"}}{{t "Admin"}} · Blogspace{{end}}
{{define "content"}}
<div class="pagehead">
- <h1>Users &amp; blogs</h1>
- <div class="actions"><a class="btn" href="/admin/users/new">+ New blogger</a></div>
+ <h1>{{t "Users & blogs"}}</h1>
+ <div class="actions"><a class="btn" href="/admin/users/new">+ {{t "New blogger"}}</a></div>
</div>
<div class="card">
<table>
- <tr><th>User</th><th>Role</th><th>Blog</th><th>Since</th><th>Actions</th></tr>
+ <tr><th>{{t "User"}}</th><th>{{t "Role"}}</th><th>{{t "Blog"}}</th><th>{{t "Since"}}</th><th>{{t "Actions"}}</th></tr>
{{range .Data.users}}<tr{{if .Disabled}} class="disabled"{{end}}>
- <td>{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td>
- <td>{{.Role}}</td>
- <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">view &#8599;</a>{{else}}<span class="muted">—</span>{{end}}</td>
+ <td>{{.Username}}{{if .Disabled}} <span class="tag">{{t "disabled"}}</span>{{end}}</td>
+ <td>{{t .Role}}</td>
+ <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{t "view"}} &#8599;</a>{{else}}<span class="muted">—</span>{{end}}</td>
<td class="nowrap">{{date .CreatedAt}}</td>
<td class="nowrap">
{{if ne .ID $.User.ID}}
- <details class="inline"><summary class="mini">reset password</summary>
+ <details class="inline"><summary class="mini">{{t "reset password"}}</summary>
<form method="post" action="/admin/users/{{.ID}}/reset-password" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
- <input type="password" name="password" placeholder="new password" minlength="8" required size="14">
- <button class="mini">Set</button>
+ <input type="password" name="password" placeholder="{{t "new password"}}" minlength="8" required size="14">
+ <button class="mini">{{t "Set"}}</button>
</form></details>
- {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">enable</button></form>
- {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">disable</button></form>{{end}}
- <a class="danger" href="/admin/users/{{.ID}}/delete">delete</a>
- {{else}}<span class="muted">(you)</span>{{end}}
+ {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "enable"}}</button></form>
+ {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "disable"}}</button></form>{{end}}
+ <a class="danger" href="/admin/users/{{.ID}}/delete">{{t "delete"}}</a>
+ {{else}}<span class="muted">({{t "you"}})</span>{{end}}
</td>
</tr>{{end}}
</table>
diff --git a/internal/web/templates/admin/new_user.html b/internal/web/templates/admin/new_user.html
index 3d93dbd..f1c79be 100644
--- a/internal/web/templates/admin/new_user.html
+++ b/internal/web/templates/admin/new_user.html
@@ -1,15 +1,15 @@
-{{define "title"}}New blogger · Blogspace{{end}}
+{{define "title"}}{{t "New blogger"}} · Blogspace{{end}}
{{define "content"}}
<div class="card narrow">
- <h1>New blogger</h1>
+ <h1>{{t "New blogger"}}</h1>
<form method="post" action="/admin/users/new">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Username<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label>
- <label>Password <span class="muted">(min 8 characters; they can change it later)</span><br><input type="password" name="password" required minlength="8"></label>
- <label>Subdomain <span class="muted">(defaults to the username)</span><br>
+ <label>{{t "Username"}}<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label>
+ <label>{{t "Password"}} <span class="muted">({{t "min 8 characters; they can change it later"}})</span><br><input type="password" name="password" required minlength="8"></label>
+ <label>{{t "Subdomain"}} <span class="muted">({{t "defaults to the username"}})</span><br>
<span class="nowrap"><input name="subdomain" value="{{.Data.subdomain}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="63" size="14" style="width:auto">.{{.Data.cfg.BaseDomain}}</span></label>
- <label>Blog title<br><input name="title" value="{{.Data.title}}" maxlength="120"></label>
- <p class="actionrow"><button type="submit">Create</button> <a href="/admin/">Cancel</a></p>
+ <label>{{t "Blog title"}}<br><input name="title" value="{{.Data.title}}" maxlength="120"></label>
+ <p class="actionrow"><button type="submit">{{t "Create"}}</button> <a href="/admin/">{{t "Cancel"}}</a></p>
</form>
</div>
{{end}}
diff --git a/internal/web/templates/auth/login.html b/internal/web/templates/auth/login.html
index 3f66f3f..938b70e 100644
--- a/internal/web/templates/auth/login.html
+++ b/internal/web/templates/auth/login.html
@@ -1,13 +1,13 @@
-{{define "title"}}Log in · Blogspace{{end}}
+{{define "title"}}{{t "Log in"}} · Blogspace{{end}}
{{define "content"}}
<p class="login-brand">Blogspace</p>
<div class="card narrow center">
- <h1>Log in</h1>
+ <h1>{{t "Log in"}}</h1>
<form method="post" action="/webadmin">
<input type="hidden" name="next" value="{{.Data.next}}">
- <label>Username<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label>
- <label>Password<br><input type="password" name="password" required autocomplete="current-password"></label>
- <p class="actionrow"><button type="submit">Log in</button></p>
+ <label>{{t "Username"}}<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label>
+ <label>{{t "Password"}}<br><input type="password" name="password" required autocomplete="current-password"></label>
+ <p class="actionrow"><button type="submit">{{t "Log in"}}</button></p>
</form>
</div>
{{end}}
diff --git a/internal/web/templates/blog/404.html b/internal/web/templates/blog/404.html
index b0aa6a9..00f5d14 100644
--- a/internal/web/templates/blog/404.html
+++ b/internal/web/templates/blog/404.html
@@ -1,5 +1,5 @@
-{{define "title"}}Not found · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Not found"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
-<h1>Not found</h1>
-<p>There is nothing at this address. <a href="/">Back to the front page.</a></p>
+<h1>{{t "Not found"}}</h1>
+<p>{{t "There is nothing at this address."}} <a href="/">{{t "Back to the front page."}}</a></p>
{{end}}
diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html
index ba78ff6..ac6a609 100644
--- a/internal/web/templates/blog/page.html
+++ b/internal/web/templates/blog/page.html
@@ -5,15 +5,15 @@
{{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">{{$.Data.theme.FormatDate .CreatedAt}}</p>{{end}}
+ {{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">Nothing here yet.</p>{{end}}{{end}}
+{{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; Newer</a>{{end}}
- <span>Page {{.Data.pageNum}} of {{.Data.lastPage}}</span>
- {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">Older &rarr;</a>{{end}}
+ {{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}}
diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html
index 5fa8ca4..d14f22a 100644
--- a/internal/web/templates/blog/post.html
+++ b/internal/web/templates/blog/post.html
@@ -2,7 +2,7 @@
{{define "content"}}
<article class="post single">
<h1 class="post-title">{{.Data.post.Title}}</h1>
- <p class="post-date">{{if .Data.theme.ShowDates}}{{.Data.theme.FormatDate .Data.post.CreatedAt}} &middot; {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p>
+ <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>
<div class="post-body">{{html .Data.post.BodyHTML}}</div>
</article>
{{end}}
diff --git a/internal/web/templates/dashboard/confirm.html b/internal/web/templates/dashboard/confirm.html
index 8dd0b3a..62bf4a4 100644
--- a/internal/web/templates/dashboard/confirm.html
+++ b/internal/web/templates/dashboard/confirm.html
@@ -1,16 +1,16 @@
-{{define "title"}}Confirm · Blogspace{{end}}
+{{define "title"}}{{t "Confirm"}} · Blogspace{{end}}
{{define "content"}}
<div class="card narrow">
- <h1>Are you sure?</h1>
+ <h1>{{t "Are you sure?"}}</h1>
{{if .Data.isHome}}
- <p>This is the home page; it cannot be deleted. Make another page the home page first.</p>
- <p><a class="btn secondary" href="{{.Data.back}}">Back</a></p>
+ <p>{{t "This is the home page; it cannot be deleted. Make another page the home page first."}}</p>
+ <p><a class="btn secondary" href="{{.Data.back}}">{{t "Back"}}</a></p>
{{else}}
- <p>You are about to permanently delete {{.Data.what}}. This cannot be undone.</p>
+ <p>{{tf "You are about to permanently delete %s. This cannot be undone." .Data.what}}</p>
<form method="post" action="{{.Data.action}}">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <p class="actionrow"><button type="submit" class="danger">Yes, delete</button>
- <a class="btn secondary" href="{{.Data.back}}">Cancel</a></p>
+ <p class="actionrow"><button type="submit" class="danger">{{t "Yes, delete"}}</button>
+ <a class="btn secondary" href="{{.Data.back}}">{{t "Cancel"}}</a></p>
</form>
{{end}}
</div>
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index 9c2a12f..e961388 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -1,21 +1,21 @@
-{{define "title"}}Design · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Design"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$t := .Data.theme}}
<div class="pagehead">
- <h1>Design</h1>
- <p class="lead muted">Changes are live as soon as you save; refresh <a href="{{.BlogURL}}" target="_blank">your blog &#8599;</a> to see them.</p>
+ <h1>{{t "Design"}}</h1>
+ <p class="lead muted">{{t "Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} &#8599;</a> {{t "to see them."}}</p>
</div>
-<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#logo">Logo &amp; icon</a></p>
-<p class="muted small">What appears where — header, columns, footer and their modules — is arranged on the <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a> tab.</p>
+<p class="jumps"><a href="#schemes">{{t "Schemes"}}</a><a href="#background">{{t "Background"}}</a><a href="#text">{{t "Text"}}</a><a href="#header">{{t "Header"}}</a><a href="#menu">{{t "Menu"}}</a><a href="#posts">{{t "Posts"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#logo">{{t "Logo & icon"}}</a></p>
+<p class="muted small">{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a> {{t "tab."}}</p>
<div class="card" id="schemes">
- <h2>Colour schemes</h2>
- <p class="muted small">A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.</p>
+ <h2>{{t "Colour schemes"}}</h2>
+ <p class="muted small">{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}</p>
<form method="post" action="/b/{{.Blog.Subdomain}}/design/preset" class="presets">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="Apply {{.Name}}">
+ {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="{{tf "Apply %s" (t .Name)}}">
<span class="sw" style="background:{{.HeaderBg}}"></span><span class="sw" style="background:{{.Bg}}"></span><span class="sw" style="background:{{.ContentBg}}"></span><span class="sw" style="background:{{.Link}}"></span>
- <span class="pname">{{.Name}}</span>
+ <span class="pname">{{t .Name}}</span>
</button>
{{end}}
</form>
@@ -25,158 +25,158 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="card" id="background">
- <h2>Background</h2>
+ <h2>{{t "Background"}}</h2>
<div class="row">
- <label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label>
- <label>Image style<br><select name="bg_mode">
- <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>Stretch to fill</option>
- <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>Fill, fixed while scrolling</option>
- <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>Repeat (tile)</option>
+ <label>{{t "Colour"}}<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label>
+ <label>{{t "Image style"}}<br><select name="bg_mode">
+ <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>{{t "Stretch to fill"}}</option>
+ <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>{{t "Fill, fixed while scrolling"}}</option>
+ <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>{{t "Repeat (tile)"}}</option>
</select></label>
</div>
- {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" "Background image")}}
+ {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Background image"))}}
</div>
<div class="card" id="text">
- <h2>Text &amp; content area</h2>
+ <h2>{{t "Text & content area"}}</h2>
<div class="row">
- <label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
- <label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label>
- <label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label>
+ <label>{{t "Content background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
+ <label>{{t "Text colour"}}<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label>
+ <label>{{t "Link colour"}}<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label>
</div>
<div class="hoverpick">
<input type="checkbox" name="link_hover_custom" id="link-hover-custom"{{if $t.LinkHover}} checked{{end}}>
- <label for="link-hover-custom" class="check">Change colour when the mouse is over a link</label>
- <label class="hover-color">Hover colour<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label>
+ <label for="link-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label>
+ <label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label>
</div>
<div class="row">
- <label>Font<br><select name="font">
+ <label>{{t "Font"}}<br><select name="font">
<option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option>
<option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option>
<option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option>
</select></label>
- <label>Heading font<br><select name="heading_font">
- <option value="same"{{if eq $t.HeadingFont "same"}} selected{{end}}>Same as text</option>
+ <label>{{t "Heading font"}}<br><select name="heading_font">
+ <option value="same"{{if eq $t.HeadingFont "same"}} selected{{end}}>{{t "Same as text"}}</option>
<option value="sans"{{if eq $t.HeadingFont "sans"}} selected{{end}}>Sans-serif</option>
<option value="serif"{{if eq $t.HeadingFont "serif"}} selected{{end}}>Serif</option>
<option value="mono"{{if eq $t.HeadingFont "mono"}} selected{{end}}>Monospace</option>
</select></label>
- <label>Text size<br><select name="font_size">
- <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option>
- <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option>
- <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option>
+ <label>{{t "Text size"}}<br><select name="font_size">
+ <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>{{t "Small"}}</option>
+ <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>{{t "Normal"}}</option>
+ <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>{{t "Large"}}</option>
</select></label>
- <label>Underline links<br><select name="link_underline">
- <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>Always</option>
- <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>Only on hover</option>
- <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>Never</option>
+ <label>{{t "Underline links"}}<br><select name="link_underline">
+ <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option>
+ <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option>
+ <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option>
</select></label>
</div>
<div class="row">
- <label>Box style<br><select name="content_style">
- <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>Card (shadow, rounded corners)</option>
- <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>Flat</option>
+ <label>{{t "Box style"}}<br><select name="content_style">
+ <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option>
+ <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option>
</select></label>
- <label>Padding <span class="muted">(inside the box)</span><br><select name="content_padding">
- <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>Compact</option>
- <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>Normal</option>
- <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>Roomy</option>
+ <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding">
+ <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option>
+ <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option>
+ <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option>
</select></label>
</div>
- <p class="muted small">The content spans the whole page; how much of it the posts get is set by the column widths on the <a href="/b/{{.Blog.Subdomain}}/layout#columns">Layout</a> tab.</p>
+ <p class="muted small">{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#columns">{{t "Layout"}}</a> {{t "tab."}}</p>
</div>
<div class="card" id="header">
- <h2>Header</h2>
+ <h2>{{t "Header"}}</h2>
<div class="row">
- <label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label>
- <label>Text colour<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label>
- <label>Alignment<br><select name="header_align">
- <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option>
- <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option>
+ <label>{{t "Background"}}<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label>
+ <label>{{t "Text colour"}}<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label>
+ <label>{{t "Alignment"}}<br><select name="header_align">
+ <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>{{t "Left"}}</option>
+ <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>{{t "Centred"}}</option>
</select></label>
- <label>Image height<br><select name="header_image_height">
- <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>Short</option>
- <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>Medium</option>
- <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>Tall</option>
+ <label>{{t "Image height"}}<br><select name="header_image_height">
+ <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option>
+ <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option>
+ <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option>
</select></label>
</div>
- <p class="muted small">The title, logo and menu are header modules — add, remove and order them on the <a href="/b/{{.Blog.Subdomain}}/layout#header">Layout</a> tab. The banner below is shown across the top of the header whenever one is chosen.</p>
- {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" "Header image (banner)")}}
+ <p class="muted small">{{t "The title, logo and menu are header modules — add, remove and order them on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#header">{{t "Layout"}}</a> {{t "tab."}} {{t "The banner below is shown across the top of the header whenever one is chosen."}}</p>
+ {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Header image (banner)"))}}
</div>
<div class="card" id="menu">
- <h2>Menu</h2>
- <p class="muted small">Which links the menu shows, and where it sits, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab. Here you choose how it looks.</p>
+ <h2>{{t "Menu"}}</h2>
+ <p class="muted small">{{t "Which links the menu shows, and where it sits, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}} {{t "Here you choose how it looks."}}</p>
<div class="row">
- <label>Alignment <span class="muted">(menu in the header)</span><br><select name="nav_align">
- <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option>
- <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option>
- <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option>
+ <label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align">
+ <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option>
+ <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>{{t "Centred"}}</option>
+ <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>{{t "Right"}}</option>
</select></label>
- <label>Link style<br><select name="nav_style">
- <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>Normal</option>
- <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>Uppercase, spaced</option>
+ <label>{{t "Link style"}}<br><select name="nav_style">
+ <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>{{t "Normal"}}</option>
+ <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>{{t "Uppercase, spaced"}}</option>
</select></label>
</div>
- <h3>Menu colours</h3>
+ <h3>{{t "Menu colours"}}</h3>
<div class="row">
- <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
- <label>Text<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
+ <label>{{t "Background"}}<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
+ <label>{{t "Text"}}<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
</div>
<div class="hoverpick">
<input type="checkbox" name="nav_hover_custom" id="nav-hover-custom"{{if $t.NavHover}} checked{{end}}>
- <label for="nav-hover-custom" class="check">Change colour when the mouse is over a link</label>
- <label class="hover-color">Hover colour<br><input type="color" name="nav_hover" value="{{if $t.NavHover}}{{$t.NavHover}}{{else}}{{$t.NavText}}{{end}}"></label>
+ <label for="nav-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label>
+ <label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="nav_hover" value="{{if $t.NavHover}}{{$t.NavHover}}{{else}}{{$t.NavText}}{{end}}"></label>
</div>
</div>
<div class="card" id="posts">
- <h2>Posts &amp; dates</h2>
- <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> Show the date on posts</label>
+ <h2>{{t "Posts & dates"}}</h2>
+ <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> {{t "Show the date on posts"}}</label>
<div class="row">
- <label>Date format<br><select name="date_format">
- <option value="long"{{if eq $t.DateFormat "long"}} selected{{end}}>12 September 2026</option>
- <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>12 Sep 2026</option>
- <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>2026-09-12</option>
+ <label>{{t "Date format"}}<br><select name="date_format">
+ <option value="long"{{if eq $t.DateFormat "long"}} selected{{end}}>{{postdate "long" .Data.today}}</option>
+ <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>{{postdate "short" .Data.today}}</option>
+ <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>{{postdate "iso" .Data.today}}</option>
</select></label>
</div>
</div>
<div class="card" id="footer">
- <h2>Footer</h2>
- <p class="muted small">The footer text, RSS link and other footer modules live on the <a href="/b/{{.Blog.Subdomain}}/layout#footer">Layout</a> tab.</p>
+ <h2>{{t "Footer"}}</h2>
+ <p class="muted small">{{t "The footer text, RSS link and other footer modules live on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#footer">{{t "Layout"}}</a> {{t "tab."}}</p>
<div class="row">
- <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label>
- <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label>
+ <label>{{t "Background"}}<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label>
+ <label>{{t "Text colour"}}<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label>
</div>
</div>
<div class="card" id="logo">
- <h2>Logo &amp; site icon</h2>
+ <h2>{{t "Logo & site icon"}}</h2>
<div class="row">
- <label>Logo size<br><select name="logo_size">
- <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>Small (48 px tall)</option>
- <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>Medium (80 px)</option>
- <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>Large (140 px)</option>
+ <label>{{t "Logo size"}}<br><select name="logo_size">
+ <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option>
+ <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option>
+ <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option>
</select></label>
</div>
- {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}}
- {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
+ {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module (Layout tab); it links to your front page."))}}
+ {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
</div>
<div class="savebar">
- <button type="submit">Save design</button>
- <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+ <button type="submit">{{t "Save design"}}</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
</div>
</form>
<details class="help reset">
- <summary>Reset to defaults</summary>
- <p>Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).</p>
+ <summary>{{t "Reset to defaults"}}</summary>
+ <p>{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library)."}}</p>
<form method="post" action="/b/{{.Blog.Subdomain}}/design/reset">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <button type="submit" class="danger">Reset everything</button>
+ <button type="submit" class="danger">{{t "Reset everything"}}</button>
</form>
</details>
{{end}}
diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html
index 2e39531..8b9faa3 100644
--- a/internal/web/templates/dashboard/images.html
+++ b/internal/web/templates/dashboard/images.html
@@ -1,14 +1,14 @@
-{{define "title"}}Images · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Images"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="pagehead">
- <h1>Images</h1>
- <p class="lead muted">Images are stored with your blog. Put one in a post with <em>Insert image</em> in the editor, or copy its line from below.</p>
+ <h1>{{t "Images"}}</h1>
+ <p class="lead muted">{{t "Images are stored with your blog. Put one in a post with"}} <em>{{t "Insert image"}}</em> {{t "in the editor, or copy its line from below."}}</p>
</div>
<div class="card">
<form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Upload an image <span class="muted">(PNG, JPEG, GIF, WebP or ICO)</span><br><input type="file" name="file" accept="image/*" required></label>
- <p class="actionrow"><button type="submit">Upload</button></p>
+ <label>{{t "Upload an image"}} <span class="muted">({{t "PNG, JPEG, GIF, WebP or ICO"}})</span><br><input type="file" name="file" accept="image/*" required></label>
+ <p class="actionrow"><button type="submit">{{t "Upload"}}</button></p>
</form>
</div>
<div class="gallery">
@@ -17,8 +17,8 @@
<a href="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" target="_blank"><img src="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" alt="{{.Filename}}"></a>
<div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div>
<input class="copy" readonly value="![{{.Filename}}](/media/{{.ID}})" onclick="this.select()">
- <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">delete</button></form>
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "delete"}}</button></form>
</div>
-{{else}}<p class="muted">No images yet.</p>{{end}}
+{{else}}<p class="muted">{{t "No images yet."}}</p>{{end}}
</div>
{{end}}
diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html
index 229ca0e..48b6778 100644
--- a/internal/web/templates/dashboard/layout.html
+++ b/internal/web/templates/dashboard/layout.html
@@ -1,86 +1,86 @@
-{{define "title"}}Layout · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Layout"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$t := .Data.theme}}
{{$u := print "/b/" .Blog.Subdomain "/layout"}}
<div class="pagehead">
- <h1>Layout</h1>
- <p class="lead muted">Decide what goes where on your blog. Every change here is saved straight away; refresh <a href="{{.BlogURL}}" target="_blank">your blog &#8599;</a> to see it. Colours and fonts are on the <a href="/b/{{.Blog.Subdomain}}/design">Design</a> tab.</p>
+ <h1>{{t "Layout"}}</h1>
+ <p class="lead muted">{{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} &#8599;</a> {{t "to see it."}} {{t "Colours and fonts are on the"}} <a href="/b/{{.Blog.Subdomain}}/design">{{t "Design"}}</a> {{t "tab."}}</p>
</div>
-<p class="jumps"><a href="#columns">Columns</a><a href="#header">Header</a><a href="#left">Left column</a><a href="#main">Main content</a><a href="#right">Right column</a><a href="#footer">Footer</a><a href="#menu">Menu</a></p>
+<p class="jumps"><a href="#columns">{{t "Columns"}}</a><a href="#header">{{t "Header"}}</a><a href="#left">{{t "Left column"}}</a><a href="#main">{{t "Main content"}}</a><a href="#right">{{t "Right column"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#menu">{{t "Menu"}}</a></p>
<div class="card" id="columns">
- <h2>Columns</h2>
+ <h2>{{t "Columns"}}</h2>
<div class="lay">
- <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">Header{{if not $t.HeaderOn}} (hidden){{end}}</div>
+ <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}</div>
<div class="lay-cols">
- <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">Left {{$t.LeftWidth}}%</div>
- <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">Main content {{$t.MainWidth}}%</div>
- <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">Right {{$t.RightWidth}}%</div>
+ <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} {{$t.LeftWidth}}%</div>
+ <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} {{$t.MainWidth}}%</div>
+ <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} {{$t.RightWidth}}%</div>
</div>
- <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">Footer{{if not $t.FooterOn}} (hidden){{end}}</div>
+ <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}</div>
</div>
<form method="post" action="{{$u}}/widths">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<div class="row">
- <label>Left column width<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label>
- <label>Right column width<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label>
+ <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label>
+ <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label>
</div>
- <p class="muted small">Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer.</p>
- <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> Always keep the columns' space, even when a column is empty or hidden <span class="muted">(the main content then keeps its width and stays centred)</span></label>
- <p class="actionrow"><button type="submit">Save columns</button></p>
+ <p class="muted small">{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}</p>
+ <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label>
+ <p class="actionrow"><button type="submit">{{t "Save columns"}}</button></p>
</form>
</div>
-{{template "area" (dict "area" "header" "root" . "hint" "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab.")}}
-{{template "area" (dict "area" "left" "root" . "hint" "A narrow column beside your posts. Good for the menu, an archive or recent posts.")}}
+{{template "area" (dict "area" "header" "root" . "hint" (t "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab."))}}
+{{template "area" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}}
<div class="card" id="main">
- <div class="cardhead"><h2>Main content</h2></div>
- <p class="muted small">Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…</p>
+ <div class="cardhead"><h2>{{t "Main content"}}</h2></div>
+ <p class="muted small">{{t "Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…"}}</p>
<table class="modules">
{{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}}
- <tr><th>{{index $.Data.areaNames $area}}</th>
+ <tr><th>{{t (index $.Data.areaNames $area)}}</th>
{{if $mods}}{{$m := index $mods 0}}
- <td>{{index $.Data.names $m.Kind}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td>
- <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">edit</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">remove</a></td>
+ <td>{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td>
+ <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">{{t "edit"}}</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">{{t "remove"}}</a></td>
{{else}}
- <td class="muted">Nothing extra.</td>
- <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ Add custom HTML</button></form></td>
+ <td class="muted">{{t "Nothing extra."}}</td>
+ <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ {{t "Add custom HTML"}}</button></form></td>
{{end}}
</tr>{{end}}
</table>
</div>
-{{template "area" (dict "area" "right" "root" . "hint" "A narrow column on the other side of your posts. Same choices as the left one.")}}
-{{template "area" (dict "area" "footer" "root" . "hint" "The bottom of every page: a copyright line, the RSS link, a list of your pages.")}}
+{{template "area" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}}
+{{template "area" (dict "area" "footer" "root" . "hint" (t "The bottom of every page: a copyright line, the RSS link, a list of your pages."))}}
<div class="card" id="menu">
- <h2>Menu</h2>
- <p class="muted small">The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking "Show in menu" on a page does the same as adding it here.</p>
+ <h2>{{t "Menu"}}</h2>
+ <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p>
{{if .Data.menu}}<table class="modules">
{{range .Data.menu}}<tr>
<td class="nowrap order">
- <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
- <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
+ <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
</td>
- <td>{{.Text}} {{if .PageID}}<span class="tag">page</span>{{else}}<span class="tag">link</span> <span class="muted small">{{.URL}}</span>{{end}}</td>
- <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">Remove</button></form></td>
+ <td>{{.Text}} {{if .PageID}}<span class="tag">{{t "page"}}</span>{{else}}<span class="tag">{{t "link"}}</span> <span class="muted small">{{.URL}}</span>{{end}}</td>
+ <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "Remove"}}</button></form></td>
</tr>{{end}}
- </table>{{else}}<p class="muted">The menu is empty.</p>{{end}}
+ </table>{{else}}<p class="muted">{{t "The menu is empty."}}</p>{{end}}
<div class="cols addrow">
<form method="post" action="{{$u}}/menu/add-page" class="add">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <h3>Add a page</h3>
- {{if .Data.freePages}}<label>Page<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label>
- <button type="submit" class="secondary">Add page</button>
- {{else}}<p class="muted small">Every page is already in the menu.</p>{{end}}
+ <h3>{{t "Add a page"}}</h3>
+ {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label>
+ <button type="submit" class="secondary">{{t "Add page"}}</button>
+ {{else}}<p class="muted small">{{t "Every page is already in the menu."}}</p>{{end}}
</form>
<form method="post" action="{{$u}}/menu/add-link" class="add">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <h3>Add a link</h3>
- <label>Text<br><input name="label" maxlength="60" placeholder="My photos"></label>
- <label>Address<br><input name="url" maxlength="500" placeholder="https://example.org"></label>
- <button type="submit" class="secondary">Add link</button>
+ <h3>{{t "Add a link"}}</h3>
+ <label>{{t "Text"}}<br><input name="label" maxlength="60" placeholder="{{t "My photos"}}"></label>
+ <label>{{t "Address"}}<br><input name="url" maxlength="500" placeholder="https://example.org"></label>
+ <button type="submit" class="secondary">{{t "Add link"}}</button>
</form>
</div>
</div>
@@ -90,24 +90,24 @@
{{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}}
<div class="card{{if not $on}} area-off{{end}}" id="{{$area}}">
<div class="cardhead">
- <h2>{{index $r.Data.areaNames $area}}{{if not $on}} <span class="tag off">hidden</span>{{end}}</h2>
- <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}Hide this area{{else}}Show this area{{end}}</button></form>
+ <h2>{{t (index $r.Data.areaNames $area)}}{{if not $on}} <span class="tag off">{{t "hidden"}}</span>{{end}}</h2>
+ <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}{{t "Hide this area"}}{{else}}{{t "Show this area"}}{{end}}</button></form>
</div>
<p class="muted small">{{.hint}}</p>
{{if $mods}}<table class="modules">
{{range $mods}}<tr>
<td class="nowrap order">
- <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
- <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
+ <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
</td>
- <td>{{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">no logo chosen</span> <span class="muted small">pick one on the <a href="/b/{{$r.Blog.Subdomain}}/design#logo">Design</a> tab</span>{{end}}</td>
- <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">edit</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">remove</a></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>
</tr>{{end}}
- </table>{{else}}<p class="muted">Nothing here yet{{if ne $area "header"}} — this area is not shown until you add a module{{end}}.</p>{{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">
<input type="hidden" name="_csrf" value="{{$r.CSRF}}">
- <label>Add a module<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{index $r.Data.names .}}</option>{{end}}</select></label>
- <button type="submit" class="secondary">Add</button>
+ <label>{{t "Add a module"}}<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label>
+ <button type="submit" class="secondary">{{t "Add"}}</button>
</form>
</div>{{end}}
diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html
index b63b405..0cd27fa 100644
--- a/internal/web/templates/dashboard/module_form.html
+++ b/internal/web/templates/dashboard/module_form.html
@@ -2,32 +2,32 @@
{{define "content"}}
{{$m := .Data.module}}
<div class="card">
- <h1>{{.Data.name}} <span class="muted small">in the {{lower .Data.area}}</span></h1>
+ <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>Heading <span class="muted">(optional)</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}Recent posts{{else if eq $m.Kind "archive"}}Archive{{else if eq $m.Kind "sitemap"}}Pages{{end}}"></label>
+ <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>
{{end}}
{{if eq $m.Kind "recent"}}
- <div class="row"><label>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>
- <p class="muted small">The newest published posts from every page of your blog, newest first.</p>
+ <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>
+ <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p>
{{else if eq $m.Kind "archive"}}
- <p class="muted small">Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.</p>
+ <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">Links to every page of your blog.</p>
+ <p class="muted small">{{t "Links to every page of your blog."}}</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;Hello!&lt;/p&gt;">{{$m.Body}}</textarea>
- <p class="muted small">Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.</p>
+ <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="&lt;p&gt;{{t "Hello!"}}&lt;/p&gt;">{{$m.Body}}</textarea>
+ <p class="muted small">{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}</p>
{{else if eq $m.Kind "text"}}
- <label for="mod-body">Text</label>
- <textarea id="mod-body" name="body" rows="4" placeholder="© 2026 Me">{{$m.Body}}</textarea>
- <p class="muted small">Plain text; each line becomes its own paragraph.</p>
+ <label for="mod-body">{{t "Text"}}</label>
+ <textarea id="mod-body" name="body" rows="4" placeholder="{{t "© 2026 Me"}}">{{$m.Body}}</textarea>
+ <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p>
{{end}}
<p class="actionrow">
- <button type="submit">Save</button>
- <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
- <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">Back to layout</a>
+ <button type="submit">{{t "Save"}}</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
+ <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">{{t "Back to layout"}}</a>
</p>
</form>
</div>
diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html
index acd539d..e021495 100644
--- a/internal/web/templates/dashboard/overview.html
+++ b/internal/web/templates/dashboard/overview.html
@@ -1,35 +1,35 @@
{{define "title"}}{{.Blog.Title}} · Blogspace{{end}}
{{define "content"}}
<h1>{{.Blog.Title}}</h1>
-<p class="muted">Your blog is live at <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. Every change you save here shows up there as soon as you refresh.</p>
+<p class="muted">{{t "Your blog is live at"}} <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. {{t "Every change you save here shows up there as soon as you refresh."}}</p>
<div class="cols">
<div class="card">
- <h2>Quick actions</h2>
- <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p>
- <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">Arrange the layout</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
+ <h2>{{t "Quick actions"}}</h2>
+ <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write a new post"}}</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">{{t "Upload images"}}</a></p>
</div>
<div class="card">
- <h2>Pages</h2>
+ <h2>{{t "Pages"}}</h2>
<ul class="plain">
- {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})</span></li>{{end}}
+ {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{tf "%d posts" .PostCount}}{{if .IsHome}}, {{t "home"}}{{end}}{{if not .ShowInNav}}, {{t "hidden from menu"}}{{end}})</span></li>{{end}}
</ul>
</div>
</div>
<div class="card">
- <h2>Latest posts</h2>
+ <h2>{{t "Latest posts"}}</h2>
{{if .Data.posts}}
<table>
- <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr>
+ <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">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}}</td>
<td>{{.PageTitle}}</td>
<td>{{date .CreatedAt}}</td>
- <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view &#8599;</a></td>
+ <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">{{t "view"}} &#8599;</a></td>
</tr>{{end}}
</table>
- <p><a href="/b/{{.Blog.Subdomain}}/posts">All posts</a></p>
- {{else}}<p class="muted">No posts yet. <a href="/b/{{.Blog.Subdomain}}/posts/new">Write the first one.</a></p>{{end}}
+ <p><a href="/b/{{.Blog.Subdomain}}/posts">{{t "All posts"}}</a></p>
+ {{else}}<p class="muted">{{t "No posts yet."}} <a href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write the first one."}}</a></p>{{end}}
</div>
{{end}}
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 2692449..08c4a43 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -1,19 +1,19 @@
-{{define "title"}}{{if .Data.page.ID}}Edit page{{else}}New page{{end}} · {{.Blog.Title}}{{end}}
+{{define "title"}}{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="card">
- <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1>
+ <h1>{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}}</h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label>
- <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br>
+ <label>{{t "Title"}}<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label>
+ <label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br>
<input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
- <label for="ed-intro">Intro text <span class="muted">(Markdown, shown above the posts; optional)</span></label>
+ <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label>
{{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}}
- <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label>
+ <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label>
<p class="actionrow">
- <button type="submit">Save</button>
- {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">View page &#8599;</a>{{end}}
- <a href="/b/{{.Blog.Subdomain}}/pages">Back to pages</a>
+ <button type="submit">{{t "Save"}}</button>
+ {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">{{t "View page"}} &#8599;</a>{{end}}
+ <a href="/b/{{.Blog.Subdomain}}/pages">{{t "Back to pages"}}</a>
</p>
</form>
</div>
diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html
index eeb09c5..cb40eb4 100644
--- a/internal/web/templates/dashboard/pages.html
+++ b/internal/web/templates/dashboard/pages.html
@@ -1,25 +1,25 @@
-{{define "title"}}Pages · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Pages"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="pagehead">
- <h1>Pages</h1>
- <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></div>
- <p class="lead muted">Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab.</p>
+ <h1>{{t "Pages"}}</h1>
+ <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a></div>
+ <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}}</p>
</div>
<div class="card">
<table>
- <tr><th>Order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr>
+ <tr><th>{{t "Order"}}</th><th>{{t "Title"}}</th><th>{{t "Address"}}</th><th>{{t "Posts"}}</th><th></th></tr>
{{range .Data.pages}}<tr>
<td class="nowrap">
- <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
- <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
+ <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
</td>
<td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a>
- {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag off">not in menu</span>{{end}}</td>
+ {{if .IsHome}}<span class="tag">{{t "home"}}</span>{{end}}{{if not .ShowInNav}}<span class="tag off">{{t "not in menu"}}</span>{{end}}</td>
<td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} &#8599;</a></td>
<td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td>
<td class="nowrap">
- {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">Make home</button></form>
- <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">delete</a>{{end}}
+ {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "Make home"}}</button></form>
+ <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">{{t "delete"}}</a>{{end}}
</td>
</tr>{{end}}
</table>
diff --git a/internal/web/templates/dashboard/password.html b/internal/web/templates/dashboard/password.html
index 3e28744..2150a40 100644
--- a/internal/web/templates/dashboard/password.html
+++ b/internal/web/templates/dashboard/password.html
@@ -1,13 +1,13 @@
-{{define "title"}}Change password · Blogspace{{end}}
+{{define "title"}}{{t "Change password"}} · Blogspace{{end}}
{{define "content"}}
<div class="card narrow">
- <h1>Change password</h1>
+ <h1>{{t "Change password"}}</h1>
<form method="post" action="/account/password">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Current password<br><input type="password" name="current" required autocomplete="current-password"></label>
- <label>New password<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label>
- <label>Repeat new password<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label>
- <p class="actionrow"><button type="submit">Change password</button></p>
+ <label>{{t "Current password"}}<br><input type="password" name="current" required autocomplete="current-password"></label>
+ <label>{{t "New password"}}<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label>
+ <label>{{t "Repeat new password"}}<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label>
+ <p class="actionrow"><button type="submit">{{t "Change password"}}</button></p>
</form>
</div>
{{end}}
diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html
index 8b4a593..af48215 100644
--- a/internal/web/templates/dashboard/post_form.html
+++ b/internal/web/templates/dashboard/post_form.html
@@ -1,24 +1,24 @@
-{{define "title"}}{{if .Data.post.ID}}Edit post{{else}}New post{{end}} · {{.Blog.Title}}{{end}}
+{{define "title"}}{{if .Data.post.ID}}{{t "Edit post"}}{{else}}{{t "New post"}}{{end}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="card">
- <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1>
+ <h1>{{if .Data.post.ID}}{{t "Edit post"}}{{else}}{{t "New post"}}{{end}}</h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label>
+ <label>{{t "Title"}}<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label>
<div class="row">
- <label>Page<br>
+ <label>{{t "Page"}}<br>
<select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select>
</label>
- <label>Address <span class="muted">(optional, made from the title)</span><br>
+ <label>{{t "Address"}} <span class="muted">({{t "optional, made from the title"}})</span><br>
<input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
</div>
- <label for="ed-body">Content <span class="muted">(Markdown)</span></label>
+ <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 "/images/upload") "csrf" .CSRF)}}
- <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label>
+ <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label>
<p class="actionrow">
- <button type="submit">Save</button>
- {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">View post &#8599;</a>{{end}}
- <a href="/b/{{.Blog.Subdomain}}/posts">Back to posts</a>
+ <button type="submit">{{t "Save"}}</button>
+ {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">{{t "View post"}} &#8599;</a>{{end}}
+ <a href="/b/{{.Blog.Subdomain}}/posts">{{t "Back to posts"}}</a>
</p>
</form>
</div>
diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html
index c0b7112..3a99a31 100644
--- a/internal/web/templates/dashboard/posts.html
+++ b/internal/web/templates/dashboard/posts.html
@@ -1,28 +1,28 @@
-{{define "title"}}Posts · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Posts"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="pagehead">
- <h1>Posts</h1>
- <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ New post</a></div>
+ <h1>{{t "Posts"}}</h1>
+ <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ {{t "New post"}}</a></div>
</div>
<form method="get" class="filter">
- <label>Page:
+ <label>{{t "Page:"}}
<select name="page" onchange="this.form.submit()">
- <option value="0">All pages</option>
+ <option value="0">{{t "All pages"}}</option>
{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}}
</select></label>
- <noscript><button type="submit">Filter</button></noscript>
+ <noscript><button type="submit">{{t "Filter"}}</button></noscript>
</form>
<div class="card">
{{if .Data.posts}}
<table>
- <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr>
+ <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">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}}</td>
<td>{{.PageTitle}}</td>
<td class="nowrap">{{date .CreatedAt}}</td>
- <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view &#8599;</a> &middot; <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">delete</a></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>
</tr>{{end}}
</table>
-{{else}}<p class="muted">No posts here yet.</p>{{end}}
+{{else}}<p class="muted">{{t "No posts here yet."}}</p>{{end}}
</div>
{{end}}
diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html
index 7eeaf7c..bc3ba34 100644
--- a/internal/web/templates/dashboard/section_form.html
+++ b/internal/web/templates/dashboard/section_form.html
@@ -1,35 +1,35 @@
-{{define "title"}}{{if .Data.section.ID}}Edit announcement{{else}}New announcement{{end}} · {{.Blog.Title}}{{end}}
+{{define "title"}}{{if .Data.section.ID}}{{t "Edit announcement"}}{{else}}{{t "New announcement"}}{{end}} · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$s := .Data.section}}
<div class="card">
- <h1>{{if $s.ID}}Edit announcement{{else}}New announcement{{end}}</h1>
+ <h1>{{if $s.ID}}{{t "Edit announcement"}}{{else}}{{t "New announcement"}}{{end}}</h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label>
+ <label>{{t "Title"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="{{t "Next meeting"}}"></label>
<div class="row">
- <label>Where<br><select name="column">
- <option value="main"{{if eq $s.Column "main"}} selected{{end}}>Main content (with the posts)</option>
- <option value="left"{{if eq $s.Column "left"}} selected{{end}}>Left column</option>
- <option value="right"{{if eq $s.Column "right"}} selected{{end}}>Right column</option>
+ <label>{{t "Where"}}<br><select name="column">
+ <option value="main"{{if eq $s.Column "main"}} selected{{end}}>{{t "Main content (with the posts)"}}</option>
+ <option value="left"{{if eq $s.Column "left"}} selected{{end}}>{{t "Left column"}}</option>
+ <option value="right"{{if eq $s.Column "right"}} selected{{end}}>{{t "Right column"}}</option>
</select></label>
- <label>Position<br><select name="position">
- <option value="top"{{if eq $s.Position "top"}} selected{{end}}>At the top</option>
- <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>At the bottom</option>
+ <label>{{t "Position"}}<br><select name="position">
+ <option value="top"{{if eq $s.Position "top"}} selected{{end}}>{{t "At the top"}}</option>
+ <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>{{t "At the bottom"}}</option>
</select></label>
- <label>Look<br><select name="style">
- <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option>
- <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>Warning</option>
- <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option>
+ <label>{{t "Look"}}<br><select name="style">
+ <option value="note"{{if eq $s.Style "note"}} selected{{end}}>{{t "Highlighted"}}</option>
+ <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>{{t "Warning"}}</option>
+ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>{{t "Plain"}}</option>
</select></label>
</div>
- <p class="muted small">"Top" of a side column means above its modules, "bottom" below them. If that column is not shown on your blog (see <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a>) the announcement moves to the main content instead.</p>
- <label for="ed-body">Text <span class="muted">(Markdown)</span></label>
+ <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p>
+ <label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label>
{{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}}
- <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label>
+ <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label>
<p class="actionrow">
- <button type="submit">Save</button>
- <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
- <a href="/b/{{.Blog.Subdomain}}/announcements">Back to announcements</a>
+ <button type="submit">{{t "Save"}}</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
+ <a href="/b/{{.Blog.Subdomain}}/announcements">{{t "Back to announcements"}}</a>
</p>
</form>
</div>
diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html
index 1834229..4d62f29 100644
--- a/internal/web/templates/dashboard/sections.html
+++ b/internal/web/templates/dashboard/sections.html
@@ -1,29 +1,29 @@
-{{define "title"}}Announcements · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Announcements"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$u := print "/b/" .Blog.Subdomain "/announcements"}}
<div class="pagehead">
- <h1>Announcements</h1>
- <div class="actions"><a class="btn" href="{{$u}}/new">+ New announcement</a></div>
- <p class="lead muted">Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again.</p>
+ <h1>{{t "Announcements"}}</h1>
+ <div class="actions"><a class="btn" href="{{$u}}/new">+ {{t "New announcement"}}</a></div>
+ <p class="lead muted">{{t "Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again."}}</p>
</div>
<div class="card">
{{if .Data.sections}}
<table>
- <tr><th>Order</th><th>Announcement</th><th>Where</th><th>Look</th><th></th></tr>
+ <tr><th>{{t "Order"}}</th><th>{{t "Announcement"}}</th><th>{{t "Where"}}</th><th>{{t "Look"}}</th><th></th></tr>
{{range .Data.sections}}<tr{{if not .Enabled}} class="disabled"{{end}}>
<td class="nowrap">
- <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
- <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">&#9650;</button></form>
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
</td>
- <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td>
- <td>{{if eq .Column "main"}}Main content{{else if eq .Column "left"}}Left column{{else}}Right column{{end}}, {{.Position}}</td>
- <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td>
+ <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}({{t "untitled"}}){{end}}</a>{{if not .Enabled}}<span class="tag off">{{t "hidden"}}</span>{{end}}</td>
+ <td>{{if eq .Column "main"}}{{t "Main content"}}{{else if eq .Column "left"}}{{t "Left column"}}{{else}}{{t "Right column"}}{{end}}, {{t .Position}}</td>
+ <td>{{if eq .Style "note"}}{{t "Highlighted"}}{{else if eq .Style "warning"}}{{t "Warning"}}{{else}}{{t "Plain"}}{{end}}</td>
<td class="nowrap">
- <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form>
- <a class="danger" href="{{$u}}/{{.ID}}/delete">delete</a>
+ <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}{{t "Hide"}}{{else}}{{t "Show"}}{{end}}</button></form>
+ <a class="danger" href="{{$u}}/{{.ID}}/delete">{{t "delete"}}</a>
</td>
</tr>{{end}}
</table>
-{{else}}<p class="muted">No announcements yet. <a href="{{$u}}/new">Write the first one.</a></p>{{end}}
+{{else}}<p class="muted">{{t "No announcements yet."}} <a href="{{$u}}/new">{{t "Write the first one."}}</a></p>{{end}}
</div>
{{end}}
diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html
index 53a24c4..9685360 100644
--- a/internal/web/templates/dashboard/settings.html
+++ b/internal/web/templates/dashboard/settings.html
@@ -1,13 +1,16 @@
-{{define "title"}}Settings · {{.Blog.Title}}{{end}}
+{{define "title"}}{{t "Settings"}} · {{.Blog.Title}}{{end}}
{{define "content"}}
<div class="card narrow">
- <h1>Blog settings</h1>
+ <h1>{{t "Blog settings"}}</h1>
<form method="post" action="/b/{{.Blog.Subdomain}}/settings">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Blog title<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label>
- <label>Tagline <span class="muted">(shown under the title)</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label>
- <p class="muted">Address: <code>{{.BlogURL}}</code>{{if ne .Blog.Subdomain "www"}} — only the administrator can change this{{end}}.</p>
- <p class="actionrow"><button type="submit">Save</button></p>
+ <label>{{t "Blog title"}}<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label>
+ <label>{{t "Tagline"}} <span class="muted">({{t "shown under the title"}})</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label>
+ <label>{{t "Language"}} <span class="muted">({{t "of the dashboard, dates and the blog's fixed text"}})</span><br><select name="language">
+ {{range .Data.languages}}<option value="{{.Code}}"{{if eq .Code $.Blog.Language}} selected{{end}}>{{.Name}}</option>{{end}}
+ </select></label>
+ <p class="muted">{{t "Address:"}} <code>{{.BlogURL}}</code>{{if ne .Blog.Subdomain "www"}} — {{t "only the administrator can change this"}}{{end}}.</p>
+ <p class="actionrow"><button type="submit">{{t "Save"}}</button></p>
</form>
</div>
{{end}}
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index deaaa75..f73f729 100644
--- a/internal/web/templates/layouts/blog.html
+++ b/internal/web/templates/layouts/blog.html
@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html>
-<html lang="en">
+<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -65,14 +65,14 @@
</div>
{{- else if eq $m.Kind "archive"}}<div class="mod mod-archive">{{template "modtitle" $m}}
{{range $i, $y := $r.Data.archive}}<details{{if eq $i 0}} open{{end}}><summary>{{$y.Year}} <span class="muted">({{$y.Count}})</span></summary>
- {{range $y.Months}}<details><summary>{{.Name}} <span class="muted">({{len .Posts}})</span></summary>
+ {{range $y.Months}}<details><summary>{{month .Month}} <span class="muted">({{len .Posts}})</span></summary>
<ul>{{range .Posts}}<li><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></li>{{end}}</ul>
</details>{{end}}
- </details>{{else}}<p class="muted">No posts yet.</p>{{end}}
+ </details>{{else}}<p class="muted">{{t "No posts yet."}}</p>{{end}}
</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">RSS feed</a></p>
+{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">{{t "RSS feed"}}</a></p>
{{- else if eq $m.Kind "text"}}{{if $m.Body}}<div class="mod mod-text">{{range split $m.Body "\n"}}{{if .}}<p>{{.}}</p>{{end}}{{end}}</div>{{end}}
{{- else if eq $m.Kind "sitemap"}}<div class="mod mod-sitemap">{{template "modtitle" $m}}
<ul>{{range $r.Data.pages}}<li><a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}">{{.Title}}</a></li>{{end}}</ul>
diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html
index e724cc6..6e75863 100644
--- a/internal/web/templates/layouts/dashboard.html
+++ b/internal/web/templates/layouts/dashboard.html
@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html>
-<html lang="en">
+<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 0966f7b..65b0ed5 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -2,11 +2,11 @@
<div class="topbar-inner">
<a class="brand" href="{{if .User}}/dashboard{{else}}/{{end}}">Blogspace</a>
{{if .User}}
- <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}}</span>
+ <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} ({{t "superadmin"}}){{end}}</span>
<span class="links">
- {{if .User.IsSuperadmin}}<a href="/admin/"{{if hasPrefix .Path "/admin/"}} class="active"{{end}}>Admin</a>{{end}}
- <a href="/account/password"{{if eq .Path "/account/password"}} class="active"{{end}}>Password</a>
- <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">Log out</button></form>
+ {{if .User.IsSuperadmin}}<a href="/admin/"{{if hasPrefix .Path "/admin/"}} class="active"{{end}}>{{t "Admin"}}</a>{{end}}
+ <a href="/account/password"{{if eq .Path "/account/password"}} class="active"{{end}}>{{t "Password"}}</a>
+ <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">{{t "Log out"}}</button></form>
</span>
{{end}}
</div>
@@ -17,16 +17,16 @@
<div class="topbar-inner">
<strong class="blogname">{{.Blog.Title}}</strong>
<span class="tabs">
- <a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>Overview</a>
- <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a>
- <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a>
- <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a>
- <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>Layout</a>
- <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a>
- <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a>
- <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a>
+ <a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>{{t "Overview"}}</a>
+ <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a>
+ <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a>
+ <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>{{t "Announcements"}}</a>
+ <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>{{t "Layout"}}</a>
+ <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a>
+ <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>{{t "Images"}}</a>
+ <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a>
</span>
- <a class="view" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+ <a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
</div>
</div>
{{end}}{{end}}
diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html
index 8e0acaf..eb2e315 100644
--- a/internal/web/templates/partials/editor.html
+++ b/internal/web/templates/partials/editor.html
@@ -1,9 +1,9 @@
{{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea>
<div class="editor-tools">
- <label class="upload">Insert image <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label>
+ <label class="upload">{{t "Insert image"}} <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label>
<span class="upload-status" id="ed-{{.name}}-status"></span>
- <span class="muted small">…or paste / drop an image into the text.</span>
- <noscript><span class="muted small">The image is added at the end of the text when you save.</span></noscript>
+ <span class="muted small">{{t "…or paste / drop an image into the text."}}</span>
+ <noscript><span class="muted small">{{t "The image is added at the end of the text when you save."}}</span></noscript>
</div>
{{template "mdhelp"}}
<script>
@@ -18,14 +18,14 @@
ta.focus();
}
function upload(file) {
- st.textContent = "Uploading " + file.name + "…";
+ st.textContent = {{t "Uploading"}} + " " + file.name + "…";
var fd = new FormData();
fd.append("_csrf", {{.csrf}});
fd.append("file", file);
fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
- .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || "upload failed"); return j; }); })
- .then(function (j) { insert(j.markdown); st.textContent = "Inserted " + j.filename + "."; inp.value = ""; })
- .catch(function (err) { st.textContent = "Could not upload: " + err.message; });
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); })
+ .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
+ .catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; });
}
inp.addEventListener("change", function () { if (inp.files && inp.files[0]) upload(inp.files[0]); });
ta.addEventListener("paste", function (ev) {
diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html
index 4431d02..911b355 100644
--- a/internal/web/templates/partials/imagepick.html
+++ b/internal/web/templates/partials/imagepick.html
@@ -2,8 +2,8 @@
<label>{{.label}}</label>
{{if .hint}}<p class="muted small">{{.hint}}</p>{{end}}
<div class="picks">
- <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">&times;</span><span class="name">None</span></label>
+ <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">&times;</span><span class="name">{{t "None"}}</span></label>
{{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/b/{{$.sub}}/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}}
</div>
- <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
+ <label>{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
</div>{{end}}
diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html
index 3584025..748452e 100644
--- a/internal/web/templates/partials/mdhelp.html
+++ b/internal/web/templates/partials/mdhelp.html
@@ -1,9 +1,9 @@
{{define "mdhelp"}}<details class="help">
- <summary>Formatting cheat-sheet</summary>
+ <summary>{{t "Formatting cheat-sheet"}}</summary>
<table class="cheat">
- <tr><td><code># Heading</code>, <code>## Smaller heading</code></td><td><code>**bold**</code>, <code>*italic*</code></td></tr>
- <tr><td><code>[link text](https://example.org)</code></td><td><code>![description](/media/…)</code> — <em>Insert image</em> writes this for you</td></tr>
- <tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>&gt; quote</code>, <code>`code`</code>, <code>---</code> for a line</td></tr>
+ <tr><td><code># {{t "Heading"}}</code>, <code>## {{t "Smaller heading"}}</code></td><td><code>**{{t "bold"}}**</code>, <code>*{{t "italic"}}*</code></td></tr>
+ <tr><td><code>[{{t "link text"}}](https://example.org)</code></td><td><code>![{{t "description"}}](/media/…)</code> — <em>{{t "Insert image"}}</em> {{t "writes this for you"}}</td></tr>
+ <tr><td><code>- {{t "list item"}}</code> / <code>1. {{t "numbered"}}</code></td><td><code>&gt; {{t "quote"}}</code>, <code>`{{t "code"}}`</code>, <code>---</code> {{t "for a line"}}</td></tr>
</table>
- <p class="muted small">Blank line = new paragraph. Press Enter once for a line break.</p>
+ <p class="muted small">{{t "Blank line = new paragraph. Press Enter once for a line break."}}</p>
</details>{{end}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index 1ecd832..2853667 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"text/template"
- "time"
"github.com/google/uuid"
)
@@ -43,7 +42,7 @@ type Theme struct {
NavHover string `json:"nav_hover"` // "" = same as NavText
ShowDates bool `json:"show_dates"`
- DateFormat string `json:"date_format"` // long | short | iso
+ DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate)
FooterBg string `json:"footer_bg"`
FooterColor string `json:"footer_color"`
@@ -283,17 +282,6 @@ func (t Theme) JSON() json.RawMessage {
return b
}
-// FormatDate renders post dates in the blogger's chosen style.
-func (t Theme) FormatDate(at time.Time) string {
- switch t.DateFormat {
- case "short":
- return at.Format("2 Jan 2006")
- case "iso":
- return at.Format("2006-01-02")
- }
- return at.Format("2 January 2006")
-}
-
// ---- presets ----------------------------------------------------------------
// Preset is a named colour scheme; applying one touches only colours.
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 5078c44..ae26029 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -1,15 +1,20 @@
package web
import (
+ "io/fs"
"net/http"
"net/http/httptest"
"net/url"
+ "os"
+ "path/filepath"
+ "regexp"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/i18n"
"github.com/gramanas/blogspace/internal/store"
)
@@ -188,16 +193,16 @@ func TestModuleKinds(t *testing.T) {
if !moduleHasSettings("html") || moduleHasSettings("rss") {
t.Error("moduleHasSettings")
}
- if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" {
+ if got := moduleSummary("en", store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" {
t.Errorf("recent summary: %q", got)
}
- if got := moduleSummary(store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" {
+ if got := moduleSummary("en", store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" {
t.Errorf("html summary: %q", got)
}
- if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") {
+ if got := moduleSummary("en", store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") {
t.Errorf("long html summary: %q", got)
}
- if got := moduleSummary(store.Module{Kind: "menu"}); got != "" {
+ if got := moduleSummary("en", store.Module{Kind: "menu"}); got != "" {
t.Errorf("menu summary: %q", got)
}
}
@@ -222,7 +227,7 @@ func TestGroupArchive(t *testing.T) {
if len(years) != 2 || years[0].Year != 2026 || years[0].Count != 3 || years[1].Year != 2025 || years[1].Count != 1 {
t.Fatalf("years: %+v", years)
}
- if len(years[0].Months) != 2 || years[0].Months[0].Name != "September" || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Name != "February" {
+ if len(years[0].Months) != 2 || years[0].Months[0].Month != time.September || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Month != time.February {
t.Errorf("months: %+v", years[0].Months)
}
if years[0].Months[0].Posts[0].Title != "d" || years[1].Months[0].Posts[0].Title != "a" {
@@ -294,26 +299,96 @@ func TestPresets(t *testing.T) {
}
}
-func TestFormatDate(t *testing.T) {
- at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC)
- for format, want := range map[string]string{"long": "3 September 2026", "short": "3 Sep 2026", "iso": "2026-09-03"} {
- th := Theme{DateFormat: format}
- if got := th.FormatDate(at); got != want {
- t.Errorf("%s: got %q, want %q", format, got, want)
- }
- }
-}
-
func TestAllTemplatesParse(t *testing.T) {
- tpl := newTemplates(false, funcs)
+ tpl := newTemplates(false)
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/images.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"} {
- if _, err := tpl.get(name); err != nil {
- t.Errorf("%s: %v", name, err)
+ for _, l := range i18n.Languages() {
+ if _, err := tpl.get(l.Code, name); err != nil {
+ t.Errorf("%s (%s): %v", name, l.Code, err)
+ }
+ }
+ }
+}
+
+// Every string the templates and handlers translate must be in the Greek
+// catalog, and the catalog must not keep entries nothing uses any more (a
+// reworded English key would otherwise silently fall back to English).
+func TestGreekCatalogComplete(t *testing.T) {
+ used := map[string]bool{}
+ add := func(re *regexp.Regexp, src string) {
+ for _, m := range re.FindAllStringSubmatch(src, -1) {
+ used[m[1]] = true
+ }
+ }
+ tplKey := regexp.MustCompile(`\b(?:t|tf) "((?:[^"\\]|\\.)*)"`)
+ goKey := regexp.MustCompile(`\b(?:s\.trf?\(r|i18n\.Tf?\([A-Za-z]+), "((?:[^"\\]|\\.)*)"`)
+ if err := fs.WalkDir(assets, "templates", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ b, err := fs.ReadFile(assets, path)
+ add(tplKey, string(b))
+ return err
+ }); err != nil {
+ t.Fatal(err)
+ }
+ goFiles, _ := filepath.Glob("*.go")
+ for _, f := range goFiles {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ b, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ add(goKey, string(b))
+ }
+ // Keys that reach t/tr through a variable rather than a literal.
+ for _, m := range []map[string]string{moduleNames, areaNames} {
+ for _, v := range m {
+ used[v] = true
}
}
+ for _, p := range Presets() {
+ used[p.Name] = true
+ }
+ for _, k := range []string{"top", "bottom", store.RoleSuperadmin, store.RoleBlogger} {
+ used[k] = true
+ }
+ have := map[string]bool{}
+ for _, k := range i18n.Keys("el") {
+ have[k] = true
+ if !used[k] {
+ t.Errorf("el catalog: %q is not used anywhere", k)
+ }
+ }
+ for k := range used {
+ if !have[k] {
+ t.Errorf("el catalog: missing %q", k)
+ }
+ }
+ if len(used) < 100 {
+ t.Errorf("only %d keys found; the scan is broken", len(used))
+ }
+}
+
+// Anonymous pages follow the browser's language; unknown ones fall back to English.
+func TestLangFromAcceptLanguage(t *testing.T) {
+ s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil)
+ req := httptest.NewRequest("GET", "/webadmin", nil)
+ if got := s.lang(req); got != "en" {
+ t.Errorf("no header: %q", got)
+ }
+ req.Header.Set("Accept-Language", "el-GR,el;q=0.9,en;q=0.8")
+ if got := s.lang(req); got != "el" {
+ t.Errorf("greek header: %q", got)
+ }
+ if got := s.lang(withLang(req, "en")); got != "en" {
+ t.Errorf("context wins: %q", got)
+ }
}
// /webadmin on a blog's own host bounces to the root login page and back to that blog's dashboard.