aboutsummaryrefslogtreecommitdiffstats
path: root/internal/i18n/i18n.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/i18n/i18n.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/i18n/i18n.go')
-rw-r--r--internal/i18n/i18n.go110
1 files changed, 110 insertions, 0 deletions
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 0000000..4f31667
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,110 @@
+// Package i18n holds the languages a blog can be in and the translations of
+// the fixed text (dashboard, messages, dates). Keys are the English strings
+// themselves, so an untranslated key renders as English rather than blank.
+package i18n
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Default is what new blogs get and what anything unknown falls back to.
+const Default = "en"
+
+type Language struct {
+ Code, Name string // Name is in the language itself, for the settings menu
+}
+
+var languages = []Language{
+ {Code: "en", Name: "English"},
+ {Code: "el", Name: "Ελληνικά"},
+}
+
+// catalogs maps a language to its translations; English is the identity.
+var catalogs = map[string]map[string]string{
+ "el": el,
+}
+
+func Languages() []Language { return languages }
+
+func Valid(code string) bool {
+ for _, l := range languages {
+ if l.Code == code {
+ return true
+ }
+ }
+ return false
+}
+
+// T translates key into lang, returning the key itself when there is no translation.
+func T(lang, key string) string {
+ if s, ok := catalogs[lang][key]; ok {
+ return s
+ }
+ return key
+}
+
+// Tf is T followed by Sprintf; translations may reorder arguments with %[n]s.
+func Tf(lang, key string, args ...any) string {
+ return fmt.Sprintf(T(lang, key), args...)
+}
+
+// Keys lists what a catalog translates (for the completeness test).
+func Keys(lang string) []string {
+ out := make([]string, 0, len(catalogs[lang]))
+ for k := range catalogs[lang] {
+ out = append(out, k)
+ }
+ return out
+}
+
+// Match picks a supported language from an Accept-Language header: the first
+// tag (in header order) whose primary subtag we have, else Default.
+func Match(acceptLanguage string) string {
+ for _, part := range strings.Split(acceptLanguage, ",") {
+ tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
+ primary, _, _ := strings.Cut(strings.ToLower(tag), "-")
+ if Valid(primary) {
+ return primary
+ }
+ }
+ return Default
+}
+
+// months are the names a language needs: standalone (archive headings), in a
+// date ("14 September" — Greek uses the genitive there) and abbreviated.
+type months struct {
+ name, inDate, short [12]string
+}
+
+var monthTables = map[string]months{
+ "el": elMonths,
+}
+
+// Month is the standalone month name (archive headings).
+func Month(lang string, m time.Month) string {
+ if t, ok := monthTables[lang]; ok {
+ return t.name[m-1]
+ }
+ return m.String()
+}
+
+// FormatDate renders a post date in the blogger's chosen style (long | short | iso).
+func FormatDate(lang, style string, at time.Time) string {
+ if style == "iso" {
+ return at.Format("2006-01-02")
+ }
+ t, ok := monthTables[lang]
+ if !ok { // English: the Go layouts
+ if style == "short" {
+ return at.Format("2 Jan 2006")
+ }
+ return at.Format("2 January 2006")
+ }
+ m := t.inDate[at.Month()-1]
+ if style == "short" {
+ m = t.short[at.Month()-1]
+ }
+ return fmt.Sprintf("%d %s %d", at.Day(), m, at.Year())
+}