feat(i18n): add locale message fallback

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:17:26 +00:00
parent 0ed72c4952
commit a055781d5d
2 changed files with 99 additions and 3 deletions

65
i18n.go
View file

@ -3,6 +3,8 @@
package api
import (
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
)
@ -13,6 +15,12 @@ const i18nContextKey = "i18n.locale"
// i18nMessagesKey is the Gin context key for the message lookup map.
const i18nMessagesKey = "i18n.messages"
// i18nCatalogKey is the Gin context key for the full locale->message catalog.
const i18nCatalogKey = "i18n.catalog"
// i18nDefaultLocaleKey stores the configured default locale for fallback lookups.
const i18nDefaultLocaleKey = "i18n.default_locale"
// I18nConfig configures the internationalisation middleware.
type I18nConfig struct {
// DefaultLocale is the fallback locale when the Accept-Language header
@ -79,14 +87,13 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
}
c.Set(i18nContextKey, locale)
c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale)
// Attach the message map for this locale if messages are configured.
if cfg.Messages != nil {
c.Set(i18nCatalogKey, cfg.Messages)
if msgs, ok := cfg.Messages[locale]; ok {
c.Set(i18nMessagesKey, msgs)
} else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok {
// Fall back to default locale messages.
c.Set(i18nMessagesKey, msgs)
}
}
@ -116,5 +123,57 @@ func GetMessage(c *gin.Context, key string) (string, bool) {
}
}
}
catalog, _ := c.Get(i18nCatalogKey)
msgsByLocale, _ := catalog.(map[string]map[string]string)
if len(msgsByLocale) == 0 {
return "", false
}
locales := localeFallbacks(GetLocale(c))
if defaultLocale, ok := c.Get(i18nDefaultLocaleKey); ok {
if fallback, ok := defaultLocale.(string); ok && fallback != "" {
locales = append(locales, localeFallbacks(fallback)...)
}
}
seen := make(map[string]struct{}, len(locales))
for _, locale := range locales {
if locale == "" {
continue
}
if _, ok := seen[locale]; ok {
continue
}
seen[locale] = struct{}{}
if msgs, ok := msgsByLocale[locale]; ok {
if msg, ok := msgs[key]; ok {
return msg, true
}
}
}
return "", false
}
// localeFallbacks returns the locale and its parent tags in order from
// most specific to least specific. For example, "fr-CA" yields
// ["fr-CA", "fr"] and "zh-Hant-TW" yields ["zh-Hant-TW", "zh-Hant", "zh"].
func localeFallbacks(locale string) []string {
locale = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
if locale == "" {
return nil
}
parts := strings.Split(locale, "-")
if len(parts) == 0 {
return []string{locale}
}
fallbacks := make([]string, 0, len(parts))
for i := len(parts); i >= 1; i-- {
fallbacks = append(fallbacks, strings.Join(parts[:i], "-"))
}
return fallbacks
}

View file

@ -251,3 +251,40 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message)
}
}
func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr", "fr-CA"},
Messages: map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
req.Header.Set("Accept-Language", "fr-CA")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp i18nMessageResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data.Locale != "fr-CA" {
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data.Locale)
}
if resp.Data.Message != "Bonjour" {
t.Fatalf("expected fallback message=%q, got %q", "Bonjour", resp.Data.Message)
}
if !resp.Data.Found {
t.Fatal("expected found=true")
}
}