2026-02-16 23:34:27 +00:00
|
|
|
package html
|
|
|
|
|
|
2026-02-16 23:35:50 +00:00
|
|
|
import (
|
2026-03-09 08:29:54 +00:00
|
|
|
"html"
|
2026-02-23 05:11:04 +00:00
|
|
|
"iter"
|
|
|
|
|
"maps"
|
|
|
|
|
"slices"
|
2026-03-31 19:31:34 +00:00
|
|
|
"strconv"
|
2026-02-16 23:35:50 +00:00
|
|
|
)
|
2026-02-16 23:34:27 +00:00
|
|
|
|
|
|
|
|
// Node is anything renderable.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: var n Node = El("div", Text("welcome"))
|
2026-02-16 23:34:27 +00:00
|
|
|
type Node interface {
|
|
|
|
|
Render(ctx *Context) string
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 16:40:39 +00:00
|
|
|
// Compile-time interface checks.
|
|
|
|
|
var (
|
|
|
|
|
_ Node = (*rawNode)(nil)
|
|
|
|
|
_ Node = (*elNode)(nil)
|
|
|
|
|
_ Node = (*textNode)(nil)
|
|
|
|
|
_ Node = (*ifNode)(nil)
|
|
|
|
|
_ Node = (*unlessNode)(nil)
|
|
|
|
|
_ Node = (*entitledNode)(nil)
|
|
|
|
|
_ Node = (*switchNode)(nil)
|
|
|
|
|
_ Node = (*eachNode[any])(nil)
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-16 23:34:27 +00:00
|
|
|
// 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 {
|
2026-03-09 08:29:54 +00:00
|
|
|
return html.EscapeString(s)
|
2026-02-16 23:34:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- rawNode ---
|
|
|
|
|
|
|
|
|
|
type rawNode struct {
|
|
|
|
|
content string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Raw creates a node that renders without escaping (escape hatch for trusted content).
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Raw("<strong>trusted</strong>")
|
2026-02-16 23:34:27 +00:00
|
|
|
func Raw(content string) Node {
|
|
|
|
|
return &rawNode{content: content}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *rawNode) Render(_ *Context) string {
|
2026-03-30 00:09:50 +00:00
|
|
|
if n == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:34:27 +00:00
|
|
|
return n.content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- elNode ---
|
|
|
|
|
|
|
|
|
|
type elNode struct {
|
|
|
|
|
tag string
|
|
|
|
|
children []Node
|
|
|
|
|
attrs map[string]string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// El creates an HTML element node with children.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: El("section", Text("welcome"))
|
2026-02-16 23:34:27 +00:00
|
|
|
func El(tag string, children ...Node) Node {
|
|
|
|
|
return &elNode{
|
|
|
|
|
tag: tag,
|
|
|
|
|
children: children,
|
|
|
|
|
attrs: make(map[string]string),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 00:08:17 +00:00
|
|
|
// Attr sets an attribute on an El node. Returns the node for chaining.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Attr(El("a", Text("docs")), "href", "/docs")
|
2026-03-09 08:29:54 +00:00
|
|
|
// It recursively traverses through wrappers like If, Unless, and Entitled.
|
2026-02-17 00:08:17 +00:00
|
|
|
func Attr(n Node, key, value string) Node {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil {
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 08:29:54 +00:00
|
|
|
switch t := n.(type) {
|
|
|
|
|
case *elNode:
|
|
|
|
|
t.attrs[key] = value
|
|
|
|
|
case *ifNode:
|
|
|
|
|
Attr(t.node, key, value)
|
|
|
|
|
case *unlessNode:
|
|
|
|
|
Attr(t.node, key, value)
|
|
|
|
|
case *entitledNode:
|
|
|
|
|
Attr(t.node, key, value)
|
2026-03-31 20:07:35 +00:00
|
|
|
case *switchNode:
|
|
|
|
|
for _, child := range t.cases {
|
|
|
|
|
Attr(child, key, value)
|
|
|
|
|
}
|
2026-02-17 00:08:17 +00:00
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:28:53 +00:00
|
|
|
// AriaLabel sets an aria-label attribute on an element node.
|
|
|
|
|
// Usage example: AriaLabel(El("button", Text("save")), "Save changes")
|
|
|
|
|
func AriaLabel(n Node, label string) Node {
|
|
|
|
|
return Attr(n, "aria-label", label)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AltText sets an alt attribute on an element node.
|
|
|
|
|
// Usage example: AltText(El("img"), "Profile photo")
|
|
|
|
|
func AltText(n Node, text string) Node {
|
|
|
|
|
return Attr(n, "alt", text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:31:34 +00:00
|
|
|
// TabIndex sets a tabindex attribute on an element node.
|
|
|
|
|
// Usage example: TabIndex(El("button", Text("save")), 0)
|
|
|
|
|
func TabIndex(n Node, index int) Node {
|
|
|
|
|
return Attr(n, "tabindex", strconv.Itoa(index))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AutoFocus sets an autofocus attribute on an element node.
|
|
|
|
|
// Usage example: AutoFocus(El("input"))
|
|
|
|
|
func AutoFocus(n Node) Node {
|
|
|
|
|
return Attr(n, "autofocus", "autofocus")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 23:34:27 +00:00
|
|
|
func (n *elNode) Render(ctx *Context) string {
|
2026-03-30 00:09:50 +00:00
|
|
|
if n == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:24:16 +00:00
|
|
|
b := newTextBuilder()
|
2026-02-16 23:34:27 +00:00
|
|
|
|
|
|
|
|
b.WriteByte('<')
|
2026-03-09 08:29:54 +00:00
|
|
|
b.WriteString(escapeHTML(n.tag))
|
2026-02-16 23:34:27 +00:00
|
|
|
|
2026-02-16 23:53:31 +00:00
|
|
|
// Sort attribute keys for deterministic output.
|
2026-02-23 05:11:04 +00:00
|
|
|
keys := slices.Collect(maps.Keys(n.attrs))
|
|
|
|
|
slices.Sort(keys)
|
2026-02-16 23:53:31 +00:00
|
|
|
for _, key := range keys {
|
2026-02-16 23:34:27 +00:00
|
|
|
b.WriteByte(' ')
|
2026-03-09 08:29:54 +00:00
|
|
|
b.WriteString(escapeHTML(key))
|
2026-02-16 23:34:27 +00:00
|
|
|
b.WriteString(`="`)
|
2026-02-16 23:53:31 +00:00
|
|
|
b.WriteString(escapeAttr(n.attrs[key]))
|
2026-02-16 23:34:27 +00:00
|
|
|
b.WriteByte('"')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.WriteByte('>')
|
|
|
|
|
|
|
|
|
|
if voidElements[n.tag] {
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 05:11:04 +00:00
|
|
|
for i := range len(n.children) {
|
2026-03-30 00:09:50 +00:00
|
|
|
if n.children[i] == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-02-23 05:11:04 +00:00
|
|
|
b.WriteString(n.children[i].Render(ctx))
|
2026-02-16 23:34:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.WriteString("</")
|
2026-03-09 08:29:54 +00:00
|
|
|
b.WriteString(escapeHTML(n.tag))
|
2026-02-16 23:34:27 +00:00
|
|
|
b.WriteByte('>')
|
|
|
|
|
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
2026-02-16 23:35:50 +00:00
|
|
|
|
|
|
|
|
// --- escapeHTML ---
|
|
|
|
|
|
|
|
|
|
// escapeHTML escapes a string for safe inclusion in HTML text content.
|
|
|
|
|
func escapeHTML(s string) string {
|
2026-03-09 08:29:54 +00:00
|
|
|
return html.EscapeString(s)
|
2026-02-16 23:35:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- textNode ---
|
|
|
|
|
|
|
|
|
|
type textNode struct {
|
|
|
|
|
key string
|
|
|
|
|
args []any
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text creates a node that renders through the go-i18n grammar pipeline.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Text("welcome", "Ada")
|
2026-02-16 23:35:50 +00:00
|
|
|
// 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 {
|
2026-03-30 00:09:50 +00:00
|
|
|
if n == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-03-26 11:31:31 +00:00
|
|
|
return escapeHTML(translateText(ctx, n.key, n.args...))
|
2026-02-16 23:35:50 +00:00
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
|
|
|
|
|
// --- ifNode ---
|
|
|
|
|
|
|
|
|
|
type ifNode struct {
|
|
|
|
|
cond func(*Context) bool
|
|
|
|
|
node Node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If renders child only when condition is true.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: If(func(ctx *Context) bool { return ctx.Identity != "" }, Text("hi"))
|
2026-02-16 23:36:54 +00:00
|
|
|
func If(cond func(*Context) bool, node Node) Node {
|
|
|
|
|
return &ifNode{cond: cond, node: node}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *ifNode) Render(ctx *Context) string {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil || n.cond == nil || n.node == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
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.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Unless(func(ctx *Context) bool { return ctx.Identity == "" }, Text("welcome"))
|
2026-02-16 23:36:54 +00:00
|
|
|
func Unless(cond func(*Context) bool, node Node) Node {
|
|
|
|
|
return &unlessNode{cond: cond, node: node}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *unlessNode) Render(ctx *Context) string {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil || n.cond == nil || n.node == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
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.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Entitled("beta", Text("preview"))
|
2026-02-16 23:36:54 +00:00
|
|
|
// 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 {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil || n.node == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
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.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Switch(func(ctx *Context) string { return ctx.Locale }, map[string]Node{"en": Text("hello")})
|
2026-02-16 23:36:54 +00:00
|
|
|
func Switch(selector func(*Context) string, cases map[string]Node) Node {
|
|
|
|
|
return &switchNode{selector: selector, cases: cases}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *switchNode) Render(ctx *Context) string {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil || n.selector == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
key := n.selector(ctx)
|
2026-03-29 23:10:48 +00:00
|
|
|
if n.cases == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
if node, ok := n.cases[key]; ok {
|
2026-03-29 23:10:48 +00:00
|
|
|
if node == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2026-02-16 23:36:54 +00:00
|
|
|
return node.Render(ctx)
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- eachNode ---
|
|
|
|
|
|
|
|
|
|
type eachNode[T any] struct {
|
2026-02-23 05:11:04 +00:00
|
|
|
items iter.Seq[T]
|
2026-02-16 23:36:54 +00:00
|
|
|
fn func(T) Node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Each iterates items and renders each via fn.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: Each([]string{"a", "b"}, func(v string) Node { return Text(v) })
|
2026-02-16 23:36:54 +00:00
|
|
|
func Each[T any](items []T, fn func(T) Node) Node {
|
2026-02-23 05:11:04 +00:00
|
|
|
return EachSeq(slices.Values(items), fn)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EachSeq iterates an iter.Seq and renders each via fn.
|
2026-03-26 18:12:06 +00:00
|
|
|
// Usage example: EachSeq(slices.Values([]string{"a", "b"}), func(v string) Node { return Text(v) })
|
2026-02-23 05:11:04 +00:00
|
|
|
func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node {
|
2026-02-16 23:36:54 +00:00
|
|
|
return &eachNode[T]{items: items, fn: fn}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (n *eachNode[T]) Render(ctx *Context) string {
|
2026-03-29 23:10:48 +00:00
|
|
|
if n == nil || n.fn == nil || n.items == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:24:16 +00:00
|
|
|
b := newTextBuilder()
|
2026-02-23 05:11:04 +00:00
|
|
|
for item := range n.items {
|
2026-03-29 23:10:48 +00:00
|
|
|
child := n.fn(item)
|
|
|
|
|
if child == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(child.Render(ctx))
|
2026-02-16 23:36:54 +00:00
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|