fix(html): clone mutable translators in context copies
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-04 01:23:40 +00:00
parent 575150d686
commit 7cbf678738
6 changed files with 68 additions and 10 deletions

View file

@ -10,6 +10,10 @@ type Translator interface {
T(key string, args ...any) string
}
type translatorCloner interface {
Clone() Translator
}
// context.go: Context carries rendering state through the node tree.
// Example: NewContext("en-GB") initialises locale-specific rendering state.
// Locale and translator selection happen at construction time.

View file

@ -28,6 +28,15 @@ func (t *localeTranslator) SetLanguage(language string) error {
return nil
}
func (t *localeTranslator) Clone() Translator {
if t == nil {
return (*localeTranslator)(nil)
}
clone := *t
return &clone
}
func TestContext_NewContextWithService_AppliesLocale(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "fr-FR")
@ -159,8 +168,8 @@ func TestContext_CloneCopiesDataWithoutSharingMap(t *testing.T) {
if clone == ctx {
t.Fatal("Clone should return a distinct context instance")
}
if clone.service != ctx.service {
t.Fatal("Clone should preserve the active translator")
if clone.service == ctx.service {
t.Fatal("Clone should duplicate cloneable translators")
}
if clone.Locale != ctx.Locale {
t.Fatalf("Clone should preserve locale, got %q want %q", clone.Locale, ctx.Locale)
@ -193,6 +202,31 @@ func TestContext_CloneDoesNotShareDefaultTranslator(t *testing.T) {
}
}
func TestContext_CloneClonesMutableTranslator(t *testing.T) {
svc := &localeTranslator{}
ctx := NewContextWithService(svc, "en-GB")
clone := ctx.Clone()
if clone == nil {
t.Fatal("Clone should return a context")
}
if clone.service == ctx.service {
t.Fatal("Clone should isolate cloneable translators")
}
clone.SetLocale("fr-FR")
if got := svc.language; got != "en" {
t.Fatalf("Clone should not mutate the original translator, got %q", got)
}
if got := Text("prompt.yes").Render(ctx); got != "y" {
t.Fatalf("Clone should leave original context translation unchanged, got %q", got)
}
if got := Text("prompt.yes").Render(clone); got != "o" {
t.Fatalf("Clone should reapply locale to the cloned translator, got %q", got)
}
}
func TestContext_WithDataReturnsClonedContext(t *testing.T) {
ctx := NewContext()
ctx.SetData("theme", "dark")
@ -251,11 +285,14 @@ func TestContext_WithLocaleReturnsClonedContext(t *testing.T) {
if got := next.Locale; got != "fr-FR" {
t.Fatalf("WithLocale should set the requested locale on the clone, got %q", got)
}
if got := next.service; got != ctx.service {
t.Fatal("WithLocale should preserve the active translator on the clone")
if got := next.service; got == ctx.service {
t.Fatal("WithLocale should duplicate cloneable translators on the clone")
}
if svc.language != "fr" {
t.Fatalf("WithLocale should reapply locale to the cloned service, got %q", svc.language)
if svc.language != "en" {
t.Fatalf("WithLocale should not mutate the original translator, got %q", svc.language)
}
if got := Text("prompt.yes").Render(next); got != "o" {
t.Fatalf("WithLocale should reapply locale to the cloned service, got %q", got)
}
if got := next.Data["theme"]; got != "dark" {
t.Fatalf("WithLocale should preserve existing data on the clone, got %v", got)

View file

@ -32,6 +32,15 @@ func (t *defaultTranslator) SetLanguage(language string) error {
return nil
}
func (t *defaultTranslator) Clone() Translator {
if t == nil {
return (*defaultTranslator)(nil)
}
clone := *t
return &clone
}
func newDefaultTranslator() Translator {
return &defaultTranslator{}
}

4
go.sum
View file

@ -14,12 +14,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -11,6 +11,12 @@ func cloneTranslator(svc Translator, locale string) Translator {
return nil
}
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
if clone := cloner.Clone(); clone != nil {
return clone
}
}
if current, ok := svc.(*i18n.Service); ok && current != nil {
clone := &i18n.Service{}
applyLocaleToService(clone, locale)

View file

@ -9,6 +9,12 @@ func cloneTranslator(svc Translator, _ string) Translator {
return nil
}
if cloner, ok := svc.(translatorCloner); ok && cloner != nil {
if clone := cloner.Clone(); clone != nil {
return clone
}
}
if current, ok := svc.(*defaultTranslator); ok && current != nil {
clone := *current
return &clone