package html
import (
"strings"
i18n "forge.lthn.ai/core/go-i18n"
)
// 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("")
b.WriteString(n.tag)
b.WriteByte('>')
return b.String()
}
// --- escapeHTML ---
// escapeHTML escapes a string for safe inclusion in HTML text content.
func escapeHTML(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
}
// --- 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)
}