aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/templates.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
committergrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
commite14a4250efb828262347f5a4bbc4067cd71d885f (patch)
tree3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web/templates.go
parent282d2ab3e0fb3a74f32160a231225773001acd14 (diff)
downloadblogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can now pick the language of their blog; it switches the whole dashboard and the blog's fixed text — post dates, archive months, the RSS link, the pager, the 404 page — while what the blogger wrote is left alone. The new internal/i18n package keys translations by the English string, so an untranslated key renders as English rather than blank, and TestGreekCatalogComplete scans the templates and handlers to fail when the Greek catalog misses a key or keeps a stale one. Templates are compiled once per language with t/tf/date/postdate/month closed over the language, so they need no data plumbing. The language lives in settings.language (blog migration 00002), not in the theme, so "Reset design" does not touch it. Public pages use the blog's language; management pages use the logged-in user's own blog's, so a superadmin editing someone else's blog keeps theirs; the login page follows Accept-Language. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates.go')
-rw-r--r--internal/web/templates.go50
1 files changed, 35 insertions, 15 deletions
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,