feat(html): recurse attrs through iterator wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 17:20:17 +00:00
parent 264ecc3f84
commit a86c8ef770
3 changed files with 67 additions and 2 deletions

View file

@ -22,7 +22,7 @@ All concrete node types are unexported structs with exported constructor functio
| Constructor | Behaviour |
|-------------|-----------|
| `El(tag, ...Node)` | HTML element with children. Void elements (`br`, `img`, `input`, etc.) never emit a closing tag. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, and `Entitled` wrappers. Returns the node for chaining. |
| `Attr(Node, key, value)` | Sets an attribute on an `El` node. Traverses through `If`, `Unless`, `Entitled`, `Switch`, and iterator wrappers. Returns the node for chaining. |
| `Text(key, ...any)` | Translated text via `go-i18n`. Output is always HTML-escaped. |
| `Raw(content)` | Unescaped trusted content. Explicit escape hatch. |
| `If(cond, Node)` | Renders the child only when the condition function returns true. |

20
node.go
View file

@ -149,7 +149,8 @@ func El(tag string, children ...Node) Node {
}
// Attr sets an attribute on an El node. Returns the node for chaining.
// It recursively traverses through wrappers like If, Unless, and Entitled.
// It recursively traverses through 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:
@ -160,6 +161,12 @@ func Attr(n Node, key, value string) Node {
Attr(t.node, key, value)
case *entitledNode:
Attr(t.node, key, value)
case *switchNode:
for caseKey, caseNode := range t.cases {
t.cases[caseKey] = Attr(caseNode, key, value)
}
case interface{ setAttr(string, string) }:
t.setAttr(key, value)
}
return n
}
@ -415,3 +422,14 @@ func (n *eachNode[T]) renderWithPath(ctx *Context, path string) string {
}
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)
}
}

View file

@ -5,6 +5,7 @@ import (
"testing"
i18n "dappco.re/go/core/i18n"
"slices"
)
func TestRawNode_Render(t *testing.T) {
@ -378,6 +379,52 @@ func TestAttr_ThroughEntitledNode(t *testing.T) {
}
}
func TestAttr_ThroughSwitchNode(t *testing.T) {
ctx := NewContext()
node := Switch(func(*Context) string { return "dark" }, map[string]Node{
"dark": El("div", Raw("content")),
"light": El("div", Raw("other")),
})
Attr(node, "class", "theme")
got := node.Render(ctx)
want := `<div class="theme">content</div>`
if got != want {
t.Errorf("Attr through Switch = %q, want %q", got, want)
}
}
func TestAttr_ThroughEachNode(t *testing.T) {
ctx := NewContext()
node := Each([]string{"a", "b"}, func(item string) Node {
return El("span", Raw(item))
})
Attr(node, "class", "item")
got := node.Render(ctx)
want := `<span class="item">a</span><span class="item">b</span>`
if got != want {
t.Errorf("Attr through Each = %q, want %q", got, want)
}
}
func TestAttr_ThroughEachSeqNode(t *testing.T) {
ctx := NewContext()
node := EachSeq(slices.Values([]string{"a", "b"}), func(item string) Node {
return El("span", Raw(item))
})
Attr(node, "class", "item")
got := node.Render(ctx)
want := `<span class="item">a</span><span class="item">b</span>`
if got != want {
t.Errorf("Attr through EachSeq = %q, want %q", got, want)
}
}
func TestTextNode_WithService(t *testing.T) {
svc, _ := i18n.New()
ctx := NewContextWithService(svc)