feat(i18n): implement formality-based message selection

Add support for formal/informal translation variants using suffix convention:
- key._formal for formal address (Sie, vous, usted)
- key._informal for informal address (du, tu, tú)

Formality priority: Subject.Formal() > Service.SetFormality() > neutral

Example locale file:
  "greeting": "Hello",
  "greeting._formal": "Good morning, sir",
  "greeting._informal": "Hey there"

Usage:
  svc.SetFormality(FormalityFormal)
  svc.T("greeting")  // "Good morning, sir"

  svc.T("greeting", S("user", name).Informal())  // "Hey there"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 17:13:12 +00:00
parent b4ed8a09f1
commit b04b2a70dc
2 changed files with 93 additions and 0 deletions

View file

@ -343,3 +343,56 @@ func TestRawBypassesI18nNamespace(t *testing.T) {
result = svc.T("i18n.label.status")
assert.Equal(t, "Status:", result)
}
func TestFormalityMessageSelection(t *testing.T) {
svc, err := New()
require.NoError(t, err)
// Add test messages with formality variants
svc.AddMessages("en-GB", map[string]string{
"greeting": "Hello",
"greeting._formal": "Good morning, sir",
"greeting._informal": "Hey there",
"farewell": "Goodbye",
"farewell._formal": "Farewell",
})
t.Run("neutral formality uses base key", func(t *testing.T) {
svc.SetFormality(FormalityNeutral)
assert.Equal(t, "Hello", svc.T("greeting"))
assert.Equal(t, "Goodbye", svc.T("farewell"))
})
t.Run("formal uses ._formal variant", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
assert.Equal(t, "Good morning, sir", svc.T("greeting"))
assert.Equal(t, "Farewell", svc.T("farewell"))
})
t.Run("informal uses ._informal variant", func(t *testing.T) {
svc.SetFormality(FormalityInformal)
assert.Equal(t, "Hey there", svc.T("greeting"))
// No informal variant for farewell, falls back to base
assert.Equal(t, "Goodbye", svc.T("farewell"))
})
t.Run("subject formality overrides service formality", func(t *testing.T) {
svc.SetFormality(FormalityNeutral)
// Subject with formal overrides neutral service
result := svc.T("greeting", S("user", "test").Formal())
assert.Equal(t, "Good morning, sir", result)
// Subject with informal overrides neutral service
result = svc.T("greeting", S("user", "test").Informal())
assert.Equal(t, "Hey there", result)
})
t.Run("subject formality overrides service formal", func(t *testing.T) {
svc.SetFormality(FormalityFormal)
// Subject with informal overrides formal service
result := svc.T("greeting", S("user", "test").Informal())
assert.Equal(t, "Hey there", result)
})
}

View file

@ -414,6 +414,24 @@ func (s *Service) resolveWithFallback(messageID string, data any) string {
// Returns empty string if not found.
// Must be called with s.mu.RLock held.
func (s *Service) tryResolve(lang, key string, data any) string {
// Determine effective formality
formality := s.getEffectiveFormality(data)
// Try formality-specific key first (key._formal or key._informal)
if formality != FormalityNeutral {
formalityKey := key + "._" + formality.String()
if text := s.resolveMessage(lang, formalityKey, data); text != "" {
return text
}
}
// Fall back to base key
return s.resolveMessage(lang, key, data)
}
// resolveMessage resolves a single message key without formality fallback.
// Must be called with s.mu.RLock held.
func (s *Service) resolveMessage(lang, key string, data any) string {
msg, ok := s.getMessage(lang, key)
if !ok {
return ""
@ -438,6 +456,28 @@ func (s *Service) tryResolve(lang, key string, data any) string {
return text
}
// getEffectiveFormality returns the formality to use for translation.
// Priority: Subject.formality > Service.formality > FormalityNeutral
// Must be called with s.mu.RLock held.
func (s *Service) getEffectiveFormality(data any) Formality {
// Check if data is a Subject with explicit formality
if subj, ok := data.(*Subject); ok && subj != nil {
if subj.formality != FormalityNeutral {
return subj.formality
}
}
// Check if data is a map with Formality field
if m, ok := data.(map[string]any); ok {
if f, ok := m["Formality"].(Formality); ok && f != FormalityNeutral {
return f
}
}
// Fall back to service default
return s.formality
}
// handleMissingKey handles a missing translation key based on the current mode.
// Must be called with s.mu.RLock held.
func (s *Service) handleMissingKey(key string, args []any) string {