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:
parent
b4ed8a09f1
commit
b04b2a70dc
2 changed files with 93 additions and 0 deletions
|
|
@ -343,3 +343,56 @@ func TestRawBypassesI18nNamespace(t *testing.T) {
|
||||||
result = svc.T("i18n.label.status")
|
result = svc.T("i18n.label.status")
|
||||||
assert.Equal(t, "Status:", result)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,24 @@ func (s *Service) resolveWithFallback(messageID string, data any) string {
|
||||||
// Returns empty string if not found.
|
// Returns empty string if not found.
|
||||||
// Must be called with s.mu.RLock held.
|
// Must be called with s.mu.RLock held.
|
||||||
func (s *Service) tryResolve(lang, key string, data any) string {
|
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)
|
msg, ok := s.getMessage(lang, key)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -438,6 +456,28 @@ func (s *Service) tryResolve(lang, key string, data any) string {
|
||||||
return text
|
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.
|
// handleMissingKey handles a missing translation key based on the current mode.
|
||||||
// Must be called with s.mu.RLock held.
|
// Must be called with s.mu.RLock held.
|
||||||
func (s *Service) handleMissingKey(key string, args []any) string {
|
func (s *Service) handleMissingKey(key string, args []any) string {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue