From b3b44ae432492357b6bad0f94e90cc5ff0418e43 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 02:03:39 +0000 Subject: [PATCH] feat(html): restore context translator swapping Co-Authored-By: Virgil --- context.go | 27 ++++++++++++++++++++++- context_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 2 +- docs/history.md | 2 +- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/context.go b/context.go index be4e3cf..9b5dba4 100644 --- a/context.go +++ b/context.go @@ -16,7 +16,7 @@ type translatorCloner interface { // 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. +// Locale and translator selection are managed through dedicated setters. type Context struct { Identity string Locale string @@ -110,6 +110,20 @@ func NewContextWithService(svc Translator, locale ...string) *Context { return ctx } +// SetService swaps the translator used by the context and reapplies the +// current locale to it. +// Example: ctx.SetService(svc). +func (ctx *Context) SetService(svc Translator) *Context { + if ctx == nil { + return nil + } + + ensureContextDefaults(ctx) + ctx.service = svc + applyLocaleToService(ctx.service, ctx.Locale) + return ctx +} + // SetEntitlements updates the feature gate callback used by Entitled nodes and // returns the same context. // Example: ctx.SetEntitlements(func(feature string) bool { return feature == "premium" }). @@ -181,6 +195,17 @@ func (ctx *Context) WithLocale(locale string) *Context { return clone } +// WithService returns a cloned context with a different translator. +// Example: next := ctx.WithService(svc). +func (ctx *Context) WithService(svc Translator) *Context { + clone := ctx.Clone() + if clone == nil { + return nil + } + clone.SetService(svc) + return clone +} + // WithEntitlements returns a cloned context with a different feature gate callback. // Example: next := ctx.WithEntitlements(func(feature string) bool { return feature == "premium" }). func (ctx *Context) WithEntitlements(entitlements func(feature string) bool) *Context { diff --git a/context_test.go b/context_test.go index d496d25..e2e1985 100644 --- a/context_test.go +++ b/context_test.go @@ -88,6 +88,24 @@ func TestContext_SetLocale_ReappliesToTranslator(t *testing.T) { } } +func TestContext_SetService_ReappliesCurrentLocale(t *testing.T) { + ctx := NewContext("fr-FR") + svc := &localeTranslator{} + + if got := ctx.SetService(svc); got != ctx { + t.Fatalf("SetService should return the same context") + } + if ctx.service != svc { + t.Fatalf("SetService should replace the active translator") + } + if svc.language != "fr" { + t.Fatalf("SetService should apply the existing locale to the new translator, got %q", svc.language) + } + if got := Text("prompt.yes").Render(ctx); got != "o" { + t.Fatalf("SetService translation = %q, want %q", got, "o") + } +} + func TestContext_SetEntitlements_UpdatesFeatureGate(t *testing.T) { ctx := NewContext() @@ -282,6 +300,34 @@ func TestContext_WithLocaleReturnsClonedContext(t *testing.T) { } } +func TestContext_WithServiceReturnsClonedContext(t *testing.T) { + ctx := NewContext("fr-FR") + ctx.SetIdentity("user-001") + ctx.SetData("theme", "dark") + + svc := &localeTranslator{} + next := ctx.WithService(svc) + + if next == ctx { + t.Fatal("WithService should return a cloned context") + } + if got := ctx.service; got == svc { + t.Fatal("WithService should not mutate the original context service") + } + if got := next.service; got != svc { + t.Fatalf("WithService should set the requested service on the clone, got %v", got) + } + if svc.language != "fr" { + t.Fatalf("WithService should apply the existing locale to the new translator, got %q", svc.language) + } + if got := next.Data["theme"]; got != "dark" { + t.Fatalf("WithService should preserve existing data on the clone, got %v", got) + } + if got := next.Locale; got != "fr-FR" { + t.Fatalf("WithService should preserve the locale on the clone, got %q", got) + } +} + func TestContext_WithEntitlementsReturnsClonedContext(t *testing.T) { ctx := NewContext() ctx.SetIdentity("user-001") @@ -328,12 +374,18 @@ func TestContext_Setters_NilReceiver(t *testing.T) { if got := ctx.SetLocale("en-GB"); got != nil { t.Fatalf("nil Context.SetLocale should return nil, got %v", got) } + if got := ctx.SetService(&localeTranslator{}); got != nil { + t.Fatalf("nil Context.SetService should return nil, got %v", got) + } if got := ctx.SetEntitlements(func(string) bool { return true }); got != nil { t.Fatalf("nil Context.SetEntitlements should return nil, got %v", got) } if got := ctx.WithLocale("en-GB"); got != nil { t.Fatalf("nil Context.WithLocale should return nil, got %v", got) } + if got := ctx.WithService(&localeTranslator{}); got != nil { + t.Fatalf("nil Context.WithService should return nil, got %v", got) + } if got := ctx.WithEntitlements(func(string) bool { return true }); got != nil { t.Fatalf("nil Context.WithEntitlements should return nil, got %v", got) } diff --git a/docs/architecture.md b/docs/architecture.md index fef04a9..609461e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,7 +101,7 @@ Two constructors are provided: - `NewContext()` creates a context with sensible defaults and an empty `Data` map. - `NewContextWithService(svc)` creates a context backed by a specific `i18n.Service` instance. -The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. This prevents callers from setting the service inconsistently after construction. +The `service` field is intentionally unexported. When nil, `Text` nodes fall back to the global `i18n.T()` default. Callers can replace the active translator with `SetService()` or `WithService()`, which reapply the current locale to the new service. ## HLCRF Layout diff --git a/docs/history.md b/docs/history.md index dbf12aa..276a5f8 100644 --- a/docs/history.md +++ b/docs/history.md @@ -101,7 +101,7 @@ These are not regressions; they are design choices or deferred work recorded for 3. **Responsive accepts only Layout.** `Responsive.Variant()` takes `*Layout` rather than `Node`. The rationale is that `CompareVariants` in the pipeline needs access to the slot structure. Accepting `Node` would require a different approach to variant analysis. -4. **Context.service is private and immutable.** The i18n service is selected at construction time via `NewContext()` or `NewContextWithService()` and cannot be swapped afterwards. This keeps rendering state stable and avoids accidental cross-request mutation. +4. **Context.service is private, but swappable through setters.** The i18n service remains unexported, but `SetService()` and `WithService()` let callers replace it while keeping the current locale applied. 5. **TypeScript definitions are generated.** `codegen.GenerateTypeDefinitions()` produces a `.d.ts` companion for the generated Web Components.