From 794c3b26acbd3931b7973ff7e09a42b0ac414b1c Mon Sep 17 00:00:00 2001 From: Markus Wüstenberg Date: Mon, 16 Nov 2020 12:38:24 +0100 Subject: Render correct HTML5 (#44) Previously, elements of kind void and empty elements generally would be rendered auto-closing (with a final `/` character in the start tag), which is allowed sometimes but arguably wrong. See https://dev.w3.org/html5/spec-LC/syntax.html#end-tags This created problems with for example `textarea` and `script`, which cannot be auto-closing, or the browser renders it wrong. Also clarified in the docs that this library outputs HTML5. Fixes #42.--- README.md | 6 ++-- attr/attributes_test.go | 2 +- attr/boolean_test.go | 2 +- attr/simple_test.go | 2 +- components/documents_test.go | 4 +-- el/elements_test.go | 12 ++++---- el/simple_test.go | 36 ++++++++++++++++-------- gomponents.go | 66 +++++++++++++++++++++++++------------------- gomponents_test.go | 45 ++++++++++++++++++------------ 9 files changed, 103 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 118a1c7..cba1e66 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ [![GoDoc](https://godoc.org/github.com/maragudk/gomponents?status.svg)](https://godoc.org/github.com/maragudk/gomponents) [![codecov](https://codecov.io/gh/maragudk/gomponents/branch/master/graph/badge.svg)](https://codecov.io/gh/maragudk/gomponents) -gomponents are declarative view components in Go, that can render to HTML. -gomponents aims to make it easy to build HTML pages of reusable components, +gomponents are declarative view components in Go, that can render to HTML5. +gomponents aims to make it easy to build HTML5 pages of reusable components, without the use of a template language. Think server-side-rendered React, but without the virtual DOM and diffing. @@ -15,7 +15,7 @@ for background. ## Features -- Write declarative HTML in Go without all the strings, so you get +- Write declarative HTML5 in Go without all the strings, so you get - Type safety - Auto-completion - Nice formatting with `gofmt` diff --git a/attr/attributes_test.go b/attr/attributes_test.go index efb7044..da04a20 100644 --- a/attr/attributes_test.go +++ b/attr/attributes_test.go @@ -20,7 +20,7 @@ func TestClasses(t *testing.T) { t.Run("renders as attribute in an element", func(t *testing.T) { e := g.El("div", attr.Classes{"hat": true}) - assert.Equal(t, `
`, e) + assert.Equal(t, `
`, e) }) t.Run("also works with fmt", func(t *testing.T) { diff --git a/attr/boolean_test.go b/attr/boolean_test.go index 4e6bb37..309b025 100644 --- a/attr/boolean_test.go +++ b/attr/boolean_test.go @@ -26,7 +26,7 @@ func TestBooleanAttributes(t *testing.T) { for name, fn := range cases { t.Run(fmt.Sprintf("should output %v", name), func(t *testing.T) { n := g.El("div", fn()) - assert.Equal(t, fmt.Sprintf(`
`, name), n) + assert.Equal(t, fmt.Sprintf(`
`, name), n) }) } } diff --git a/attr/simple_test.go b/attr/simple_test.go index eb2cfe3..6181322 100644 --- a/attr/simple_test.go +++ b/attr/simple_test.go @@ -43,7 +43,7 @@ func TestSimpleAttributes(t *testing.T) { for name, fn := range cases { t.Run(fmt.Sprintf(`should output %v="hat"`, name), func(t *testing.T) { n := g.El("div", fn("hat")) - assert.Equal(t, fmt.Sprintf(`
`, name), n) + assert.Equal(t, fmt.Sprintf(`
`, name), n) }) } } diff --git a/components/documents_test.go b/components/documents_test.go index 2aabd5a..863ac96 100644 --- a/components/documents_test.go +++ b/components/documents_test.go @@ -20,7 +20,7 @@ func TestHTML5(t *testing.T) { Body: []g.Node{el.Div()}, }) - assert.Equal(t, `Hat
`, e) + assert.Equal(t, `Hat
`, e) }) t.Run("returns no language, description, and extra head/body elements if empty", func(t *testing.T) { @@ -28,6 +28,6 @@ func TestHTML5(t *testing.T) { Title: "Hat", }) - assert.Equal(t, `Hat`, e) + assert.Equal(t, `Hat`, e) }) } diff --git a/el/elements_test.go b/el/elements_test.go index e90be9f..adce6df 100644 --- a/el/elements_test.go +++ b/el/elements_test.go @@ -17,7 +17,7 @@ func (w *erroringWriter) Write(p []byte) (n int, err error) { func TestDocument(t *testing.T) { t.Run("returns doctype and children", func(t *testing.T) { - assert.Equal(t, ``, el.Document(g.El("html"))) + assert.Equal(t, ``, el.Document(g.El("html"))) }) t.Run("errors on write error in Render", func(t *testing.T) { @@ -28,13 +28,13 @@ func TestDocument(t *testing.T) { func TestForm(t *testing.T) { t.Run("returns a form element with action and method attributes", func(t *testing.T) { - assert.Equal(t, `
`, el.Form("/", "post")) + assert.Equal(t, `
`, el.Form("/", "post")) }) } func TestInput(t *testing.T) { t.Run("returns an input element with attributes type and name", func(t *testing.T) { - assert.Equal(t, ``, el.Input("text", "hat")) + assert.Equal(t, ``, el.Input("text", "hat")) }) } @@ -52,7 +52,7 @@ func TestOption(t *testing.T) { func TestProgress(t *testing.T) { t.Run("returns a progress element with attributes value and max", func(t *testing.T) { - assert.Equal(t, ``, el.Progress(5.5, 10)) + assert.Equal(t, ``, el.Progress(5.5, 10)) }) } @@ -65,7 +65,7 @@ func TestSelect(t *testing.T) { func TestTextarea(t *testing.T) { t.Run("returns a textarea element with attribute name", func(t *testing.T) { - assert.Equal(t, ``, el.Textarea("hat")) }) } @@ -77,6 +77,6 @@ func TestA(t *testing.T) { func TestImg(t *testing.T) { t.Run("returns an img element with href and alt attributes", func(t *testing.T) { - assert.Equal(t, `hat`, el.Img("hat.png", "hat", g.Attr("id", "image"))) + assert.Equal(t, `hat`, el.Img("hat.png", "hat", g.Attr("id", "image"))) }) } diff --git a/el/simple_test.go b/el/simple_test.go index d388e1e..f01ba51 100644 --- a/el/simple_test.go +++ b/el/simple_test.go @@ -12,19 +12,15 @@ import ( func TestSimpleElements(t *testing.T) { cases := map[string]func(...g.Node) g.NodeFunc{ "address": el.Address, - "area": el.Area, "article": el.Article, "aside": el.Aside, "audio": el.Audio, - "base": el.Base, "blockquote": el.BlockQuote, "body": el.Body, - "br": el.Br, "button": el.Button, "canvas": el.Canvas, "cite": el.Cite, "code": el.Code, - "col": el.Col, "colgroup": el.ColGroup, "data": el.Data, "datalist": el.DataList, @@ -32,22 +28,18 @@ func TestSimpleElements(t *testing.T) { "dialog": el.Dialog, "div": el.Div, "dl": el.Dl, - "embed": el.Embed, "fieldset": el.FieldSet, "figure": el.Figure, "footer": el.Footer, "head": el.Head, "header": el.Header, "hgroup": el.HGroup, - "hr": el.Hr, "html": el.HTML, "iframe": el.IFrame, "legend": el.Legend, "li": el.Li, - "link": el.Link, "main": el.Main, "menu": el.Menu, - "meta": el.Meta, "meter": el.Meter, "nav": el.Nav, "noscript": el.NoScript, @@ -55,12 +47,10 @@ func TestSimpleElements(t *testing.T) { "ol": el.Ol, "optgroup": el.OptGroup, "p": el.P, - "param": el.Param, "picture": el.Picture, "pre": el.Pre, "script": el.Script, "section": el.Section, - "source": el.Source, "span": el.Span, "style": el.Style, "summary": el.Summary, @@ -72,13 +62,35 @@ func TestSimpleElements(t *testing.T) { "thead": el.THead, "tr": el.Tr, "ul": el.Ul, - "wbr": el.Wbr, } for name, fn := range cases { t.Run(fmt.Sprintf("should output %v", name), func(t *testing.T) { n := fn(g.Attr("id", "hat")) - assert.Equal(t, fmt.Sprintf(`<%v id="hat" />`, name), n) + assert.Equal(t, fmt.Sprintf(`<%v id="hat">`, name, name), n) + }) + } +} + +func TestSimpleVoidKindElements(t *testing.T) { + cases := map[string]func(...g.Node) g.NodeFunc{ + "area": el.Area, + "base": el.Base, + "br": el.Br, + "col": el.Col, + "embed": el.Embed, + "hr": el.Hr, + "link": el.Link, + "meta": el.Meta, + "param": el.Param, + "source": el.Source, + "wbr": el.Wbr, + } + + for name, fn := range cases { + t.Run(fmt.Sprintf("should output %v", name), func(t *testing.T) { + n := fn(g.Attr("id", "hat")) + assert.Equal(t, fmt.Sprintf(`<%v id="hat">`, name), n) }) } } diff --git a/gomponents.go b/gomponents.go index 02e52ca..14df153 100644 --- a/gomponents.go +++ b/gomponents.go @@ -1,4 +1,4 @@ -// Package gomponents provides declarative view components in Go, that can render to HTML. +// Package gomponents provides declarative view components in Go, that can render to HTML5. // The primary interface is a Node, which has a single function Render, which should render // the Node to a string. Furthermore, NodeFunc is a function which implements the Node interface // by calling itself on Render. @@ -14,6 +14,10 @@ import ( "strings" ) +// voidElements don't have end tags and must be treated differently in the rendering. +// See https://dev.w3.org/html5/spec-LC/syntax.html#void-elements +var voidElements = []string{"area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"} + // Node is a DOM node that can Render itself to a io.Writer. type Node interface { Render(w io.Writer) error @@ -51,33 +55,32 @@ func (n NodeFunc) String() string { return b.String() } +// nodeType is for DOM Nodes that are either an element or an attribute. type nodeType int const ( - attrType = nodeType(iota) - elementType + elementType = nodeType(iota) + attributeType ) // El creates an element DOM Node with a name and child Nodes. // Use this if no convenience creator exists. +// See https://dev.w3.org/html5/spec-LC/syntax.html#elements-0 for how elements are rendered. +// No tags are ever omitted from normal tags, even though it's allowed for elements given at +// https://dev.w3.org/html5/spec-LC/syntax.html#optional-tags +// If an element is a void kind, non-attribute nodes are ignored. func El(name string, children ...Node) NodeFunc { return func(w2 io.Writer) error { w := &statefulWriter{w: w2} w.Write([]byte("<" + name)) - if len(children) == 0 { - w.Write([]byte(" />")) - return w.err - } - - hasOutsideChildren := false for _, c := range children { - hasOutsideChildren = renderChild(w, c, attrType) || hasOutsideChildren + renderChild(w, c, attributeType) } - if !hasOutsideChildren { - w.Write([]byte(" />")) + if isVoidKind(name) { + w.Write([]byte(">")) return w.err } @@ -92,33 +95,38 @@ func El(name string, children ...Node) NodeFunc { } } +func isVoidKind(name string) bool { + for _, e := range voidElements { + if name == e { + return true + } + } + return false +} + // 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 { +func renderChild(w *statefulWriter, c Node, t nodeType) { if w.err != nil || c == nil { - return false + return } - isOutside := false if g, ok := c.(group); ok { for _, groupC := range g.children { - isOutside = renderChild(w, groupC, t) || isOutside + renderChild(w, groupC, t) } - return isOutside - } - - if p, ok := c.(Placer); !ok || p.Place() == Outside { - isOutside = true + return } - switch { - case t == attrType && !isOutside: - w.err = c.Render(w.w) - case t == elementType && isOutside: - w.err = c.Render(w.w) + switch t { + case elementType: + if p, ok := c.(Placer); !ok || p.Place() == Outside { + w.err = c.Render(w.w) + } + case attributeType: + if p, ok := c.(Placer); ok && p.Place() == Inside { + w.err = c.Render(w.w) + } } - - return isOutside } // statefulWriter only writes if no errors have occured earlier in its lifetime. diff --git a/gomponents_test.go b/gomponents_test.go index 0c5dc68..26199c3 100644 --- a/gomponents_test.go +++ b/gomponents_test.go @@ -86,22 +86,33 @@ func (o outsider) Render(w io.Writer) error { func TestEl(t *testing.T) { t.Run("renders an empty element if no children given", func(t *testing.T) { e := g.El("div") - assert.Equal(t, "
", e) + assert.Equal(t, "
", e) + }) + + t.Run("renders an empty element without closing tag if it's a void kind element", func(t *testing.T) { + e := g.El("hr") + assert.Equal(t, "
", e) + + e = g.El("br") + assert.Equal(t, "
", e) + + e = g.El("img") + assert.Equal(t, "", e) }) t.Run("renders an empty element if only attributes given as children", func(t *testing.T) { e := g.El("div", g.Attr("class", "hat")) - assert.Equal(t, `
`, e) + assert.Equal(t, `
`, e) }) t.Run("renders an element, attributes, and element children", func(t *testing.T) { - e := g.El("div", g.Attr("class", "hat"), g.El("span")) - assert.Equal(t, `
`, e) + e := g.El("div", g.Attr("class", "hat"), g.El("br")) + assert.Equal(t, `

`, e) }) t.Run("renders attributes at the correct place regardless of placement in parameter list", func(t *testing.T) { - e := g.El("div", g.El("span"), g.Attr("class", "hat")) - assert.Equal(t, `
`, e) + e := g.El("div", g.El("br"), g.Attr("class", "hat")) + assert.Equal(t, `

`, e) }) t.Run("renders outside if node does not implement placer", func(t *testing.T) { @@ -110,8 +121,8 @@ func TestEl(t *testing.T) { }) t.Run("does not fail on nil node", func(t *testing.T) { - e := g.El("div", nil, g.El("span"), nil, g.El("span")) - assert.Equal(t, `
`, e) + e := g.El("div", nil, g.El("br"), nil, g.El("br")) + assert.Equal(t, `


`, e) }) t.Run("returns render error on cannot write", func(t *testing.T) { @@ -129,30 +140,30 @@ func (w *erroringWriter) Write(p []byte) (n int, err error) { func TestText(t *testing.T) { t.Run("renders escaped text", func(t *testing.T) { - e := g.Text("
") - assert.Equal(t, "<div />", e) + e := g.Text("
") + assert.Equal(t, "<div>", e) }) } func TestTextf(t *testing.T) { t.Run("renders interpolated and escaped text", func(t *testing.T) { - e := g.Textf("<%v />", "div") - assert.Equal(t, "<div />", e) + e := g.Textf("<%v>", "div") + assert.Equal(t, "<div>", e) }) } func TestRaw(t *testing.T) { t.Run("renders raw text", func(t *testing.T) { - e := g.Raw("
") - assert.Equal(t, "
", e) + e := g.Raw("
") + assert.Equal(t, "
", e) }) } 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")} - e := g.El("div", g.Attr("class", "foo"), g.El("div"), g.Group(children)) - assert.Equal(t, `
`, e) + children := []g.Node{g.El("br", g.Attr("id", "hat")), g.El("hr")} + e := g.El("div", g.Attr("class", "foo"), g.El("img"), g.Group(children)) + assert.Equal(t, `


`, e) }) t.Run("panics on direct render", func(t *testing.T) { -- cgit 1.4.1