package html import ( "html" "iter" "maps" "slices" "strconv" "strings" ) // node.go: Node is anything renderable. // Example: El("p", Text("page.body")) returns a Node that can be passed to Render(). type Node interface { Render(ctx *Context) string } type cloneableNode interface { cloneNode() Node } // 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) ) // renderNode renders a node while treating nil values as empty output. func renderNode(n Node, ctx *Context) string { if n == nil { return "" } return n.Render(normaliseContext(ctx)) } // renderNodeWithPath renders a node while preserving layout path prefixes for // nested layouts that may be wrapped in conditional or switch nodes. func renderNodeWithPath(n Node, ctx *Context, path string) string { if n == nil { return "" } ctx = normaliseContext(ctx) switch t := n.(type) { case *Layout: if t == nil { return "" } clone := *t clone.path = path return clone.Render(ctx) case interface{ renderWithPath(*Context, string) string }: return t.renderWithPath(ctx, path) case *ifNode: if t == nil || t.cond == nil || t.node == nil { return "" } if t.cond(ctx) { return renderNodeWithPath(t.node, ctx, path) } return "" case *unlessNode: if t == nil || t.cond == nil || t.node == nil { return "" } if !t.cond(ctx) { return renderNodeWithPath(t.node, ctx, path) } return "" case *entitledNode: if t == nil || t.node == nil { return "" } if ctx == nil || ctx.Entitlements == nil || !ctx.Entitlements(t.feature) { return "" } return renderNodeWithPath(t.node, ctx, path) case *switchNode: if t == nil || t.selector == nil || t.cases == nil { return "" } key := t.selector(ctx) if node, ok := t.cases[key]; ok { return renderNodeWithPath(node, ctx, path) } return "" default: return n.Render(ctx) } } // 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 { return html.EscapeString(s) } // writeSortedAttrs renders a deterministic attribute list to the builder. // An optional skip callback can omit reserved keys while preserving ordering // for the remaining attributes. func writeSortedAttrs(b *strings.Builder, attrs map[string]string, skip func(string) bool) { if len(attrs) == 0 { return } keys := slices.Collect(maps.Keys(attrs)) slices.Sort(keys) for _, key := range keys { if skip != nil && skip(key) { continue } b.WriteByte(' ') b.WriteString(escapeHTML(key)) b.WriteString(`="`) b.WriteString(escapeAttr(attrs[key])) b.WriteByte('"') } } // --- rawNode --- type rawNode struct { content string } // node.go: Raw creates a node that renders without escaping. // Example: Raw("trusted") preserves the HTML verbatim. func Raw(content string) Node { return &rawNode{content: content} } func (n *rawNode) Render(_ *Context) string { if n == nil { return "" } return n.content } // --- elNode --- type elNode struct { tag string children []Node attrs map[string]string } // node.go: El creates an HTML element node with children. // Example: El("nav", Text("nav.label")) renders a semantic element with nested nodes. func El(tag string, children ...Node) Node { return &elNode{ tag: tag, children: children, attrs: make(map[string]string), } } // node.go: Attr sets an attribute on an El node and returns the same node for chaining. // Example: Attr(El("img"), "alt", "Logo") adds an escaped alt attribute. // // It recursively traverses wrappers like If, Unless, Entitled, Switch, // and Each/EachSeq so the attribute lands on the rendered element. func Attr(n Node, key, value string) Node { switch t := n.(type) { case *elNode: t.attrs[key] = value case *ifNode: t.node = Attr(cloneNode(t.node), key, value) case *unlessNode: t.node = Attr(cloneNode(t.node), key, value) case *entitledNode: t.node = Attr(cloneNode(t.node), key, value) case *switchNode: cloned := make(map[string]Node, len(t.cases)) for caseKey, caseNode := range t.cases { cloned[caseKey] = Attr(cloneNode(caseNode), key, value) } t.cases = cloned case interface{ setAttr(string, string) }: t.setAttr(key, value) } return n } func cloneNode(n Node) Node { if n == nil { return nil } if cloner, ok := n.(cloneableNode); ok { return cloner.cloneNode() } return n } // node.go: AriaLabel sets the aria-label attribute on an element node. // Example: AriaLabel(El("button"), "Open menu"). func AriaLabel(n Node, label string) Node { if value := trimmedNonEmpty(label); value != "" { return Attr(n, "aria-label", value) } return n } // node.go: AriaDescribedBy sets the aria-describedby attribute on an element node. // Example: AriaDescribedBy(El("input"), "help-text", "error-text"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaDescribedBy(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-describedby", value) } return n } // node.go: AriaLabelledBy sets the aria-labelledby attribute on an element node. // Example: AriaLabelledBy(El("section"), "section-title"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaLabelledBy(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-labelledby", value) } return n } // node.go: AriaControls sets the aria-controls attribute on an element node. // Example: AriaControls(El("button"), "menu-panel"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaControls(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-controls", value) } return n } // node.go: AriaHasPopup sets the aria-haspopup attribute on an element node. // Example: AriaHasPopup(El("button"), "menu"). // An empty value leaves the node unchanged so callers can opt out cleanly. func AriaHasPopup(n Node, popup string) Node { if value := trimmedNonEmpty(popup); value != "" { return Attr(n, "aria-haspopup", value) } return n } // node.go: AriaOwns sets the aria-owns attribute on an element node. // Example: AriaOwns(El("div"), "owned-panel"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaOwns(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-owns", value) } return n } // node.go: AriaKeyShortcuts sets the aria-keyshortcuts attribute on an element node. // Example: AriaKeyShortcuts(El("button"), "Ctrl+S", "Meta+S"). // Multiple shortcuts are joined with spaces, matching the HTML attribute format. func AriaKeyShortcuts(n Node, shortcuts ...string) Node { if value := joinNonEmpty(shortcuts...); value != "" { return Attr(n, "aria-keyshortcuts", value) } return n } // node.go: AriaCurrent sets the aria-current attribute on an element node. // Example: AriaCurrent(El("a"), "page"). // An empty value leaves the node unchanged so callers can opt out cleanly. func AriaCurrent(n Node, current string) Node { if value := trimmedNonEmpty(current); value != "" { return Attr(n, "aria-current", value) } return n } // node.go: AriaBusy sets the aria-busy attribute on an element node. // Example: AriaBusy(El("section"), true). func AriaBusy(n Node, busy bool) Node { if busy { return Attr(n, "aria-busy", "true") } return Attr(n, "aria-busy", "false") } // node.go: AriaLive sets the aria-live attribute on an element node. // Example: AriaLive(El("div"), "polite"). // An empty value leaves the node unchanged so callers can opt out cleanly. func AriaLive(n Node, live string) Node { if value := trimmedNonEmpty(live); value != "" { return Attr(n, "aria-live", value) } return n } // node.go: AriaAtomic sets the aria-atomic attribute on a live region node. // Example: AriaAtomic(El("div"), true). func AriaAtomic(n Node, atomic bool) Node { if atomic { return Attr(n, "aria-atomic", "true") } return Attr(n, "aria-atomic", "false") } // node.go: AriaDescription sets the aria-description attribute on an element node. // Example: AriaDescription(El("button"), "Opens the navigation menu"). // An empty value leaves the node unchanged so callers can opt out cleanly. func AriaDescription(n Node, description string) Node { if value := trimmedNonEmpty(description); value != "" { return Attr(n, "aria-description", value) } return n } // node.go: AriaDetails sets the aria-details attribute on an element node. // Example: AriaDetails(El("input"), "field-help"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaDetails(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-details", value) } return n } // node.go: AriaErrorMessage sets the aria-errormessage attribute on an element node. // Example: AriaErrorMessage(El("input"), "field-error"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaErrorMessage(n Node, ids ...string) Node { if value := joinNonEmpty(ids...); value != "" { return Attr(n, "aria-errormessage", value) } return n } // node.go: AriaRoleDescription sets the aria-roledescription attribute on an // element node. // Example: AriaRoleDescription(El("section"), "carousel"). // An empty value leaves the node unchanged so callers can opt out cleanly. func AriaRoleDescription(n Node, description string) Node { if value := trimmedNonEmpty(description); value != "" { return Attr(n, "aria-roledescription", value) } return n } // node.go: Role sets the role attribute on an element node. // Example: Role(El("aside"), "complementary"). func Role(n Node, role string) Node { if value := trimmedNonEmpty(role); value != "" { return Attr(n, "role", value) } return n } // node.go: Lang sets the lang attribute on an element node. // Example: Lang(El("html"), "en-GB"). func Lang(n Node, locale string) Node { if value := trimmedNonEmpty(locale); value != "" { return Attr(n, "lang", value) } return n } // node.go: Dir sets the dir attribute on an element node. // Example: Dir(El("p"), "rtl"). func Dir(n Node, direction string) Node { if value := trimmedNonEmpty(direction); value != "" { return Attr(n, "dir", value) } return n } // node.go: Alt sets the alt attribute on an element node. // Example: Alt(El("img"), "Product screenshot"). func Alt(n Node, text string) Node { if value := trimmedNonEmpty(text); value != "" { return Attr(n, "alt", value) } return n } // node.go: Title sets the title attribute on an element node. // Example: Title(El("abbr"), "World Wide Web"). func Title(n Node, text string) Node { if value := trimmedNonEmpty(text); value != "" { return Attr(n, "title", value) } return n } // node.go: Placeholder sets the placeholder attribute on an element node. // Example: Placeholder(El("input"), "Search by keyword"). // An empty value leaves the node unchanged so callers can opt out cleanly. func Placeholder(n Node, text string) Node { if value := trimmedNonEmpty(text); value != "" { return Attr(n, "placeholder", value) } return n } // node.go: AltText is a compatibility alias for Alt. // Example: AltText(El("img"), "Product screenshot"). // Prefer Alt for new call sites so the canonical image helper stays predictable. func AltText(n Node, text string) Node { return Alt(n, text) } // node.go: Class sets the class attribute on an element node. // Example: Class(El("div"), "card", "card--primary"). // Multiple class tokens are joined with spaces. func Class(n Node, classes ...string) Node { if value := joinNonEmpty(classes...); value != "" { return Attr(n, "class", value) } return n } // node.go: AriaHidden sets the aria-hidden attribute on an element node. // Example: AriaHidden(El("svg"), true). func AriaHidden(n Node, hidden bool) Node { if hidden { return Attr(n, "aria-hidden", "true") } return n } // node.go: Hidden sets the HTML hidden attribute on an element node. // Example: Hidden(El("section"), true). // Hidden is a standard HTML visibility flag and omits the attribute when false. func Hidden(n Node, hidden bool) Node { if hidden { return Attr(n, "hidden", "hidden") } return n } // node.go: Disabled sets the HTML disabled attribute on an element node. // Example: Disabled(El("button"), true). // Disabled follows standard HTML boolean attribute semantics and omits the // attribute when false. func Disabled(n Node, disabled bool) Node { if disabled { return Attr(n, "disabled", "disabled") } return n } // node.go: Checked sets the HTML checked attribute on an element node. // Example: Checked(El("input"), true). // Checked follows standard HTML boolean attribute semantics and omits the // attribute when false. func Checked(n Node, checked bool) Node { if checked { return Attr(n, "checked", "checked") } return n } // node.go: Required sets the HTML required attribute on an element node. // Example: Required(El("input"), true). // Required follows standard HTML boolean attribute semantics and omits the // attribute when false. func Required(n Node, required bool) Node { if required { return Attr(n, "required", "required") } return n } // node.go: ReadOnly sets the HTML readonly attribute on an element node. // Example: ReadOnly(El("input"), true). // ReadOnly follows standard HTML boolean attribute semantics and omits the // attribute when false. func ReadOnly(n Node, readonly bool) Node { if readonly { return Attr(n, "readonly", "readonly") } return n } // node.go: Selected sets the HTML selected attribute on an element node. // Example: Selected(El("option"), true). // Selected follows standard HTML boolean attribute semantics and omits the // attribute when false. func Selected(n Node, selected bool) Node { if selected { return Attr(n, "selected", "selected") } return n } // node.go: AriaExpanded sets the aria-expanded attribute on an element node. // Example: AriaExpanded(El("button"), true). func AriaExpanded(n Node, expanded bool) Node { if expanded { return Attr(n, "aria-expanded", "true") } return Attr(n, "aria-expanded", "false") } // node.go: AriaDisabled sets the aria-disabled attribute on an element node. // Example: AriaDisabled(El("button"), true). func AriaDisabled(n Node, disabled bool) Node { if disabled { return Attr(n, "aria-disabled", "true") } return Attr(n, "aria-disabled", "false") } // node.go: AriaModal sets the aria-modal attribute on an element node. // Example: AriaModal(El("dialog"), true). func AriaModal(n Node, modal bool) Node { if modal { return Attr(n, "aria-modal", "true") } return Attr(n, "aria-modal", "false") } // node.go: AriaChecked sets the aria-checked attribute on an element node. // Example: AriaChecked(El("input"), true). func AriaChecked(n Node, checked bool) Node { if checked { return Attr(n, "aria-checked", "true") } return Attr(n, "aria-checked", "false") } // node.go: AriaInvalid sets the aria-invalid attribute on an element node. // Example: AriaInvalid(El("input"), true). func AriaInvalid(n Node, invalid bool) Node { if invalid { return Attr(n, "aria-invalid", "true") } return Attr(n, "aria-invalid", "false") } // node.go: AriaRequired sets the aria-required attribute on an element node. // Example: AriaRequired(El("input"), true). func AriaRequired(n Node, required bool) Node { if required { return Attr(n, "aria-required", "true") } return Attr(n, "aria-required", "false") } // node.go: AriaReadOnly sets the aria-readonly attribute on an element node. // Example: AriaReadOnly(El("input"), true). func AriaReadOnly(n Node, readonly bool) Node { if readonly { return Attr(n, "aria-readonly", "true") } return Attr(n, "aria-readonly", "false") } // node.go: AriaPressed sets the aria-pressed attribute on an element node. // Example: AriaPressed(El("button"), true). func AriaPressed(n Node, pressed bool) Node { if pressed { return Attr(n, "aria-pressed", "true") } return Attr(n, "aria-pressed", "false") } // node.go: AriaSelected sets the aria-selected attribute on an element node. // Example: AriaSelected(El("option"), true). func AriaSelected(n Node, selected bool) Node { if selected { return Attr(n, "aria-selected", "true") } return Attr(n, "aria-selected", "false") } // node.go: TabIndex sets the tabindex attribute on an element node. // Example: TabIndex(El("button"), 0). func TabIndex(n Node, index int) Node { return Attr(n, "tabindex", strconv.Itoa(index)) } // node.go: AutoFocus sets the autofocus attribute on an element node. // Example: AutoFocus(El("input")). func AutoFocus(n Node) Node { return Attr(n, "autofocus", "autofocus") } // node.go: ID sets the id attribute on an element node. // Example: ID(El("section"), "main-content"). func ID(n Node, id string) Node { if value := trimmedNonEmpty(id); value != "" { return Attr(n, "id", value) } return n } // node.go: For sets the for attribute on an element node. // Example: For(El("label"), "email-input"). func For(n Node, target string) Node { if value := trimmedNonEmpty(target); value != "" { return Attr(n, "for", value) } return n } func joinNonEmpty(parts ...string) string { if len(parts) == 0 { return "" } var filtered []string for i := range parts { part := strings.TrimSpace(parts[i]) if part == "" { continue } filtered = append(filtered, part) } if len(filtered) == 0 { return "" } return strings.Join(filtered, " ") } func trimmedNonEmpty(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } return value } func (n *elNode) Render(ctx *Context) string { if n == nil { return "" } return n.renderWithPath(normaliseContext(ctx), "") } func (n *elNode) renderWithPath(ctx *Context, path string) string { if n == nil { return "" } var b strings.Builder b.WriteByte('<') b.WriteString(escapeHTML(n.tag)) writeSortedAttrs(&b, n.attrs, nil) b.WriteByte('>') if voidElements[n.tag] { return b.String() } for i := range len(n.children) { b.WriteString(renderNodeWithPath(n.children[i], ctx, path)) } b.WriteString("') return b.String() } func (n *elNode) cloneNode() Node { if n == nil { return (*elNode)(nil) } clone := *n if len(n.children) > 0 { clone.children = make([]Node, len(n.children)) for i := range n.children { clone.children[i] = cloneNode(n.children[i]) } } if n.attrs != nil { clone.attrs = maps.Clone(n.attrs) } return &clone } // --- escapeHTML --- // escapeHTML escapes a string for safe inclusion in HTML text content. func escapeHTML(s string) string { return html.EscapeString(s) } // --- textNode --- type textNode struct { key string args []any } // node.go: Text creates a node that renders through the go-i18n grammar pipeline. // Example: Text("page.title") renders translated text and escapes it for HTML. // 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 { if n == nil { return "" } ctx = normaliseContext(ctx) return escapeHTML(translateText(ctx, n.key, n.args...)) } // --- ifNode --- type ifNode struct { cond func(*Context) bool node Node } // node.go: If renders child only when condition is true. // Example: If(func(*Context) bool { return true }, Raw("shown")). func If(cond func(*Context) bool, node Node) Node { return &ifNode{cond: cond, node: node} } func (n *ifNode) Render(ctx *Context) string { if n == nil || n.cond == nil || n.node == nil { return "" } ctx = normaliseContext(ctx) if n.cond(ctx) { return renderNodeWithPath(n.node, ctx, "") } return "" } func (n *ifNode) cloneNode() Node { if n == nil { return (*ifNode)(nil) } clone := *n clone.node = cloneNode(n.node) return &clone } // --- unlessNode --- type unlessNode struct { cond func(*Context) bool node Node } // node.go: Unless renders child only when condition is false. // Example: Unless(func(*Context) bool { return true }, Raw("hidden")). func Unless(cond func(*Context) bool, node Node) Node { return &unlessNode{cond: cond, node: node} } func (n *unlessNode) Render(ctx *Context) string { if n == nil || n.cond == nil || n.node == nil { return "" } ctx = normaliseContext(ctx) if !n.cond(ctx) { return renderNodeWithPath(n.node, ctx, "") } return "" } func (n *unlessNode) cloneNode() Node { if n == nil { return (*unlessNode)(nil) } clone := *n clone.node = cloneNode(n.node) return &clone } // --- entitledNode --- type entitledNode struct { feature string node Node } // node.go: Entitled renders child only when entitlement is granted. // Example: Entitled("premium", Raw("paid feature")). // Content is 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 n == nil || n.node == nil { return "" } ctx = normaliseContext(ctx) if ctx.Entitlements == nil || !ctx.Entitlements(n.feature) { return "" } return renderNodeWithPath(n.node, ctx, "") } func (n *entitledNode) cloneNode() Node { if n == nil { return (*entitledNode)(nil) } clone := *n clone.node = cloneNode(n.node) return &clone } // --- switchNode --- type switchNode struct { selector func(*Context) string cases map[string]Node } // node.go: Switch renders based on runtime selector value. // Example: Switch(selector, map[string]Node{"desktop": Raw("wide")}). func Switch(selector func(*Context) string, cases map[string]Node) Node { return &switchNode{selector: selector, cases: cases} } func (n *switchNode) Render(ctx *Context) string { if n == nil || n.selector == nil || n.cases == nil { return "" } ctx = normaliseContext(ctx) key := n.selector(ctx) if node, ok := n.cases[key]; ok { return renderNodeWithPath(node, ctx, "") } return "" } func (n *switchNode) cloneNode() Node { if n == nil { return (*switchNode)(nil) } clone := *n if n.cases != nil { clone.cases = make(map[string]Node, len(n.cases)) for caseKey, caseNode := range n.cases { clone.cases[caseKey] = cloneNode(caseNode) } } return &clone } // --- eachNode --- type eachNode[T any] struct { items iter.Seq[T] fn func(T) Node } // node.go: Each iterates items and renders each via fn. // Example: Each(items, func(item Item) Node { return El("li", Text(item.Name)) }). func Each[T any](items []T, fn func(T) Node) Node { return EachSeq(slices.Values(items), fn) } // node.go: EachSeq iterates an iter.Seq and renders each via fn. // Example: EachSeq(slices.Values(items), renderItem). func EachSeq[T any](items iter.Seq[T], fn func(T) Node) Node { return &eachNode[T]{items: items, fn: fn} } func (n *eachNode[T]) Render(ctx *Context) string { if n == nil || n.items == nil || n.fn == nil { return "" } ctx = normaliseContext(ctx) var b strings.Builder for item := range n.items { b.WriteString(renderNodeWithPath(n.fn(item), ctx, "")) } return b.String() } func (n *eachNode[T]) renderWithPath(ctx *Context, path string) string { if n == nil || n.items == nil || n.fn == nil { return "" } var b strings.Builder for item := range n.items { b.WriteString(renderNodeWithPath(n.fn(item), ctx, path)) } return b.String() } func (n *eachNode[T]) setAttr(key, value string) { if n == nil || n.fn == nil { return } prev := n.fn n.fn = func(item T) Node { return Attr(cloneNode(prev(item)), key, value) } } func (n *eachNode[T]) cloneNode() Node { if n == nil { return (*eachNode[T])(nil) } clone := *n return &clone }