1
0

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.
This commit is contained in:
Markus Wüstenberg 2020-11-16 12:38:24 +01:00 committed by GitHub
parent 87d09c3824
commit 794c3b26ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 103 additions and 72 deletions

View File

@ -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`

View File

@ -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, `<div class="hat" />`, e)
assert.Equal(t, `<div class="hat"></div>`, e)
})
t.Run("also works with fmt", func(t *testing.T) {

View File

@ -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(`<div %v />`, name), n)
assert.Equal(t, fmt.Sprintf(`<div %v></div>`, name), n)
})
}
}

View File

@ -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(`<div %v="hat" />`, name), n)
assert.Equal(t, fmt.Sprintf(`<div %v="hat"></div>`, name), n)
})
}
}

View File

@ -20,7 +20,7 @@ func TestHTML5(t *testing.T) {
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 @@ func TestHTML5(t *testing.T) {
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)
})
}

View File

@ -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, `<!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 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, `<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 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, `<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 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, `<textarea name="hat" />`, el.Textarea("hat"))
assert.Equal(t, `<textarea name="hat"></textarea>`, 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, `<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")))
})
}

View File

@ -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"></%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)
})
}
}

View File

@ -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
return
}
if p, ok := c.(Placer); !ok || p.Place() == Outside {
isOutside = true
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)
}
}
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.

View File

@ -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, "<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 @@ 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, `<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 (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("<div />")
assert.Equal(t, "&lt;div /&gt;", e)
e := g.Text("<div>")
assert.Equal(t, "&lt;div&gt;", 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, "&lt;div /&gt;", e)
e := g.Textf("<%v>", "div")
assert.Equal(t, "&lt;div&gt;", 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) {