go /

Building view-trees: The basics [Part 2]

.md | permalink | Published on December 04, 2023

We laid our goals in the part 1.


Let's make something renderable

import "html/template"

type RenderFunc func(r Renderable) (template.HTML, error)

type Renderable interface {
    Template() (*template.Template, error)
    TemplateData() (any, error)
}

Let's start with interfaces and type definitions of the concepts:

  1. We want to be able to Render a Renderable struct into HTML, this can fail.
  2. We also want the Renderable thing to give us all of the information it needs so we can render it. This can also fail.

This interface is small, let's see how far we can push this.

First Implementation

func Render(r Renderable) (template.HTML, error) {
    var empty template.HTML

    tpl, err := r.Template()
    if err != nil {
        return empty, err
    }

    data, err := r.TemplateData()
    if err != nil {
        return empty, err
    }

    var bs bytes.Buffer
    if err := tpl.Execute(&bs, data); err != nil {
        return empty, err
    }

    return template.HTML(bs.String()), nil
}

The implementation is small, too, but what good are components if you can't compose them.

Patches

Initial Renderer implementation (source: 90edfc07)
diff --git a/renderer.go b/renderer.go
new file mode 100644
index 0000000..c96d89d
--- /dev/null
+++ b/renderer.go
@@ -0,0 +1,39 @@
+package veun
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+)
+
+type RenderFunc func(r Renderable) (template.HTML, error)
+
+type Renderable interface {
+	Template() (*template.Template, error)
+	TemplateData() (any, error)
+}
+
+func Render(r Renderable) (template.HTML, error) {
+	var empty template.HTML
+
+	tpl, err := r.Template()
+	if err != nil {
+		return empty, err
+	}
+
+	if tpl == nil {
+		return empty, fmt.Errorf("missing template")
+	}
+
+	data, err := r.TemplateData()
+	if err != nil {
+		return empty, err
+	}
+
+	var bs bytes.Buffer
+	if err := tpl.Execute(&bs, data); err != nil {
+		return empty, err
+	}
+
+	return template.HTML(bs.String()), nil
+}

Initial test for rendering PersonView(Person...) (source: 23ca88bb)
diff --git a/go.mod b/go.mod
index 09551ac..ec2216f 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,9 @@
 module github.com/stanistan/veun
 
 go 1.21.4
