diff options
author | Markus Wüstenberg | 2020-11-02 10:03:05 +0100 |
---|---|---|
committer | GitHub | 2020-11-02 10:03:05 +0100 |
commit | 6c8f0c235287edf7252fe239d4c9beb258c6ff01 (patch) | |
tree | ae1245a8e1491f5f369e8d9ef2517148d9774130 | |
parent | 92ba5904c1645e6572f5ff1b9d0e0ec629e1afb9 (diff) | |
download | gomponents-6c8f0c235287edf7252fe239d4c9beb258c6ff01.tar.lz gomponents-6c8f0c235287edf7252fe239d4c9beb258c6ff01.tar.zst gomponents-6c8f0c235287edf7252fe239d4c9beb258c6ff01.zip |
Render to Writer instead of string (#39)
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. This also removes the `gomponents.Write` function, which is now redundant. Furthermore, the `el.Document` function has been changed to only take one child, as multiple children never make sense for it. (It's not even a child, more a sibling.)
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | assert/assert.go | 12 | ||||
-rw-r--r-- | attr/attributes.go | 9 | ||||
-rw-r--r-- | el/elements.go | 16 | ||||
-rw-r--r-- | el/elements_test.go | 12 | ||||
-rw-r--r-- | examples/simple/simple.go | 17 | ||||
-rw-r--r-- | gomponents.go | 143 | ||||
-rw-r--r-- | gomponents_test.go | 81 |
8 files changed, 179 insertions, 115 deletions
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/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/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. 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() } }) |