package html import ( "sort" "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) // Sort attribute keys for deterministic output. keys := make([]string, 0, len(n.attrs)) for k := range n.attrs { keys = append(keys, k) } sort.Strings(keys) for _, key := range keys { b.WriteByte(' ') b.WriteString(key) b.WriteString(`="`) b.WriteString(escapeAttr(n.attrs[key])) 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() } // --- 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) } // --- ifNode --- type ifNode struct { cond func(*Context) bool node Node } // If renders child only when condition is true. func If(cond func(*Context) bool, node Node) Node { return &ifNode{cond: cond, node: node} } func (n *ifNode) Render(ctx *Context) string { if n.cond(ctx) { return n.node.Render(ctx) } return "" } // --- unlessNode --- type unlessNode struct { cond func(*Context) bool node Node } // Unless renders child only when condition is false. func Unless(cond func(*Context) bool, node Node) Node { return &unlessNode{cond: cond, node: node} } func (n *unlessNode) Render(ctx *Context) string { if !n.cond(ctx) { return n.node.Render(ctx) } return "" } // --- entitledNode --- type entitledNode struct { feature string node Node } // Entitled renders child only when entitlement is granted. Absent, not hidden. // If no entitlement function is set on the context, access is denied by default. func Entitled(feature string, node Node) Node { return &entitledNode{feature: feature, node: node} } func (n *entitledNode) Render(ctx *Context) string { if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(n.feature) { return "" } return n.node.Render(ctx) } // --- switchNode --- type switchNode struct { selector func(*Context) string cases map[string]Node } // Switch renders based on runtime selector value. func Switch(selector func(*Context) string, cases map[string]Node) Node { return &switchNode{selector: selector, cases: cases} } func (n *switchNode) Render(ctx *Context) string { key := n.selector(ctx) if node, ok := n.cases[key]; ok { return node.Render(ctx) } return "" } // --- eachNode --- type eachNode[T any] struct { items []T fn func(T) Node } // Each iterates items and renders each via fn. func Each[T any](items []T, fn func(T) Node) Node { return &eachNode[T]{items: items, fn: fn} } func (n *eachNode[T]) Render(ctx *Context) string { if len(n.items) == 0 { return "" } var b strings.Builder for _, item := range n.items { b.WriteString(n.fn(item).Render(ctx)) } return b.String() }