Skip to content

Commit 9a19816

Browse files
committed
Add i18n package
1 parent 1b5b7b1 commit 9a19816

16 files changed

Lines changed: 930 additions & 11 deletions

File tree

docs/features.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
| Package | Description |
1414
|---------|-------------|
1515
| `web` | Chi router helpers, response utilities, flash messages |
16-
| `middleware` | Request ID, role-based access, middleware stack, IP-based rate limiting |
17-
| `render` | Template FuncMap utilities |
16+
| `middleware` | Request ID, role-based access, rate limiting, locale detection, static cache |
17+
| `render` | Template FuncMap utilities, i18n integration |
1818
| `render/ui` | UI components (Badge, Chip, Price, Stat), dynamic currency registration |
1919
| `modal` | Modal dialog configuration |
2020
| `pagination` | Generic pagination (`Result[T]`) |
21+
| `i18n` | Internationalization with YAML translation files |
22+
| `settings` | Runtime key-value configuration with schema validation |
2123

2224
## Auth & Security
2325

i18n/i18n.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package i18n
2+
3+
import (
4+
"embed"
5+
"fmt"
6+
"strings"
7+
"sync"
8+
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
const DefaultLocale = "en"
13+
14+
type Translator struct {
15+
translations map[string]map[string]any
16+
mu sync.RWMutex
17+
defaultLoc string
18+
available []string
19+
}
20+
21+
func New() *Translator {
22+
return &Translator{
23+
translations: make(map[string]map[string]any),
24+
defaultLoc: DefaultLocale,
25+
available: []string{},
26+
}
27+
}
28+
29+
func (t *Translator) LoadFromFS(fs embed.FS, basePath string) error {
30+
entries, err := fs.ReadDir(basePath)
31+
if err != nil {
32+
return fmt.Errorf("read i18n directory: %w", err)
33+
}
34+
35+
for _, entry := range entries {
36+
if entry.IsDir() {
37+
continue
38+
}
39+
40+
name := entry.Name()
41+
if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") {
42+
continue
43+
}
44+
45+
locale := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
46+
filePath := basePath + "/" + name
47+
48+
data, err := fs.ReadFile(filePath)
49+
if err != nil {
50+
return fmt.Errorf("read %s: %w", filePath, err)
51+
}
52+
53+
if err := t.loadLocale(locale, data); err != nil {
54+
return fmt.Errorf("parse %s: %w", filePath, err)
55+
}
56+
57+
t.available = append(t.available, locale)
58+
}
59+
60+
return nil
61+
}
62+
63+
func (t *Translator) loadLocale(locale string, data []byte) error {
64+
var raw map[string]any
65+
if err := yaml.Unmarshal(data, &raw); err != nil {
66+
return err
67+
}
68+
69+
t.mu.Lock()
70+
defer t.mu.Unlock()
71+
72+
t.translations[locale] = make(map[string]any)
73+
flatten("", raw, t.translations[locale])
74+
75+
return nil
76+
}
77+
78+
func flatten(prefix string, src map[string]any, dest map[string]any) {
79+
for k, v := range src {
80+
key := k
81+
if prefix != "" {
82+
key = prefix + "." + k
83+
}
84+
85+
switch val := v.(type) {
86+
case map[string]any:
87+
flatten(key, val, dest)
88+
default:
89+
dest[key] = val
90+
}
91+
}
92+
}
93+
94+
func (t *Translator) Get(locale, key string) string {
95+
t.mu.RLock()
96+
defer t.mu.RUnlock()
97+
98+
if trans, ok := t.translations[locale]; ok {
99+
if val, ok := trans[key]; ok {
100+
if s, ok := val.(string); ok {
101+
return s
102+
}
103+
return fmt.Sprintf("%v", val)
104+
}
105+
}
106+
107+
if locale != t.defaultLoc {
108+
if trans, ok := t.translations[t.defaultLoc]; ok {
109+
if val, ok := trans[key]; ok {
110+
if s, ok := val.(string); ok {
111+
return s
112+
}
113+
return fmt.Sprintf("%v", val)
114+
}
115+
}
116+
}
117+
118+
return key
119+
}
120+
121+
func (t *Translator) SetDefaultLocale(locale string) {
122+
t.mu.Lock()
123+
defer t.mu.Unlock()
124+
t.defaultLoc = locale
125+
}
126+
127+
func (t *Translator) DefaultLocaleValue() string {
128+
t.mu.RLock()
129+
defer t.mu.RUnlock()
130+
return t.defaultLoc
131+
}
132+
133+
func (t *Translator) AvailableLocales() []string {
134+
t.mu.RLock()
135+
defer t.mu.RUnlock()
136+
result := make([]string, len(t.available))
137+
copy(result, t.available)
138+
return result
139+
}
140+
141+
func (t *Translator) HasLocale(locale string) bool {
142+
t.mu.RLock()
143+
defer t.mu.RUnlock()
144+
_, ok := t.translations[locale]
145+
return ok
146+
}
147+
148+
func (t *Translator) TranslateFunc(locale string) func(string) string {
149+
return func(key string) string {
150+
return t.Get(locale, key)
151+
}
152+
}