+
+require (
+	github.com/alecthomas/assert/v2 v2.4.0 // indirect
+	github.com/alecthomas/repr v0.3.0 // indirect
+	github.com/hexops/gotextdiff v1.0.3 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..bb218ed
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/alecthomas/assert/v2 v2.4.0 h1:/ZiZ0NnriAWPYYO+4eOjgzNELrFQLaHNr92mHSHFj9U=
+github.com/alecthomas/assert/v2 v2.4.0/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
+github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8=
+github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
diff --git a/render_person_test.go b/render_person_test.go
new file mode 100644
index 0000000..64a542b
--- /dev/null
+++ b/render_person_test.go
@@ -0,0 +1,41 @@
+package veun_test
+
+import (
+	"html/template"
+	"testing"
+
+	"github.com/alecthomas/assert/v2"
+	"github.com/stanistan/veun"
+)
+
+type Person struct {
+	Name string
+}
+
+type personView struct {
+	Person Person
+}
+
+func PersonView(person Person) *personView {
+	return &personView{Person: person}
+}
+
+var _ veun.Renderable = &personView{}
+
+var personViewTpl = template.Must(
+	template.New("PersonView").Parse(`<div>Hi, {{ .Name }}.</div>`),
+)
+
+func (v *personView) Template() (*template.Template, error) {
+	return personViewTpl, nil
+}
+
+func (v *personView) TemplateData() (any, error) {
+	return v.Person, nil
+}
+
+func TestRenderPerson(t *testing.T) {
+	html, err := veun.Render(PersonView(Person{Name: "Stan"}))
+	assert.NoError(t, err)
+	assert.Equal(t, html, template.HTML(`<div>Hi, Stan.</div>`))
+}

Trees and Subviews

In order to bring the component into our tree composition view library, we need to have Renderable objects have subtrees.

_, _ := Render(ContainerView{
    Heading: ChildView1{},
    Body:    ChildView2{},
})
<div>
    <div class="heading">{{ slot "heading" }}</div>
    <div class="body">{{ slot "body" }}</div>
</div>

The POC

The basic idea is to leverage template.FuncMap to create a slot function.

func (v ContainerView) Template() (*template.Template, error) {
    return template.New("containerView").Funcs(template.FuncMap{
        "slot": func(name string) (template.HTML, error) {
            switch name {
            case "heading":
                return Render(v.Heading)
            case "body":
                return Render(v.Body)
            default:
                return template.HTML(""), nil
            }
        },
    }).Parse(`<div>
    <div class="heading">{{ slot "heading" }}</div>
    <div class="body">{{ slot "body" }}</div>
</div>`)
}
test for v1 of composition (source: 40fb4895)
diff --git a/render_container_test.go b/render_container_test.go
new file mode 100644
index 0000000..951acb3
--- /dev/null
+++ b/render_container_test.go
@@ -0,0 +1,73 @@
+package veun_test
+
+import (
+	"html/template"
+	"testing"
+
+	"github.com/alecthomas/assert/v2"
+
+	. "github.com/stanistan/veun"
+)
+
+type ContainerView struct {
+	Heading Renderable
+	Body    Renderable
+}
+
+func (v ContainerView) Template() (*template.Template, error) {
+	return template.New("containerView").Funcs(template.FuncMap{
+		"slot": func(name string) (template.HTML, error) {
+			switch name {
+			case "heading":
+				return Render(v.Heading)
+			case "body":
+				return Render(v.Body)
+			default:
+				return template.HTML(""), nil
+			}
+		},
+	}).Parse(`<div>
+	<div class="heading">{{ slot "heading" }}</div>
+	<div class="body">{{ slot "body" }}</div>
+</div>`)
+}
+
+func (v ContainerView) TemplateData() (any, error) {
+	return nil, nil
+}
+
+var childViewTemplate = template.Must(
+	template.New("childView").Parse(`{{ . }}`),
+)
+
+type ChildView1 struct{}
+
+func (v ChildView1) Template() (*template.Template, error) {
+	return childViewTemplate, nil
+}
+
+func (v ChildView1) TemplateData() (any, error) {
+	return "HEADING", nil
+}
+
+type ChildView2 struct{}
+
+func (v ChildView2) Template() (*template.Template, error) {
+	return childViewTemplate, nil
+}
+
+func (v ChildView2) TemplateData() (any, error) {
+	return "BODY", nil
+}
+
+func TestRenderContainer(t *testing.T) {
+	html, err := Render(&ContainerView{
+		Heading: ChildView1{},
+		Body:    ChildView2{},
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, template.HTML(`<div>
+	<div class="heading">HEADING</div>
+	<div class="body">BODY</div>
+</div>`), html)
+}

Alternate approach

Alternatively, we can directly inline the fields in the data so our template looks more like this:

<div class="heading">{{ render .Slots.Heading }}</div>

Template compilation

Refactor 1: Making it so that we can do pre-compilation of the template, we can pre-parse it. The immediate issue is that we don't have slots, and the slot func is necessary to compile the tempalte. We can stub that out:

func slotFuncStub(name string) (template.HTML, error) {
    return template.HTML(""), nil
}

func mustParseTemplate(name, contents string) *template.Template {
    return template.Must(
        template.New(name).
        Funcs(template.FuncMap{"slot": slotFuncStub}).
        Parse(contents),
    )
}

var containerViewTpl = mustParseTemplate("containerView", `<div>
    <div class="heading">{{ slot "heading" }}</div>
    <div class="body">{{ slot "body" }}</div>
</div>`)

And then update our Template() function:

containerViewTpl.Funcs(template.FuncMap{
    "slot": func(name string) (template.HTML, error) {
        switch name {
        case "heading":
            return Render(v.Heading)
        case "body":
            return Render(v.Body)
        default:
            return template.HTML(""), nil
        }
    },
})
After refactor (1) (source: 48ddc3cc)
diff --git a/render_container_test.go b/render_container_test.go
index 951acb3..526d1b8 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -9,13 +9,30 @@ import (
 	. "github.com/stanistan/veun"
 )
 
+func slotFuncStub(name string) (template.HTML, error) {
+	return template.HTML(""), nil
+}
+
 type ContainerView struct {
 	Heading Renderable
 	Body    Renderable
 }
 
+func mustParseTemplate(name, contents string) *template.Template {
+	return template.Must(
+		template.New(name).
+			Funcs(template.FuncMap{"slot": slotFuncStub}).
+			Parse(contents),
+	)
+}
+
+var containerViewTpl = mustParseTemplate("containerView", `<div>
+	<div class="heading">{{ slot "heading" }}</div>
+	<div class="body">{{ slot "body" }}</div>
+</div>`)
+
 func (v ContainerView) Template() (*template.Template, error) {
-	return template.New("containerView").Funcs(template.FuncMap{
+	return containerViewTpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
 			switch name {
 			case "heading":
@@ -26,10 +43,7 @@ func (v ContainerView) Template() (*template.Template, error) {
 				return template.HTML(""), nil
 			}
 		},
-	}).Parse(`<div>
-	<div class="heading">{{ slot "heading" }}</div>
-	<div class="body">{{ slot "body" }}</div>
-</div>`)
+	}), nil
 }
 
 func (v ContainerView) TemplateData() (any, error) {

Refactor 2: We can clean up the real slot function so that it is less brittle when views/slots are added and removed.

func tplWithRealSlotFunc(
    tpl *template.Template,
    slots map[string]Renderable,
) *template.Template {
    return tpl.Funcs(template.FuncMap{
        "slot": func(name string) (template.HTML, error) {
            slot, ok := slots[name]
            if ok {
                return Render(slot)
            }
            return template.HTML(""), nil
        },
    })
}

// ... snip ...

return tplWithRealSlotFunc(containerViewTpl, map[string]Renderable{
    "heading": v.Heading,
    "body":    v.Body,
}), nil

At this point we've extracted common implementation details but have kept our main interface the same, which is cool! Our base renderer doesn't need to know much about anything else, doesn't need to know about slots, or funcs, or where templates come from.

after refactor (2) (source: 510eb192)
diff --git a/render_container_test.go b/render_container_test.go
index 526d1b8..c372d45 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -31,18 +31,22 @@ var containerViewTpl = mustParseTemplate("containerView", `<div>
 	<div class="body">{{ slot "body" }}</div>
 </div>`)
 
-func (v ContainerView) Template() (*template.Template, error) {
-	return containerViewTpl.Funcs(template.FuncMap{
+func tplWithRealSlotFunc(tpl *template.Template, slots map[string]Renderable) *template.Template {
+	return tpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
-			switch name {
-			case "heading":
-				return Render(v.Heading)
-			case "body":
-				return Render(v.Body)
-			default:
-				return template.HTML(""), nil
+			slot, ok := slots[name]
+			if ok {
+				return Render(slot)
 			}
+			return template.HTML(""), nil
 		},
+	})
+}
+
+func (v ContainerView) Template() (*template.Template, error) {
+	return tplWithRealSlotFunc(containerViewTpl, map[string]Renderable{
+		"heading": v.Heading,
+		"body":    v.Body,
 	}), nil
 }
 

A View{}

This is generally all well and good, we might want to have something produce a Renderable struct, in fact we might have a struct that is represents a Renderable object, what if we could capture the above pattern in a piece of data as well as behavior?

type View struct {
    Tpl   *template.Template
    Slots map[string]Renderable
    Data  any
}

func (v View) Template() (*template.Template, error) {
    return tplWithRealSlotFunc(v.Tpl, v.Slots), nil
}

func (v View) TemplateData() (any, error) {
    return v.Data, nil
}

The container becomes representable in a different way and it would have the equivalent outcome when rendered.

with initial view.go and test (source: 745d3ae0)
diff --git a/render_container_as_view_test.go b/render_container_as_view_test.go
new file mode 100644
index 0000000..7123b23
--- /dev/null
+++ b/render_container_as_view_test.go
@@ -0,0 +1,26 @@
+package veun_test
+
+import (
+	"html/template"
+	"testing"
+
+	"github.com/alecthomas/assert/v2"
+
+	. "github.com/stanistan/veun"
+)
+
+func TestRenderContainerAsView(t *testing.T) {
+	html, err := Render(View{
+		Tpl: containerViewTpl,
+		Slots: map[string]Renderable{
+			"heading": ChildView1{},
+			"body":    ChildView2{},
+		},
+	})
+	assert.NoError(t, err)
+	assert.Equal(t, template.HTML(`<div>
+	<div class="heading">HEADING</div>
+	<div class="body">BODY</div>
+</div>`), html)
+
+}
diff --git a/render_container_test.go b/render_container_test.go
index c372d45..4bc829c 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -9,24 +9,12 @@ import (
 	. "github.com/stanistan/veun"
 )
 
-func slotFuncStub(name string) (template.HTML, error) {
-	return template.HTML(""), nil
-}
-
 type ContainerView struct {
 	Heading Renderable
 	Body    Renderable
 }
 
-func mustParseTemplate(name, contents string) *template.Template {
-	return template.Must(
-		template.New(name).
-			Funcs(template.FuncMap{"slot": slotFuncStub}).
-			Parse(contents),
-	)
-}
-
-var containerViewTpl = mustParseTemplate("containerView", `<div>
+var containerViewTpl = MustParseTemplate("containerView", `<div>
 	<div class="heading">{{ slot "heading" }}</div>
 	<div class="body">{{ slot "body" }}</div>
 </div>`)
diff --git a/view.go b/view.go
new file mode 100644
index 0000000..ee54469
--- /dev/null
+++ b/view.go
@@ -0,0 +1,41 @@
+package veun
+
+import "html/template"
+
+type View struct {
+	Tpl   *template.Template
+	Slots map[string]Renderable
+	Data  any
+}
+
+func (v View) Template() (*template.Template, error) {
+	return tplWithRealSlotFunc(v.Tpl, v.Slots), nil
+}
+
+func (v View) TemplateData() (any, error) {
+	return v.Data, nil
+}
+
+func tplWithRealSlotFunc(tpl *template.Template, slots map[string]Renderable) *template.Template {
+	return tpl.Funcs(template.FuncMap{
+		"slot": func(name string) (template.HTML, error) {
+			slot, ok := slots[name]
+			if ok {
+				return Render(slot)
+			}
+			return template.HTML(""), nil
+		},
+	})
+}
+
+func slotFuncStub(name string) (template.HTML, error) {
+	return template.HTML(""), nil
+}
+
+func MustParseTemplate(name, contents string) *template.Template {
+	return template.Must(
+		template.New(name).
+			Funcs(template.FuncMap{"slot": slotFuncStub}).
+			Parse(contents),
+	)
+}

View{
  Tpl: containerViewTpl,
  Slots: map[string]Renderable{
    "heading": ChildView1{},
    "body":    ChildView2{},
  }
}

But we still might want to have ContainerView be the thing we can "render", how would we do both?

type AsRenderable interface {
    func Renderable() (Renderable, error)
}

type Slots map[string]AsRenderable
Renderable and AsRenderable (source: 3fb3dcf7)
diff --git a/render_container_as_view_test.go b/render_container_as_view_test.go
index 7123b23..a3a0c37 100644
--- a/render_container_as_view_test.go
+++ b/render_container_as_view_test.go
@@ -12,7 +12,7 @@ import (
 func TestRenderContainerAsView(t *testing.T) {
 	html, err := Render(View{
 		Tpl: containerViewTpl,
-		Slots: map[string]Renderable{
+		Slots: map[string]AsRenderable{
 			"heading": ChildView1{},
 			"body":    ChildView2{},
 		},
diff --git a/render_container_test.go b/render_container_test.go
index 4bc829c..14dd884 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -10,8 +10,8 @@ import (
 )
 
 type ContainerView struct {
-	Heading Renderable
-	Body    Renderable
+	Heading AsRenderable
+	Body    AsRenderable
 }
 
 var containerViewTpl = MustParseTemplate("containerView", `<div>
@@ -19,7 +19,7 @@ var containerViewTpl = MustParseTemplate("containerView", `<div>
 	<div class="body">{{ slot "body" }}</div>
 </div>`)
 
-func tplWithRealSlotFunc(tpl *template.Template, slots map[string]Renderable) *template.Template {
+func tplWithRealSlotFunc(tpl *template.Template, slots map[string]AsRenderable) *template.Template {
 	return tpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
 			slot, ok := slots[name]
@@ -32,7 +32,7 @@ func tplWithRealSlotFunc(tpl *template.Template, slots map[string]Renderable) *t
 }
 
 func (v ContainerView) Template() (*template.Template, error) {
-	return tplWithRealSlotFunc(containerViewTpl, map[string]Renderable{
+	return tplWithRealSlotFunc(containerViewTpl, map[string]AsRenderable{
 		"heading": v.Heading,
 		"body":    v.Body,
 	}), nil
@@ -42,12 +42,20 @@ func (v ContainerView) TemplateData() (any, error) {
 	return nil, nil
 }
 
+func (v ContainerView) Renderable() (Renderable, error) {
+	return v, nil
+}
+
 var childViewTemplate = template.Must(
 	template.New("childView").Parse(`{{ . }}`),
 )
 
 type ChildView1 struct{}
 
+func (v ChildView1) Renderable() (Renderable, error) {
+	return v, nil
+}
+
 func (v ChildView1) Template() (*template.Template, error) {
 	return childViewTemplate, nil
 }
@@ -66,6 +74,10 @@ func (v ChildView2) TemplateData() (any, error) {
 	return "BODY", nil
 }
 
+func (v ChildView2) Renderable() (Renderable, error) {
+	return v, nil
+}
+
 func TestRenderContainer(t *testing.T) {
 	html, err := Render(&ContainerView{
 		Heading: ChildView1{},
diff --git a/render_person_test.go b/render_person_test.go
index 64a542b..80ef587 100644
--- a/render_person_test.go
+++ b/render_person_test.go
@@ -34,6 +34,10 @@ func (v *personView) TemplateData() (any, error) {
 	return v.Person, nil
 }
 
+func (v *personView) Renderable() (veun.Renderable, error) {
+	return v, nil
+}
+
 func TestRenderPerson(t *testing.T) {
 	html, err := veun.Render(PersonView(Person{Name: "Stan"}))
 	assert.NoError(t, err)
diff --git a/renderer.go b/renderer.go
index c96d89d..cb2c5f0 100644
--- a/renderer.go
+++ b/renderer.go
@@ -6,14 +6,25 @@ import (
 	"html/template"
 )
 
-type RenderFunc func(r Renderable) (template.HTML, error)
-
 type Renderable interface {
 	Template() (*template.Template, error)
 	TemplateData() (any, error)
 }
 
-func Render(r Renderable) (template.HTML, error) {
+type AsRenderable interface {
+	Renderable() (Renderable, error)
+}
+
+func Render(r AsRenderable) (template.HTML, error) {
+	rr, err := r.Renderable()
+	if err != nil {
+		return template.HTML(""), err
+	}
+
+	return render(rr)
+}
+
+func render(r Renderable) (template.HTML, error) {
 	var empty template.HTML
 
 	tpl, err := r.Template()
diff --git a/view.go b/view.go
index ee54469..bb33a3d 100644
--- a/view.go
+++ b/view.go
@@ -4,7 +4,7 @@ import "html/template"
 
 type View struct {
 	Tpl   *template.Template
-	Slots map[string]Renderable
+	Slots map[string]AsRenderable
 	Data  any
 }
 
@@ -16,7 +16,11 @@ func (v View) TemplateData() (any, error) {
 	return v.Data, nil
 }
 
-func tplWithRealSlotFunc(tpl *template.Template, slots map[string]Renderable) *template.Template {
+func (v View) Renderable() (Renderable, error) {
+	return v, nil
+}
+
+func tplWithRealSlotFunc(tpl *template.Template, slots map[string]AsRenderable) *template.Template {
 	return tpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
 			slot, ok := slots[name]

And updating the Render function for the first time to take AsRenderable instead gives us our first really big interface change, but it unlocks something, too. A simpler way to build views:

func (v ContainerView) Renderable() (Renderable, error) {
    return View{
        Tpl:   containerViewTpl,
        Slots: Slots{"heading": v.Heading, "body": v.Body},
    ), nil
}
with Slot map[string]AsRenderable (source: 6c572183)
diff --git a/render_container_as_view_test.go b/render_container_as_view_test.go
index a3a0c37..9af1ad0 100644
--- a/render_container_as_view_test.go
+++ b/render_container_as_view_test.go
@@ -9,13 +9,22 @@ import (
 	. "github.com/stanistan/veun"
 )
 
+type ContainerView2 struct {
+	Heading AsRenderable
+	Body    AsRenderable
+}
+
+func (v ContainerView2) Renderable() (Renderable, error) {
+	return View{
+		Tpl:   containerViewTpl,
+		Slots: Slots{"heading": v.Heading, "body": v.Body},
+	}, nil
+}
+
 func TestRenderContainerAsView(t *testing.T) {
-	html, err := Render(View{
-		Tpl: containerViewTpl,
-		Slots: map[string]AsRenderable{
-			"heading": ChildView1{},
-			"body":    ChildView2{},
-		},
+	html, err := Render(ContainerView2{
+		Heading: ChildView1{},
+		Body:    ChildView2{},
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, template.HTML(`<div>
diff --git a/slots.go b/slots.go
new file mode 100644
index 0000000..974792e
--- /dev/null
+++ b/slots.go
@@ -0,0 +1,3 @@
+package veun
+
+type Slots map[string]AsRenderable
diff --git a/view.go b/view.go
index bb33a3d..11b2da6 100644
--- a/view.go
+++ b/view.go
@@ -20,14 +20,19 @@ func (v View) Renderable() (Renderable, error) {
 	return v, nil
 }
 
-func tplWithRealSlotFunc(tpl *template.Template, slots map[string]AsRenderable) *template.Template {
+func tplWithRealSlotFunc(
+	tpl *template.Template,
+	slots map[string]AsRenderable,
+) *template.Template {
 	return tpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
 			slot, ok := slots[name]
 			if ok {
 				return Render(slot)
 			}
-			return template.HTML(""), nil
+
+			var empty template.HTML
+			return empty, nil
 		},
 	})
 }


Next: