fix(html): isolate wrapper attrs from shared nodes
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 20:21:24 +00:00
parent b5d170817c
commit 8900d25cfe
4 changed files with 159 additions and 4 deletions

View file

@ -161,6 +161,28 @@ func (l *Layout) VariantValid() bool {
return l == nil || l.variantErr == nil
}
func (l *Layout) cloneNode() Node {
if l == nil {
return (*Layout)(nil)
}
clone := *l
if l.attrs != nil {
clone.attrs = maps.Clone(l.attrs)
}
if l.slots != nil {
clone.slots = make(map[byte][]Node, len(l.slots))
for slot, children := range l.slots {
clonedChildren := make([]Node, len(children))
for i := range children {
clonedChildren[i] = cloneNode(children[i])
}
clone.slots[slot] = clonedChildren
}
}
return &clone
}
// layout.go: Render produces the semantic HTML for this layout.
// Example: NewLayout("C").C(Raw("body")).Render(NewContext()).
// Only slots present in the variant string are rendered.

96
node.go
View file

@ -15,6 +15,10 @@ type Node interface {
Render(ctx *Context) string
}
type cloneableNode interface {
cloneNode() Node
}
// Compile-time interface checks.
var (
_ Node = (*rawNode)(nil)
@ -160,21 +164,33 @@ func Attr(n Node, key, value string) Node {
case *elNode:
t.attrs[key] = value
case *ifNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *unlessNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *entitledNode:
Attr(t.node, key, value)
t.node = Attr(cloneNode(t.node), key, value)
case *switchNode:
cloned := make(map[string]Node, len(t.cases))
for caseKey, caseNode := range t.cases {
t.cases[caseKey] = Attr(caseNode, key, value)
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 {
@ -472,6 +488,24 @@ func (n *elNode) renderWithPath(ctx *Context, path string) string {
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.
@ -526,6 +560,16 @@ func (n *ifNode) Render(ctx *Context) string {
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 {
@ -551,6 +595,16 @@ func (n *unlessNode) Render(ctx *Context) string {
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 {
@ -578,6 +632,16 @@ func (n *entitledNode) Render(ctx *Context) string {
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 {
@ -604,6 +668,21 @@ func (n *switchNode) Render(ctx *Context) string {
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 {
@ -658,3 +737,12 @@ func (n *eachNode[T]) setAttr(key, value string) {
return Attr(prev(item), key, value)
}
}
func (n *eachNode[T]) cloneNode() Node {
if n == nil {
return (*eachNode[T])(nil)
}
clone := *n
return &clone
}

View file

@ -753,6 +753,28 @@ func TestAttr_ThroughSwitchNode(t *testing.T) {
}
}
func TestAttr_ThroughSwitchNode_IsolatedFromSharedCases(t *testing.T) {
ctx := NewContext()
shared := El("div", Raw("content"))
cases := map[string]Node{
"dark": shared,
"light": shared,
}
first := Switch(func(*Context) string { return "dark" }, cases)
second := Switch(func(*Context) string { return "light" }, cases)
Attr(first, "class", "theme")
if got := first.Render(ctx); got != `<div class="theme">content</div>` {
t.Fatalf("first switch render = %q, want %q", got, `<div class="theme">content</div>`)
}
if got := second.Render(ctx); got != `<div>content</div>` {
t.Fatalf("shared switch cases should not leak attrs, got %q", got)
}
}
func TestAttr_ThroughEachNode(t *testing.T) {
ctx := NewContext()
node := Each([]string{"a", "b"}, func(item string) Node {

View file

@ -176,6 +176,29 @@ func (r *Responsive) Render(ctx *Context) string {
return r.renderWithPath(ctx, "")
}
func (r *Responsive) cloneNode() Node {
if r == nil {
return (*Responsive)(nil)
}
clone := *r
if r.attrs != nil {
clone.attrs = maps.Clone(r.attrs)
}
if r.variants != nil {
clone.variants = make([]responsiveVariant, len(r.variants))
for i := range r.variants {
clone.variants[i] = r.variants[i]
if r.variants[i].layout != nil {
if layout, ok := cloneNode(r.variants[i].layout).(*Layout); ok {
clone.variants[i].layout = layout
}
}
}
}
return &clone
}
func (r *Responsive) renderWithPath(ctx *Context, path string) string {
if r == nil {
return ""