846 lines
21 KiB
Go
846 lines
21 KiB
Go
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)
|
|
}
|
|
|
|
// --- rawNode ---
|
|
|
|
type rawNode struct {
|
|
content string
|
|
}
|
|
|
|
// node.go: Raw creates a node that renders without escaping.
|
|
// Example: Raw("<strong>trusted</strong>") 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: 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: 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: 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))
|
|
|
|
// Sort attribute keys for deterministic output.
|
|
keys := slices.Collect(maps.Keys(n.attrs))
|
|
slices.Sort(keys)
|
|
for _, key := range keys {
|
|
b.WriteByte(' ')
|
|
b.WriteString(escapeHTML(key))
|
|
b.WriteString(`="`)
|
|
b.WriteString(escapeAttr(n.attrs[key]))
|
|
b.WriteByte('"')
|
|
}
|
|
|
|
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("</")
|
|
b.WriteString(escapeHTML(n.tag))
|
|
b.WriteByte('>')
|
|
|
|
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(prev(item), key, value)
|
|
}
|
|
}
|
|
|
|
func (n *eachNode[T]) cloneNode() Node {
|
|
if n == nil {
|
|
return (*eachNode[T])(nil)
|
|
}
|
|
|
|
clone := *n
|
|
return &clone
|
|
}
|