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.
Markus Wüstenberg markus@maragu.dk
Mon, 16 Nov 2020 12:38:24 +0100
9 files changed, 103 insertions(+), 72 deletions(-)
M README.md → README.md
@@ -3,8 +3,8 @@ [](https://godoc.org/github.com/maragudk/gomponents) [](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`
M attr/attributes_test.go → attr/attributes_test.go
@@ -20,7 +20,7 @@ }) t.Run("renders as attribute in an element", func(t *testing.T) { e := g.El("div", attr.Classes{"hat": true}) - assert.Equal(t, `<div class="hat" />`, e) + assert.Equal(t, `<div class="hat"></div>`, e) }) t.Run("also works with fmt", func(t *testing.T) {
M attr/boolean_test.go → attr/boolean_test.go
@@ -26,7 +26,7 @@ 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(`<div %v />`, name), n) + assert.Equal(t, fmt.Sprintf(`<div %v></div>`, name), n) }) } }
M attr/simple_test.go → attr/simple_test.go
@@ -43,7 +43,7 @@ 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(`<div %v="hat" />`, name), n) + assert.Equal(t, fmt.Sprintf(`<div %v="hat"></div>`, name), n) }) } }
M components/documents_test.go → components/documents_test.go
@@ -20,7 +20,7 @@ Head: []g.Node{el.Link(attr.Rel("stylesheet"), attr.Href("/hat.css"))}, Body: []g.Node{el.Div()}, }) - assert.Equal(t, `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Hat</title><meta name="description" content="Love hats." /><link rel="stylesheet" href="/hat.css" /></head><body><div /></body></html>`, e) + assert.Equal(t, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Hat</title><meta name="description" content="Love hats."><link rel="stylesheet" href="/hat.css"></head><body><div></div></body></html>`, e) }) t.Run("returns no language, description, and extra head/body elements if empty", func(t *testing.T) { @@ -28,6 +28,6 @@ e := c.HTML5(c.DocumentProps{ Title: "Hat", }) - assert.Equal(t, `<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Hat</title></head><body /></html>`, e) + assert.Equal(t, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Hat</title></head><body></body></html>`, e) }) }
M el/elements_test.go → el/elements_test.go
@@ -17,7 +17,7 @@ } 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"))) + assert.Equal(t, `<!doctype html><html></html>`, el.Document(g.El("html"))) }) t.Run("errors on write error in Render", func(t *testing.T) { @@ -28,13 +28,13 @@ } func TestForm(t *testing.T) { t.Run("returns a form element with action and method attributes", func(t *testing.T) { - assert.Equal(t, `<form action="/" method="post" />`, el.Form("/", "post")) + assert.Equal(t, `<form action="/" method="post"></form>`, 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, `<input type="text" name="hat" />`, el.Input("text", "hat")) + assert.Equal(t, `<input type="text" name="hat">`, el.Input("text", "hat")) }) } @@ -52,7 +52,7 @@ } func TestProgress(t *testing.T) { t.Run("returns a progress element with attributes value and max", func(t *testing.T) { - assert.Equal(t, `<progress value="5.5" max="10" />`, el.Progress(5.5, 10)) + assert.Equal(t, `<progress value="5.5" max="10"></progress>`, el.Progress(5.5, 10)) }) } @@ -65,7 +65,7 @@ } func TestTextarea(t *testing.T) { t.Run("returns a textarea element with attribute name", func(t *testing.T) { - assert.Equal(t, `<textarea name="hat" />`, el.Textarea("hat")) + assert.Equal(t, `<textarea name="hat"></textarea>`, el.Textarea("hat")) }) } @@ -77,6 +77,6 @@ } func TestImg(t *testing.T) { t.Run("returns an img element with href and alt attributes", func(t *testing.T) { - assert.Equal(t, `<img src="hat.png" alt="hat" id="image" />`, el.Img("hat.png", "hat", g.Attr("id", "image"))) + assert.Equal(t, `<img src="hat.png" alt="hat" id="image">`, el.Img("hat.png", "hat", g.Attr("id", "image"))) }) }
M el/simple_test.go → el/simple_test.go
@@ -12,19 +12,15 @@ 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 @@ "details": el.Details, "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 @@ "object": el.Object, "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 @@ "th": el.Th, "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"></%v>`, 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) }) } }
M gomponents.go → 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. @@ -13,6 +13,10 @@ "html/template" "io" "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 { @@ -51,33 +55,32 @@ _ = n.Render(&b) 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 @@ return w.err } } +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 + return } - 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) + 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.
M gomponents_test.go → gomponents_test.go
@@ -86,22 +86,33 @@ 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, "<div />", e) + assert.Equal(t, "<div></div>", 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, "<hr>", e) + + e = g.El("br") + assert.Equal(t, "<br>", e) + + e = g.El("img") + assert.Equal(t, "<img>", 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, `<div class="hat" />`, e) + assert.Equal(t, `<div class="hat"></div>`, 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, `<div class="hat"><span /></div>`, e) + e := g.El("div", g.Attr("class", "hat"), g.El("br")) + assert.Equal(t, `<div class="hat"><br></div>`, 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, `<div class="hat"><span /></div>`, e) + e := g.El("div", g.El("br"), g.Attr("class", "hat")) + assert.Equal(t, `<div class="hat"><br></div>`, e) }) t.Run("renders outside if node does not implement placer", func(t *testing.T) { @@ -110,8 +121,8 @@ assert.Equal(t, `<div>outsider</div>`, e) }) 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, `<div><span /><span /></div>`, e) + e := g.El("div", nil, g.El("br"), nil, g.El("br")) + assert.Equal(t, `<div><br><br></div>`, e) }) t.Run("returns render error on cannot write", func(t *testing.T) { @@ -129,30 +140,30 @@ } func TestText(t *testing.T) { t.Run("renders escaped text", func(t *testing.T) { - e := g.Text("<div />") - assert.Equal(t, "<div />", e) + e := g.Text("<div>") + 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("<div />") - assert.Equal(t, "<div />", e) + e := g.Raw("<div>") + assert.Equal(t, "<div>", 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, `<div class="foo"><div /><div id="hat" /><div /></div>`, 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, `<div class="foo"><img><br id="hat"><hr></div>`, e) }) t.Run("panics on direct render", func(t *testing.T) {