feat: add Text node with go-i18n grammar pipeline

Introduces textNode that renders through go-i18n's T() function with
automatic HTML escaping for safe-by-default output. Adds escapeHTML
utility, NewContextWithService for explicit service binding, and
Entitlements field on Context for upcoming conditional rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-16 23:35:50 +00:00
parent d7bb0b2b2c
commit 3e76e72cb7
No known key found for this signature in database
GPG key ID: AF404715446AEB41
5 changed files with 86 additions and 5 deletions

View file

@ -1,10 +1,14 @@
package html package html
import i18n "forge.lthn.ai/core/go-i18n"
// Context carries rendering state through the node tree. // Context carries rendering state through the node tree.
type Context struct { type Context struct {
Identity string Identity string
Locale string Locale string
Data map[string]any Entitlements func(feature string) bool
Data map[string]any
service *i18n.Service
} }
// NewContext creates a new rendering context with sensible defaults. // NewContext creates a new rendering context with sensible defaults.
@ -13,3 +17,11 @@ func NewContext() *Context {
Data: make(map[string]any), Data: make(map[string]any),
} }
} }
// NewContextWithService creates a rendering context backed by a specific i18n service.
func NewContextWithService(svc *i18n.Service) *Context {
return &Context{
Data: make(map[string]any),
service: svc,
}
}

4
go.mod
View file

@ -3,3 +3,7 @@ module forge.lthn.ai/core/go-html
go 1.25.5 go 1.25.5
replace forge.lthn.ai/core/go-i18n => ../go-i18n replace forge.lthn.ai/core/go-i18n => ../go-i18n
require forge.lthn.ai/core/go-i18n v0.0.0-00010101000000-000000000000
require golang.org/x/text v0.33.0 // indirect

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=

41
node.go
View file

@ -1,6 +1,10 @@
package html package html
import "strings" import (
"strings"
i18n "forge.lthn.ai/core/go-i18n"
)
// Node is anything renderable. // Node is anything renderable.
type Node interface { type Node interface {
@ -96,3 +100,38 @@ func (n *elNode) Render(ctx *Context) string {
return b.String() return b.String()
} }
// --- escapeHTML ---
// escapeHTML escapes a string for safe inclusion in HTML text content.
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}
// --- textNode ---
type textNode struct {
key string
args []any
}
// Text creates a node that renders through the go-i18n grammar pipeline.
// Output is HTML-escaped by default. Safe-by-default path.
func Text(key string, args ...any) Node {
return &textNode{key: key, args: args}
}
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)
}

View file

@ -1,6 +1,9 @@
package html package html
import "testing" import (
"strings"
"testing"
)
func TestRawNode_Render(t *testing.T) { func TestRawNode_Render(t *testing.T) {
ctx := NewContext() ctx := NewContext()
@ -50,3 +53,24 @@ func TestElNode_VoidElement(t *testing.T) {
t.Errorf("El(\"br\").Render() = %q, want %q", got, want) t.Errorf("El(\"br\").Render() = %q, want %q", got, want)
} }
} }
func TestTextNode_Render(t *testing.T) {
ctx := NewContext()
node := Text("hello")
got := node.Render(ctx)
if got != "hello" {
t.Errorf("Text(\"hello\").Render() = %q, want %q", got, "hello")
}
}
func TestTextNode_Escapes(t *testing.T) {
ctx := NewContext()
node := Text("<script>alert('xss')</script>")
got := node.Render(ctx)
if strings.Contains(got, "<script>") {
t.Errorf("Text node must HTML-escape output, got %q", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("Text node should contain escaped script tag, got %q", got)
}
}