i18n/i18n_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package i18n
2+
3+
import (
4+
"embed"
5+
"testing"
6+
)
7+
8+
//go:embed testdata
9+
var testFS embed.FS
10+
11+
func TestTranslatorLoadFromFS(t *testing.T) {
12+
tr := New()
13+
err := tr.LoadFromFS(testFS, "testdata")
14+
if err != nil {
15+
t.Fatalf("LoadFromFS failed: %v", err)
16+
}
17+
18+
if !tr.HasLocale("en") {
19+
t.Error("expected locale 'en' to be loaded")
20+
}
21+
if !tr.HasLocale("es") {
22+
t.Error("expected locale 'es' to be loaded")
23+
}
24+
}
25+
26+
func TestTranslatorGet(t *testing.T) {
27+
tr := New()
28+
if err := tr.LoadFromFS(testFS, "testdata"); err != nil {
29+
t.Fatalf("LoadFromFS failed: %v", err)
30+
}
31+
32+
tests := []struct {
33+
name string
34+
locale string
35+
key string
36+
expected string
37+
}{
38+
{
39+
name: "simple key in english",
40+
locale: "en",
41+
key: "common.search",
42+
expected: "Search",
43+
},
44+
{
45+
name: "simple key in spanish",
46+
locale: "es",
47+
key: "common.search",
48+
expected: "Buscar",
49+
},
50+
{
51+
name: "nested key",
52+
locale: "en",
53+
key: "user.name",
54+
expected: "Name",
55+
},
56+
{
57+
name: "fallback to english when key missing in locale",
58+
locale: "es",
59+
key: "user.only_english",
60+
expected: "Only in English",
61+
},
62+
{
63+
name: "missing key returns key itself",
64+
locale: "en",
65+
key: "missing.key.path",
66+
expected: "missing.key.path",
67+
},
68+
{
69+
name: "unknown locale falls back to english",
70+
locale: "fr",
71+
key: "common.search",
72+
expected: "Search",
73+
},
74+
}
75+
76+
for _, tt := range tests {
77+
t.Run(tt.name, func(t *testing.T) {
78+
result := tr.Get(tt.locale, tt.key)
79+
if result != tt.expected {
80+
t.Errorf("Get(%q, %q) = %q, want %q", tt.locale, tt.key, result, tt.expected)
81+
}
82+
})
83+
}
84+
}
85+
86+
func TestTranslatorAvailableLocales(t *testing.T) {
87+
tr := New()
88+
if err := tr.LoadFromFS(testFS, "testdata"); err != nil {
89+
t.Fatalf("LoadFromFS failed: %v", err)
90+
}
91+
92+
locales := tr.AvailableLocales()
93+
if len(locales) < 2 {
94+
t.Errorf("expected at least 2 locales, got %d", len(locales))
95+
}
96+
97+
found := make(map[string]bool)
98+
for _, loc := range locales {
99+
found[loc] = true
100+
}
101+
102+
if !found["en"] {
103+
t.Error("expected 'en' in available locales")
104+
}
105+
if !found["es"] {
106+
t.Error("expected 'es' in available locales")
107+
}
108+
}
109+
110+
func TestTranslatorSetDefaultLocale(t *testing.T) {
111+
tr := New()
112+
if err := tr.LoadFromFS(testFS, "testdata"); err != nil {
113+
t.Fatalf("LoadFromFS failed: %v", err)
114+
}
115+
116+
tr.SetDefaultLocale("es")
117+
if tr.DefaultLocaleValue() != "es" {
118+
t.Errorf("expected default locale 'es', got %q", tr.DefaultLocaleValue())
119+
}
120+
121+
result := tr.Get("fr", "common.search")
122+
if result != "Buscar" {
123+
t.Errorf("expected Spanish fallback 'Buscar', got %q", result)
124+
}
125+
}
126+
127+
func TestTranslatorTranslateFunc(t *testing.T) {
128+
tr := New()
129+
if err := tr.LoadFromFS(testFS, "testdata"); err != nil {
130+
t.Fatalf("LoadFromFS failed: %v", err)
131+
}
132+
133+
fn := tr.TranslateFunc("es")
134+
result := fn("common.search")
135+
if result != "Buscar" {
136+
t.Errorf("TranslateFunc(es)(common.search) = %q, want 'Buscar'", result)
137+
}
138+
}

