commit d7bb0b2b2cc7d533b9be85d3bf23fe833d394dd0 Author: Claude Date: Mon Feb 16 23:34:27 2026 +0000 feat: scaffold go-html module with Node interface Introduces the go-html module with a Node rendering interface, Raw and El constructors, void element handling, and attribute escaping. Includes a minimal Context stub and tests for all node types. Co-Authored-By: Claude Opus 4.6 diff --git a/context.go b/context.go new file mode 100644 index 0000000..a74aeb5 --- /dev/null +++ b/context.go @@ -0,0 +1,15 @@ +package html + +// Context carries rendering state through the node tree. +type Context struct { + Identity string + Locale string + Data map[string]any +} + +// NewContext creates a new rendering context with sensible defaults. +func NewContext() *Context { + return &Context{ + Data: make(map[string]any), + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e20be72 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module forge.lthn.ai/core/go-html + +go 1.25.5 + +replace forge.lthn.ai/core/go-i18n => ../go-i18n diff --git a/node.go b/node.go new file mode 100644 index 0000000..f4599d3 --- /dev/null +++ b/node.go @@ -0,0 +1,98 @@ +package html + +import "strings" + +// Node is anything renderable. +type Node interface { + Render(ctx *Context) string +} + +// voidElements is the set of HTML elements that must not have a closing tag. +var voidElements = map[string]bool{ + "area": true, + "base": true, + "br": true, + "col": true, + "embed": true, + "hr": true, + "img": true, + "input": true, + "link": true, + "meta": true, + "source": true, + "track": true, + "wbr": true, +} + +// escapeAttr escapes a string for use in an HTML attribute value. +func escapeAttr(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// --- rawNode --- + +type rawNode struct { + content string +} + +// Raw creates a node that renders without escaping (escape hatch for trusted content). +func Raw(content string) Node { + return &rawNode{content: content} +} + +func (n *rawNode) Render(_ *Context) string { + return n.content +} + +// --- elNode --- + +type elNode struct { + tag string + children []Node + attrs map[string]string +} + +// El creates an HTML element node with children. +func El(tag string, children ...Node) Node { + return &elNode{ + tag: tag, + children: children, + attrs: make(map[string]string), + } +} + +func (n *elNode) Render(ctx *Context) string { + var b strings.Builder + + b.WriteByte('<') + b.WriteString(n.tag) + + for key, val := range n.attrs { + b.WriteByte(' ') + b.WriteString(key) + b.WriteString(`="`) + b.WriteString(escapeAttr(val)) + b.WriteByte('"') + } + + b.WriteByte('>') + + if voidElements[n.tag] { + return b.String() + } + + for _, child := range n.children { + b.WriteString(child.Render(ctx)) + } + + b.WriteString("') + + return b.String() +} diff --git a/node_test.go b/node_test.go new file mode 100644 index 0000000..aab8d72 --- /dev/null +++ b/node_test.go @@ -0,0 +1,52 @@ +package html + +import "testing" + +func TestRawNode_Render(t *testing.T) { + ctx := NewContext() + node := Raw("hello") + got := node.Render(ctx) + if got != "hello" { + t.Errorf("Raw(\"hello\").Render() = %q, want %q", got, "hello") + } +} + +func TestElNode_Render(t *testing.T) { + ctx := NewContext() + node := El("div", Raw("content")) + got := node.Render(ctx) + want := "
content
" + if got != want { + t.Errorf("El(\"div\", Raw(\"content\")).Render() = %q, want %q", got, want) + } +} + +func TestElNode_Nested(t *testing.T) { + ctx := NewContext() + node := El("div", El("span", Raw("inner"))) + got := node.Render(ctx) + want := "
inner
" + if got != want { + t.Errorf("nested El().Render() = %q, want %q", got, want) + } +} + +func TestElNode_MultipleChildren(t *testing.T) { + ctx := NewContext() + node := El("div", Raw("a"), Raw("b")) + got := node.Render(ctx) + want := "
ab
" + if got != want { + t.Errorf("El with multiple children = %q, want %q", got, want) + } +} + +func TestElNode_VoidElement(t *testing.T) { + ctx := NewContext() + node := El("br") + got := node.Render(ctx) + want := "
" + if got != want { + t.Errorf("El(\"br\").Render() = %q, want %q", got, want) + } +}