diff options
| author | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
| commit | e14a4250efb828262347f5a4bbc4067cd71d885f (patch) | |
| tree | 3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web/server.go | |
| parent | 282d2ab3e0fb3a74f32160a231225773001acd14 (diff) | |
| download | blogspace-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/server.go')
| -rw-r--r-- | internal/web/server.go | 72 |
1 files changed, 60 insertions, 12 deletions
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) } |
