fix(wasm): keep server i18n out of js builds

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 11:31:31 +00:00
parent 8a3f28aff3
commit 0e976b3a87
7 changed files with 47 additions and 18 deletions

View file

@ -1,6 +1,12 @@
package html
import i18n "dappco.re/go/core/i18n"
// Translator provides Text() lookups for a rendering context.
//
// The default server build uses go-i18n. Alternate builds, including WASM,
// can provide any implementation with the same T() method.
type Translator interface {
T(key string, args ...any) string
}
// Context carries rendering state through the node tree.
type Context struct {
@ -8,7 +14,7 @@ type Context struct {
Locale string
Entitlements func(feature string) bool
Data map[string]any
service *i18n.Service
service Translator
}
// NewContext creates a new rendering context with sensible defaults.
@ -18,8 +24,8 @@ func NewContext() *Context {
}
}
// NewContextWithService creates a rendering context backed by a specific i18n service.
func NewContextWithService(svc *i18n.Service) *Context {
// NewContextWithService creates a rendering context backed by a specific translator.
func NewContextWithService(svc Translator) *Context {
return &Context{
Data: make(map[string]any),
service: svc,

View file

@ -23,7 +23,7 @@ All concrete node types are unexported structs with exported constructor functio
|-------------|-----------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. |
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
| `Text(key, ...any)` | Translated text via the active context translator. Server builds fall back to global `go-i18n`; JS builds fall back to the key. Output is always HTML-escaped. |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
| `If(cond, Node)` | Renders the child only when the condition function returns true. |
| `Unless(cond, Node)` | Renders the child only when the condition function returns false. |
@ -50,16 +50,16 @@ type Context struct {
Locale string // BCP 47 locale string
Entitlements func(feature string) bool // feature gate callback
Data map[string]any // arbitrary per-request data
service *i18n.Service // unexported; set via constructor
service Translator // unexported; set via constructor
}
```
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.
- `NewContextWithService(svc)` creates a context backed by any translator implementing `T(key, ...any) string` such as `*i18n.Service`.
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, server builds fall back to the global `i18n.T()` default while JS builds render the key unchanged. This prevents callers from setting the service inconsistently after construction while keeping the WASM import graph lean.
## HLCRF Layout

View file

@ -291,6 +291,6 @@ func TestGenerateClass_ValidTag(t *testing.T) {
- `NewLayout("XYZ")` silently produces empty output for unrecognised slot letters. Valid letters are `H`, `L`, `C`, `R`, `F`. There is no error or warning.
- `Responsive.Variant()` accepts only `*Layout`, not arbitrary `Node` values. Arbitrary subtrees must be wrapped in a single-slot layout first.
- `Context.service` is unexported. Custom i18n service injection requires `NewContextWithService()`. There is no way to swap the service after construction.
- `Context.service` is unexported. Custom translation injection requires `NewContextWithService()`. There is no way to swap the translator after construction.
- The WASM module has no integration test for the JavaScript exports. `size_test.go` tests binary size only; it does not exercise `renderToString` behaviour from JavaScript.
- `codegen.GenerateBundle()` iterates a `map`, so the order of class definitions in the output is non-deterministic. This does not affect correctness but may cause cosmetic diffs between runs.

10
node.go
View file

@ -6,8 +6,6 @@ import (
"maps"
"slices"
"strings"
i18n "dappco.re/go/core/i18n"
)
// Node is anything renderable.
@ -152,13 +150,7 @@ func Text(key string, args ...any) Node {
}
func (n *textNode) Render(ctx *Context) string {
var text string
if ctx != nil && ctx.service != nil {
text = ctx.service.T(n.key, n.args...)
} else {
text = i18n.T(n.key, n.args...)
}
return escapeHTML(text)
return escapeHTML(translateText(ctx, n.key, n.args...))
}
// --- ifNode ---

11
text_translate.go Normal file
View file

@ -0,0 +1,11 @@
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateText(ctx *Context, key string, args ...any) string {
if ctx != nil && ctx.service != nil {
return ctx.service.T(key, args...)
}
return translateDefault(key, args...)
}

11
text_translate_default.go Normal file
View file

@ -0,0 +1,11 @@
//go:build !js
// SPDX-Licence-Identifier: EUPL-1.2
package html
import i18n "dappco.re/go/core/i18n"
func translateDefault(key string, args ...any) string {
return i18n.T(key, args...)
}

9
text_translate_js.go Normal file
View file

@ -0,0 +1,9 @@
//go:build js
// SPDX-Licence-Identifier: EUPL-1.2
package html
func translateDefault(key string, _ ...any) string {
return key
}