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 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-16 23:34:27 +00:00
commit d7bb0b2b2c
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 170 additions and 0 deletions

15
context.go Normal file
View file

@ -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),
}
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module forge.lthn.ai/core/go-html
go 1.25.5
replace forge.lthn.ai/core/go-i18n => ../go-i18n

98
node.go Normal file
View file

@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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("</")
b.WriteString(n.tag)
b.WriteByte('>')
return b.String()
}

52
node_test.go Normal file
View file

@ -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 := "<div>content</div>"
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 := "<div><span>inner</span></div>"
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 := "<div>ab</div>"
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 := "<br>"
if got != want {
t.Errorf("El(\"br\").Render() = %q, want %q", got, want)
}
}