aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
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.