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>
137 lines
2.7 KiB
Go
137 lines
2.7 KiB
Go
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)
|
|
}
|