diff --git a/layout.go b/layout.go index d0fd95c..abeeeba 100644 --- a/layout.go +++ b/layout.go @@ -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. diff --git a/node.go b/node.go index 98862ea..2b4f659 100644 --- a/node.go +++ b/node.go @@ -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 +} diff --git a/node_test.go b/node_test.go index efa28dd..d9d1d5b 100644 --- a/node_test.go +++ b/node_test.go @@ -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 != `