i18n/readme.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# i18n
2+
3+
Internationalization support with YAML translation files.
4+
5+
## Usage
6+
7+
```go
8+
//go:embed locales
9+
var localesFS embed.FS
10+
11+
// Load translations
12+
translator := i18n.New()
13+
translator.LoadFromFS(localesFS, "locales")
14+
15+
// Get translation
16+
text := translator.Get("es", "common.search") // "Buscar"
17+
18+
// Fallback to default locale if key missing
19+
text := translator.Get("es", "missing.key") // returns key itself
20+
21+
// Template function
22+
fn := translator.TranslateFunc("es")
23+
fn("common.search") // "Buscar"
24+
25+
// Check available locales
26+
locales := translator.AvailableLocales() // ["en", "es"]
27+
translator.HasLocale("de") // false
28+
```
29+
30+
## Translation Files
31+
32+
YAML files named by locale (e.g., `en.yml`, `es.yml`):
33+
34+
```yaml
35+
common:
36+
search: "Search"
37+
back: "Back"
38+
39+
user:
40+
name: "Name"
41+
email: "Email"
42+
```
43+
44+
Access via dot notation: `common.search`, `user.name`.

i18n/testdata/en.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
common:
2+
search: "Search"
3+
back: "Back"
4+
submit: "Submit"
5+
6+
user:
7+
name: "Name"
8+
email: "Email"
9+
role: "Role"
10+
only_english: "Only in English"
11+
12+
actions:
13+
save: "Save"
14+
delete: "Delete"

i18n/testdata/es.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
common:
2+
search: "Buscar"
3+
back: "Volver"
4+
submit: "Enviar"
5+
6+
user:
7+
name: "Nombre"
8+
email: "Correo"
9+
role: "Rol"
10+
11+
actions:
12+
save: "Guardar"
13+
delete: "Eliminar"

middleware/cache.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package middleware
2+
3+
import "net/http"
4+
5+
// StaticCache adds aggressive cache headers for static assets.
6+
// Safe for content-addressed files where URLs change when content changes.
7+
func StaticCache(next http.Handler) http.Handler {
8+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9+
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
10+
next.ServeHTTP(w, r)
11+
})
12+
}

0 commit comments

Comments
 (0)