Add `components.JoinAttrs` (#262) This adds `components.JoinAttrs`, a helper to join attribute values with a given name. Example: ```go func myButton(children ...g.Node) g.Node { return Div(JoinAttrs("class", g.Group(children), Class("button"))) } func myPrimaryButton(text string) g.Node { return myButton(Class("primary"), g.Text(text)) } func ExampleJoinAttrs() { danceButton := myPrimaryButton("Dance") _ = danceButton.Render(os.Stdout) // Output: <div class="primary button">Dance</div> } ``` Fixes #258
3 files changed, 145 insertions(+), 1 deletion(-)
M components/components.go → components/components.go
@@ -64,3 +64,82 @@ var b strings.Builder _ = c.Render(&b) return b.String() } + +// JoinAttrs with the given name only on the first level of the given nodes. +// This means that attributes on non-direct descendants are ignored. +// Attribute values are joined by spaces. +// Note that this renders all first-level attributes to check whether they should be processed. +func JoinAttrs(name string, children ...g.Node) g.Node { + var attrValues []string + var result []g.Node + firstAttrIndex := -1 + + // Process all children + for _, child := range children { + // Handle groups explicitly because they may contain attributes + if group, ok := child.(g.Group); ok { + for _, groupChild := range group { + isGivenAttr, attrValue := extractAttrValue(name, groupChild) + if !isGivenAttr || attrValue == "" { + result = append(result, groupChild) + continue + } + + attrValues = append(attrValues, attrValue) + if firstAttrIndex == -1 { + firstAttrIndex = len(result) + result = append(result, nil) + } + } + + continue + } + + // Handle non-group nodes essentially the same way + isGivenAttr, attrValue := extractAttrValue(name, child) + if !isGivenAttr || attrValue == "" { + result = append(result, child) + continue + } + + attrValues = append(attrValues, attrValue) + if firstAttrIndex == -1 { + firstAttrIndex = len(result) + result = append(result, nil) + } + } + + // If no attributes were found, just return the result now + if firstAttrIndex == -1 { + return g.Group(result) + } + + // Insert joined attribute at the position of the first attribute + result[firstAttrIndex] = g.Attr(name, strings.Join(attrValues, " ")) + return g.Group(result) +} + +type nodeTypeDescriber interface { + Type() g.NodeType +} + +func extractAttrValue(name string, n g.Node) (bool, string) { + // Ignore everything that is not an attribute + if n, ok := n.(nodeTypeDescriber); !ok || n.Type() == g.ElementType { + return false, "" + } + + var b strings.Builder + if err := n.Render(&b); err != nil { + return false, "" + } + + rendered := b.String() + if !strings.HasPrefix(rendered, " "+name+`="`) || !strings.HasSuffix(rendered, `"`) { + return false, "" + } + + v := strings.TrimPrefix(rendered, " "+name+`="`) + v = strings.TrimSuffix(v, `"`) + return true, v +}
M components/components_test.go → components/components_test.go
@@ -1,6 +1,8 @@ package components_test import ( + "errors" + "io" "os" "testing"@@ -73,3 +75,63 @@ e := g.El("div", Classes{"party-hat": true, "boring-hat": false}) _ = e.Render(os.Stdout) // Output: <div class="party-hat"></div> } + +func hat(children ...g.Node) g.Node { + return Div(JoinAttrs("class", g.Group(children), Class("hat"))) +} + +func partyHat(children ...g.Node) g.Node { + return hat(ID("party-hat"), Class("party"), g.Group(children)) +} + +type brokenNode struct { + first bool +} + +func (b *brokenNode) Render(io.Writer) error { + if !b.first { + return nil + } + b.first = false + return errors.New("oh no") +} + +func (b *brokenNode) Type() g.NodeType { + return g.AttributeType +} + +func TestJoinAttrs(t *testing.T) { + t.Run("joins classes", func(t *testing.T) { + n := Div(JoinAttrs("class", Class("party"), ID("hey"), Class("hat"))) + assert.Equal(t, `<div class="party hat" id="hey"></div>`, n) + }) + + t.Run("joins classes in groups", func(t *testing.T) { + n := partyHat(Span(ID("party-hat-text"), Class("solid"), Class("gold"), g.Text("Yo."))) + assert.Equal(t, `<div id="party-hat" class="party hat"><span id="party-hat-text" class="solid" class="gold">Yo.</span></div>`, n) + }) + + t.Run("does nothing if attribute not found", func(t *testing.T) { + n := Div(JoinAttrs("style", Class("party"), ID("hey"), Class("hat"))) + assert.Equal(t, `<div class="party" id="hey" class="hat"></div>`, n) + }) + + t.Run("ignores nodes that can't render", func(t *testing.T) { + n := Div(JoinAttrs("class", Class("party"), ID("hey"), &brokenNode{first: true}, Class("hat"))) + assert.Equal(t, `<div class="party hat" id="hey"></div>`, n) + }) +} + +func myButton(children ...g.Node) g.Node { + return Div(JoinAttrs("class", g.Group(children), Class("button"))) +} + +func myPrimaryButton(text string) g.Node { + return myButton(Class("primary"), g.Text(text)) +} + +func ExampleJoinAttrs() { + danceButton := myPrimaryButton("Dance") + _ = danceButton.Render(os.Stdout) + // Output: <div class="primary button">Dance</div> +}
M internal/assert/assert.go → internal/assert/assert.go
@@ -13,7 +13,10 @@ func Equal(t *testing.T, expected string, actual g.Node) { t.Helper() var b strings.Builder - _ = actual.Render(&b) + err := actual.Render(&b) + if err != nil { + t.Fatal("error rendering actual:", err) + } if expected != b.String() { t.Fatalf(`expected "%v" but got "%v"`, expected, b.String()) }