Repository: maragudk/gomponents
Pull request #39 (2020-10-30 09:31:43)

Render to Writer instead of string
The Render function has been changed to take a `Writer` instead of returning a string. This makes it possible to generate documents without having the whole content in memory.

codecov[bot]: # [Codecov](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=h1) Report
> Merging [#39](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=desc) into [master](https://codecov.io/gh/maragudk/gomponents/commit/92ba5904c1645e6572f5ff1b9d0e0ec629e1afb9?el=desc) will **decrease** coverage by `8.03%`.
> The diff coverage is `75.00%`.

[![Impacted file tree graph](https://codecov.io/gh/maragudk/gomponents/pull/39/graphs/tree.svg?width=650&height=150&src=pr&token=JYN032TYAC)](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##            master      #39      +/-   ##
===========================================
- Coverage   100.00%   91.96%   -8.04%     
===========================================
  Files            8        8              
  Lines          226      249      +23     
===========================================
+ Hits           226      229       +3     
- Misses           0       11      +11     
- Partials         0        9       +9     
```


| [Impacted Files](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [el/elements.go](https://codecov.io/gh/maragudk/gomponents/pull/39/diff?src=pr&el=tree#diff-ZWwvZWxlbWVudHMuZ28=) | `87.50% <50.00%> (-12.50%)` | :arrow_down: |
| [gomponents.go](https://codecov.io/gh/maragudk/gomponents/pull/39/diff?src=pr&el=tree#diff-Z29tcG9uZW50cy5nbw==) | `76.92% <75.00%> (-23.08%)` | :arrow_down: |
| [attr/attributes.go](https://codecov.io/gh/maragudk/gomponents/pull/39/diff?src=pr&el=tree#diff-YXR0ci9hdHRyaWJ1dGVzLmdv) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=footer). Last update [92ba590...766cca9](https://codecov.io/gh/maragudk/gomponents/pull/39?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index b976f42..28f1edf 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ func main() {
 func handler() http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		page := Page("Hi!", r.URL.Path)
-		_ = g.Write(w, page)
+		_ = page.Render(w)
 	}
 }
 
@@ -91,7 +91,7 @@ func main() {
 func handler() http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		page := Page("Hi!", r.URL.Path)
-		_ = g.Write(w, page)
+		_ = page.Render(w)
 	}
 }
 
diff --git a/assert/assert.go b/assert/assert.go
index b2b6002..b18ff65 100644
--- a/assert/assert.go
+++ b/assert/assert.go
@@ -1,6 +1,7 @@
 package assert
 
 import (
+	"strings"
 	"testing"
 
 	g "github.com/maragudk/gomponents"
@@ -8,8 +9,17 @@ import (
 
 // Equal checks for equality between the given expected string and the rendered Node string.
 func Equal(t *testing.T, expected string, actual g.Node) {
-	if expected != actual.Render() {
+	var b strings.Builder
+	_ = actual.Render(&b)
+	if expected != b.String() {
 		t.Errorf("expected `%v` but got `%v`", expected, actual)
 		t.FailNow()
 	}
 }
+
+// Error checks for a non-nil error.
+func Error(t *testing.T, err error) {
+	if err == nil {
+		t.FailNow()
+	}
+}
diff --git a/attr/attributes.go b/attr/attributes.go
index 2b25681..bc157cb 100644
--- a/attr/attributes.go
+++ b/attr/attributes.go
@@ -3,6 +3,7 @@
 package attr
 
 import (
+	"io"
 	"sort"
 	"strings"
 
@@ -14,7 +15,7 @@ import (
 // for which the corresponding map value is true.
 type Classes map[string]bool
 
-func (c Classes) Render() string {
+func (c Classes) Render(w io.Writer) error {
 	var included []string
 	for c, include := range c {
 		if include {
@@ -22,7 +23,7 @@ func (c Classes) Render() string {
 		}
 	}
 	sort.Strings(included)
-	return g.Attr("class", strings.Join(included, " ")).Render()
+	return g.Attr("class", strings.Join(included, " ")).Render(w)
 }
 
 func (c Classes) Place() g.Placement {
@@ -31,5 +32,7 @@ func (c Classes) Place() g.Placement {
 
 // String satisfies fmt.Stringer.
 func (c Classes) String() string {
-	return c.Render()
+	var b strings.Builder
+	_ = c.Render(&b)
+	return b.String()
 }
diff --git a/el/elements.go b/el/elements.go
index 4ca001f..0841ff8 100644
--- a/el/elements.go
+++ b/el/elements.go
@@ -4,7 +4,7 @@ package el
 
 import (
 	"fmt"
-	"strings"
+	"io"
 
 	g "github.com/maragudk/gomponents"
 )
@@ -13,15 +13,13 @@ func A(href string, children ...g.Node) g.NodeFunc {
 	return g.El("a", g.Attr("href", href), g.Group(children))
 }
 
-// Document returns an special kind of Node that prefixes its children with the string "<!doctype html>".
-func Document(children ...g.Node) g.NodeFunc {
-	return func() string {
-		var b strings.Builder
-		b.WriteString("<!doctype html>")
-		for _, c := range children {
-			b.WriteString(c.Render())
+// Document returns an special kind of Node that prefixes its child with the string "<!doctype html>".
+func Document(child g.Node) g.NodeFunc {
+	return func(w io.Writer) error {
+		if _, err := w.Write([]byte("<!doctype html>")); err != nil {
+			return err
 		}
-		return b.String()
+		return child.Render(w)
 	}
 }
 
diff --git a/examples/simple/simple.go b/examples/simple/simple.go
index 6683bc4..3802f43 100644
--- a/examples/simple/simple.go
+++ b/examples/simple/simple.go
@@ -14,22 +14,23 @@ func main() {
 }
 
 func handler(w http.ResponseWriter, r *http.Request) {
-	_ = g.Write(w, page(pageProps{
+	p := page(props{
 		title: r.URL.Path,
 		path:  r.URL.Path,
-	}))
+	})
+	_ = p.Render(w)
 }
 
-type pageProps struct {
+type props struct {
 	title string
 	path  string
 }
 
-func page(props pageProps) g.Node {
+func page(p props) g.Node {
 	return el.Document(
 		el.HTML(attr.Lang("en"),
 			el.Head(
-				el.Title(props.title),
+				el.Title(p.title),
 				el.Style(attr.Type("text/css"),
 					g.Raw(".is-active{font-weight: bold}"),
 					g.Raw("ul.nav { list-style-type: none; margin: 0; padding: 0; overflow: hidden; }"),
@@ -37,10 +38,10 @@ func page(props pageProps) g.Node {
 				),
 			),
 			el.Body(
-				navbar(navbarProps{path: props.path}),
+				navbar(navbarProps{path: p.path}),
 				el.Hr(),
-				el.H1(props.title),
-				el.P(g.Textf("Welcome to the page at %v.", props.path)),
+				el.H1(p.title),
+				el.P(g.Textf("Welcome to the page at %v.", p.path)),
 				el.P(g.Textf("Rendered at %v", time.Now())),
 			),
 		),
diff --git a/gomponents.go b/gomponents.go
index f4b8467..178f0a2 100644
--- a/gomponents.go
+++ b/gomponents.go
@@ -14,9 +14,9 @@ import (
 	"strings"
 )
 
-// Node is a DOM node that can Render itself to a string representation.
+// Node is a DOM node that can Render itself to a io.Writer.
 type Node interface {
-	Render() string
+	Render(w io.Writer) error
 }
 
 // Placer can be implemented to tell Render functions where to place the string representation of a Node
@@ -34,10 +34,10 @@ const (
 )
 
 // NodeFunc is render function that is also a Node.
-type NodeFunc func() string
+type NodeFunc func(io.Writer) error
 
-func (n NodeFunc) Render() string {
-	return n()
+func (n NodeFunc) Render(w io.Writer) error {
+	return n(w)
 }
 
 func (n NodeFunc) Place() Placement {
@@ -46,64 +46,92 @@ func (n NodeFunc) Place() Placement {
 
 // String satisfies fmt.Stringer.
 func (n NodeFunc) String() string {
-	return n.Render()
+	var b strings.Builder
+	_ = n.Render(&b)
+	return b.String()
 }
 
+type nodeType int
+
+const (
+	attrType = nodeType(iota)
+	elementType
+)
+
 // El creates an element DOM Node with a name and child Nodes.
 // Use this if no convenience creator exists.
 func El(name string, children ...Node) NodeFunc {
-	return func() string {
-		var b, inside, outside strings.Builder
+	return func(w2 io.Writer) error {
+		w := &statefulWriter{w: w2}
 
-		b.WriteString("<")
-		b.WriteString(name)
+		w.Write([]byte("<" + name))
 
 		if len(children) == 0 {
-			b.WriteString(" />")
-			return b.String()
+			w.Write([]byte(" />"))
+			return w.err
 		}
 
+		hasOutsideChildren := false
 		for _, c := range children {
-			renderChild(c, &inside, &outside)
+			hasOutsideChildren = renderChild(w, c, attrType) || hasOutsideChildren
 		}
 
-		b.WriteString(inside.String())
+		if !hasOutsideChildren {
+			w.Write([]byte(" />"))
+			return w.err
+		}
+
+		w.Write([]byte(">"))
 
-		if outside.Len() == 0 {
-			b.WriteString(" />")
-			return b.String()
+		for _, c := range children {
+			renderChild(w, c, elementType)
 		}
 
-		b.WriteString(">")
-		b.WriteString(outside.String())
-		b.WriteString("</")
-		b.WriteString(name)
-		b.WriteString(">")
-		return b.String()
+		w.Write([]byte("</" + name + ">"))
+		return w.err
 	}
 }
 
-func renderChild(c Node, inside, outside *strings.Builder) {
-	if c == nil {
-		return
+// renderChild c to the given writer w if the node type is t.
+// Returns whether the child would be written Outside, regardless of whether it is actually written.
+func renderChild(w *statefulWriter, c Node, t nodeType) bool {
+	if w.err != nil || c == nil {
+		return false
 	}
+
+	isOutside := false
 	if g, ok := c.(group); ok {
 		for _, groupC := range g.children {
-			renderChild(groupC, inside, outside)
+			isOutside = renderChild(w, groupC, t) || isOutside
 		}
-		return
+		return isOutside
 	}
-	if p, ok := c.(Placer); ok {
-		switch p.Place() {
-		case Inside:
-			inside.WriteString(c.Render())
-		case Outside:
-			outside.WriteString(c.Render())
-		}
+
+	if p, ok := c.(Placer); !ok || p.Place() == Outside {
+		isOutside = true
+	}
+
+	switch {
+	case t == attrType && !isOutside:
+		w.err = c.Render(w.w)
+	case t == elementType && isOutside:
+		w.err = c.Render(w.w)
+	}
+
+	return isOutside
+}
+
+// statefulWriter only writes if no errors have occured earlier in its lifetime.
+type statefulWriter struct {
+	w   io.Writer
+	err error
+}
+
+func (w *statefulWriter) Write(p []byte) {
+	if w.err != nil {
 		return
 	}
-	// If c doesn't implement Placer, default to outside
-	outside.WriteString(c.Render())
+	_, w.err = w.w.Write(p)
 }
 
 // Attr creates an attr DOM Node.
@@ -127,11 +155,13 @@ type attr struct {
 	value *string
 }
 
-func (a *attr) Render() string {
+func (a *attr) Render(w io.Writer) error {
 	if a.value == nil {
-		return " " + a.name
+		_, err := w.Write([]byte(" " + a.name))
+		return err
 	}
-	return " " + a.name + `="` + *a.value + `"`
+	_, err := w.Write([]byte(" " + a.name + `="` + *a.value + `"`))
+	return err
 }
 
 func (a *attr) Place() Placement {
@@ -140,42 +170,45 @@ func (a *attr) Place() Placement {
 
 // String satisfies fmt.Stringer.
 func (a *attr) String() string {
-	return a.Render()
+	var b strings.Builder
+	_ = a.Render(&b)
+	return b.String()
 }
 
 // Text creates a text DOM Node that Renders the escaped string t.
 func Text(t string) NodeFunc {
-	return func() string {
-		return template.HTMLEscapeString(t)
+	return func(w io.Writer) error {
+		_, err := w.Write([]byte(template.HTMLEscapeString(t)))
+		return err
 	}
 }
 
 // Textf creates a text DOM Node that Renders the interpolated and escaped string t.
 func Textf(format string, a ...interface{}) NodeFunc {
-	return func() string {
-		return template.HTMLEscapeString(fmt.Sprintf(format, a...))
+	return func(w io.Writer) error {
+		_, err := w.Write([]byte(template.HTMLEscapeString(fmt.Sprintf(format, a...))))
+		return err
 	}
 }
 
 // Raw creates a raw Node that just Renders the unescaped string t.
 func Raw(t string) NodeFunc {
-	return func() string {
-		return t
+	return func(w io.Writer) error {
+		_, err := w.Write([]byte(t))
+		return err
 	}
 }
 
-// Write to the given io.Writer, returning any error.
-func Write(w io.Writer, n Node) error {
-	_, err := w.Write([]byte(n.Render()))
-	return err
-}
-
 type group struct {
 	children []Node
 }
 
-func (g group) Render() string {
-	panic("cannot render group")
+func (g group) String() string {
+	panic("cannot render group directly")
+}
+
+func (g group) Render(io.Writer) error {
+	panic("cannot render group directly")
 }
 
 // Group multiple Nodes into one Node. Useful for concatenation of Nodes in variadic functions.


Test patch:
diff --git a/el/elements_test.go b/el/elements_test.go
index 6c73188..e90be9f 100644
--- a/el/elements_test.go
+++ b/el/elements_test.go
@@ -1,6 +1,7 @@
 package el_test
 
 import (
+	"errors"
 	"testing"
 
 	g "github.com/maragudk/gomponents"
@@ -8,10 +9,21 @@ import (
 	"github.com/maragudk/gomponents/el"
 )
 
+type erroringWriter struct{}
+
+func (w *erroringWriter) Write(p []byte) (n int, err error) {
+	return 0, errors.New("don't want to write")
+}
+
 func TestDocument(t *testing.T) {
 	t.Run("returns doctype and children", func(t *testing.T) {
 		assert.Equal(t, `<!doctype html><html />`, el.Document(g.El("html")))
 	})
+
+	t.Run("errors on write error in Render", func(t *testing.T) {
+		err := el.Document(g.El("html")).Render(&erroringWriter{})
+		assert.Error(t, err)
+	})
 }
 
 func TestForm(t *testing.T) {
diff --git a/gomponents_test.go b/gomponents_test.go
index 2efdd7a..3028723 100644
--- a/gomponents_test.go
+++ b/gomponents_test.go
@@ -3,6 +3,7 @@ package gomponents_test
 import (
 	"errors"
 	"fmt"
+	"io"
 	"strings"
 	"testing"
 
@@ -12,7 +13,10 @@ import (
 
 func TestNodeFunc(t *testing.T) {
 	t.Run("implements fmt.Stringer", func(t *testing.T) {
-		fn := g.NodeFunc(func() string { return "hat" })
+		fn := g.NodeFunc(func(w io.Writer) error {
+			_, _ = w.Write([]byte("hat"))
+			return nil
+		})
 		if fn.String() != "hat" {
 			t.FailNow()
 		}
@@ -56,24 +60,29 @@ func BenchmarkAttr(b *testing.B) {
 	b.Run("boolean attributes", func(b *testing.B) {
 		for i := 0; i < b.N; i++ {
 			a := g.Attr("hat")
-			a.Render()
+			_ = a.Render(&strings.Builder{})
 		}
 	})
 
 	b.Run("name-value attributes", func(b *testing.B) {
 		for i := 0; i < b.N; i++ {
 			a := g.Attr("hat", "party")
-			a.Render()
+			_ = a.Render(&strings.Builder{})
 		}
 	})
 }
 
 type outsider struct{}
 
-func (o outsider) Render() string {
+func (o outsider) String() string {
 	return "outsider"
 }
 
+func (o outsider) Render(w io.Writer) error {
+	_, _ = w.Write([]byte("outsider"))
+	return nil
+}
+
 func TestEl(t *testing.T) {
 	t.Run("renders an empty element if no children given", func(t *testing.T) {
 		e := g.El("div")
@@ -101,9 +110,21 @@ func TestEl(t *testing.T) {
 	})
 
 	t.Run("does not fail on nil node", func(t *testing.T) {
-		e := g.El("div", g.El("span"), nil, g.El("span"))
+		e := g.El("div", nil, g.El("span"), nil, g.El("span"))
 		assert.Equal(t, `<div><span /><span /></div>`, e)
 	})
+
+	t.Run("returns render error on cannot write", func(t *testing.T) {
+		e := g.El("div")
+		err := e.Render(&erroringWriter{})
+		assert.Error(t, err)
+	})
+}
+
+type erroringWriter struct{}
+
+func (w *erroringWriter) Write(p []byte) (n int, err error) {
+	return 0, errors.New("no thanks")
 }
 
 func TestText(t *testing.T) {
@@ -127,34 +148,6 @@ func TestRaw(t *testing.T) {
 	})
 }
 
-type erroringWriter struct{}
-
-func (w *erroringWriter) Write(p []byte) (n int, err error) {
-	return 0, errors.New("don't want to write")
-}
-
-func TestWrite(t *testing.T) {
-	t.Run("writes to the writer", func(t *testing.T) {
-		e := g.El("div")
-		var b strings.Builder
-		err := g.Write(&b, e)
-		if err != nil {
-			t.FailNow()
-		}
-		if b.String() != e.Render() {
-			t.FailNow()
-		}
-	})
-
-	t.Run("errors on write error", func(t *testing.T) {
-		e := g.El("div")
-		err := g.Write(&erroringWriter{}, e)
-		if err == nil {
-			t.FailNow()
-		}
-	})
-}
-
 func TestGroup(t *testing.T) {
 	t.Run("groups multiple nodes into one", func(t *testing.T) {
 		children := []g.Node{g.El("div", g.Attr("id", "hat")), g.El("div")}
@@ -164,14 +157,28 @@ func TestGroup(t *testing.T) {
 
 	t.Run("panics on direct render", func(t *testing.T) {
 		e := g.Group(nil)
-		panicced := false
+		panicked := false
+		defer func() {
+			if err := recover(); err != nil {
+				panicked = true
+			}
+		}()
+		_ = e.Render(nil)
+		if !panicked {
+			t.FailNow()
+		}
+	})
+
+	t.Run("panics on direct string", func(t *testing.T) {
+		e := g.Group(nil).(fmt.Stringer)
+		panicked := false
 		defer func() {
 			if err := recover(); err != nil {
-				panicced = true
+				panicked = true
 			}
 		}()
-		e.Render()
-		if !panicced {
+		_ = e.String()
+		if !panicked {
 			t.FailNow()
 		}
 	})