go /

Building view-trees: Updating the base interface [Part 6]

.md | permalink | Published on December 13, 2023

Previously: intro, the basics, error handling, async data fetching, and http.Handler.


We have a thing that kind of works and in building out our interfaces and types we end up with something that is just a bit more complicated than we need, and a bit too specific, or not specific enough.

Our concepts include: Renderables, AsRenderables, Views, ErrorRenderables, RequestRenderables, RequestHandlers. To be fair this isn't a ton, but it has a smell, and it's a lot to type.

I have questions like:

  • Why do we have both an AsRenderable and Renderable?
  • Why do most of our Renderable functions return View?
  • Are these the same?
  • Why do we always need a template in order to render something?

Do we need both interfaces and interface factories (which is what we have and that feels bad)?

Renderable, but like, to HTML

The Renderable interface should probably only be, this gives us the ability to render strings, or have some async calls that just return html, or something else.

type Renderable interface {
    RenderToHTML(ctx context.Context) (template.HTML, error)
}

type RenderableFunc func(context.Context) (template.HTML, error)
RenderToHML() (source: 7130ec04)
diff --git a/render_container_test.go b/render_container_test.go
index aef4e68..1876b12 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -32,15 +32,15 @@ func tplWithRealSlotFunc(ctx context.Context, tpl *template.Template, slots map[
 	})
 }
 
-func (v ContainerView) Template(ctx context.Context) (*template.Template, error) {
-	return tplWithRealSlotFunc(ctx, containerViewTpl, map[string]AsRenderable{
-		"heading": v.Heading,
-		"body":    v.Body,
-	}), nil
-}
-
-func (v ContainerView) TemplateData(_ context.Context) (any, error) {
-	return nil, nil
+func (v ContainerView) RenderToHTML(ctx context.Context) (template.HTML, error) {
+	return RenderToHTML(tplWithRealSlotFunc(
+		ctx,
+		containerViewTpl,
+		map[string]AsRenderable{
+			"heading": v.Heading,
+			"body":    v.Body,
+		},
+	), nil)
 }
 
 func (v ContainerView) Renderable(_ context.Context) (Renderable, error) {
diff --git a/renderable.go b/renderable.go
index 27759f6..aa0bc86 100644
--- a/renderable.go
+++ b/renderable.go
@@ -5,14 +5,10 @@ import (
 	"html/template"
 )
 
-// Renderable represents any struct that can be rendered
-// in the Render function.
+// Renderable represents anything that can be rendered
+// to HTML.
 type Renderable interface {
-	// Template provides the template object / parsed and compiled,
-	// that Render will execute given a context.
-	Template(ctx context.Context) (*template.Template, error)
-	// TemplateData provides the data to the template given a context.
-	TemplateData(ctx context.Context) (any, error)
+	RenderToHTML(ctx context.Context) (template.HTML, error)
 }
 
 type AsRenderable interface {
diff --git a/renderer.go b/renderer.go
index 1ff48e3..e3a5204 100644
--- a/renderer.go
+++ b/renderer.go
@@ -17,7 +17,7 @@ func Render(ctx context.Context, r AsRenderable) (template.HTML, error) {
 		return handleRenderError(ctx, err, r)
 	}
 
-	out, err := render(ctx, renderable)
+	out, err := renderable.RenderToHTML(ctx)
 	if err != nil {
 		return handleRenderError(ctx, err, r)
 	}
@@ -25,23 +25,13 @@ func Render(ctx context.Context, r AsRenderable) (template.HTML, error) {
 	return out, nil
 }
 
-func render(ctx context.Context, r Renderable) (template.HTML, error) {
+func RenderToHTML(tpl *template.Template, data any) (template.HTML, error) {
 	var empty template.HTML
 
-	tpl, err := r.Template(ctx)
-	if err != nil {
-		return empty, fmt.Errorf("Template(): %w", err)
-	}
-
 	if tpl == nil {
 		return empty, fmt.Errorf("missing template")
 	}
 
-	data, err := r.TemplateData(ctx)
-	if err != nil {
-		return empty, fmt.Errorf("TemplateData(): %w", err)
-	}
-
 	var bs bytes.Buffer
 	if err := tpl.Execute(&bs, data); err != nil {
 		return empty, fmt.Errorf("tpl.Execute(): %w", err)
diff --git a/view.go b/view.go
index 99578bf..b39ef50 100644
--- a/view.go
+++ b/view.go
@@ -2,7 +2,6 @@ package veun
 
 import (
 	"context"
-	"fmt"
 	"html/template"
 	"io/fs"
 )
@@ -13,16 +12,13 @@ type View struct {
 	Data  any
 }
 
-func (v View) Template(ctx context.Context) (*template.Template, error) {
-	if v.Tpl == nil {
-		return nil, fmt.Errorf("template missing")
+func (v View) RenderToHTML(ctx context.Context) (template.HTML, error) {
+	tpl := v.Tpl
+	if v.Tpl != nil {
+		tpl = v.Slots.addToTemplate(ctx, v.Tpl)
 	}
 
-	return v.Slots.addToTemplate(ctx, v.Tpl), nil
-}
-
-func (v View) TemplateData(_ context.Context) (any, error) {
-	return v.Data, nil
+	return RenderToHTML(tpl, v.Data)
 }
 
 func (v View) Renderable(_ context.Context) (Renderable, error) {

Leaving the template bits to be an implementation detail of the specific kind of Renderable.

type TemplateRenderable struct {
    Template *template.Template
    Data     any
}

func (t TemplateRenderable) RenderToHTML(ctx context.Context) (template.HTML, error) {
    // the content of our `render` function
}

Where a View can defer to be TemplateRenderable but with Slots.

Lists

Removing the need for a template also gives us the ability to concatenate, and potentially make some views cheaper to construct.

type Views []AsRenderable

func (vs Views) Renderable(_ context.Context) (Renderable, error) { return vs, nil }

func (vs Views) RenderToHTML( /*........

<div />

Or maybe you wanted to do something like:

func Div(contents AsRenderable) AsRenderable {
    return RenderableFunc(func(ctx context.Context) (template.HTML, error) {
        inner, out := Render(ctx, r)
        if err != nil {
            return inner, err
        }

        return template.HTML("<div>") + inner + template.HTML("</div>"), nil
    })
}

But this doesn't really seem that great at all, and feels like we're leaking our implementation. We have the flexibility to do this, but we're losing our delcarative composability.

We can:

  1. make a Raw type that is a string wrapper, and
  2. reuse Views to elide the inner call to Render
type Raw string

func (r Raw) RenderToHTML(_ context.Context) (template.HTML, error) {
    return template.HTML(r), nil
}

func Div(contents AsRenderable) AsRenderable {
    return RenderableFunc(func(_ context.Context) Renderable {
        return Views{
            Raw("<div>"), contents, Raw("</div>"),
        }
    })
}
w/ common_views (Raw & Views) (source: ef34bc30)
diff --git a/common_views.go b/common_views.go
new file mode 100644
index 0000000..dd96434
--- /dev/null
+++ b/common_views.go
@@ -0,0 +1,61 @@
+package veun
+
+import (
+	"context"
+	"html/template"
+)
+
+type Raw string
+
+func (r Raw) Renderable(_ context.Context) (Renderable, error) { return r, nil }
+
+func (r Raw) RenderToHTML(_ context.Context) (template.HTML, error) {
+	return template.HTML(r), nil
+}
+
+type Views []AsRenderable
+
+func (vs Views) Renderable(ctx context.Context) (Renderable, error) {
+	return vs, nil
+}
+
+func (vs Views) RenderToHTML(ctx context.Context) (template.HTML, error) {
+	var out template.HTML
+
+	for _, v := range vs {
+		r, err := v.Renderable(ctx)
+		if err != nil {
+			return template.HTML(""), err
+		}
+
+		html, err := r.RenderToHTML(ctx)
+		if err != nil {
+			return template.HTML(""), err
+		}
+
+		out += html
+	}
+
+	return out, nil
+}
+
+type RViews []Renderable
+
+func (vs RViews) Renderable(_ context.Context) (Renderable, error) {
+	return vs, nil
+}
+
+func (vs RViews) RenderToHTML(ctx context.Context) (template.HTML, error) {
+	var out template.HTML
+
+	for _, r := range vs {
+		html, err := r.RenderToHTML(ctx)
+		if err != nil {
+			return template.HTML(""), err
+		}
+
+		out += html
+	}
+
+	return out, nil
+}
diff --git a/common_views_test.go b/common_views_test.go
new file mode 100644
index 0000000..ef724f4
--- /dev/null
+++ b/common_views_test.go
@@ -0,0 +1,29 @@
+package veun_test
+
+import (
+	"context"
+	"html/template"
+	"testing"
+
+	"github.com/alecthomas/assert/v2"
+
+	. "github.com/stanistan/veun"
+)
+
+func Div(contents AsRenderable) AsRenderable {
+	return Views{
+		Raw("<div>"), contents, Raw("</div>"),
+	}
+}
+
+func TestCommonViews(t *testing.T) {
+	html, err := Render(context.Background(), Div(ChildView1{}))
+	assert.NoError(t, err)
+	assert.Equal(t, template.HTML("<div>HEADING</div>"), html)
+}
+
+func BenchmarkCommonViews(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		_, _ = Render(context.Background(), Div(ChildView1{}))
+	}
+}
diff --git a/slots.go b/slots.go
index 8a04e81..bb69032 100644
--- a/slots.go
+++ b/slots.go
@@ -20,5 +20,9 @@ func (s Slots) renderSlot(ctx context.Context) func(string) (template.HTML, erro
 }
 
 func (s Slots) addToTemplate(ctx context.Context, t *template.Template) *template.Template {
+	if t == nil {
+		return nil
+	}
+
 	return t.Funcs(template.FuncMap{"slot": s.renderSlot(ctx)})
 }

And now since we're no longer doing any render call, we can make it even clearer:

func Div(contents AsRenderable) AsRenderable {
    return Views{
        Raw("<div>"), contents, Raw("</div>"), // NICE :boom:
    }
}
w/ TemplateRenderable (source: f427fa0e)
diff --git a/render_container_test.go b/render_container_test.go
index 1876b12..6be78c3 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -33,14 +33,12 @@ func tplWithRealSlotFunc(ctx context.Context, tpl *template.Template, slots map[
 }
 
 func (v ContainerView) RenderToHTML(ctx context.Context) (template.HTML, error) {
-	return RenderToHTML(tplWithRealSlotFunc(
-		ctx,
-		containerViewTpl,
-		map[string]AsRenderable{
+	return TemplateRenderable{
+		Tpl: tplWithRealSlotFunc(ctx, containerViewTpl, map[string]AsRenderable{
 			"heading": v.Heading,
 			"body":    v.Body,
-		},
-	), nil)
+		}),
+	}.RenderToHTML(ctx)
 }
 
 func (v ContainerView) Renderable(_ context.Context) (Renderable, error) {
diff --git a/renderer.go b/renderer.go
index e3a5204..4fab94b 100644
--- a/renderer.go
+++ b/renderer.go
@@ -7,33 +7,55 @@ import (
 	"html/template"
 )
 
-func Render(ctx context.Context, r AsRenderable) (template.HTML, error) {
+func RenderToHTML(ctx context.Context, r Renderable, errHandler any) (template.HTML, error) {
+	var empty template.HTML
+
 	if r == nil {
-		return template.HTML(""), nil
+		return empty, nil
+	}
+
+	out, err := r.RenderToHTML(ctx)
+	if err != nil {
+		return handleRenderError(ctx, err, errHandler)
+	}
+
+	return out, nil
+}
+
+func Render(ctx context.Context, v AsRenderable) (template.HTML, error) {
+	var empty template.HTML
+
+	if v == nil {
+		return empty, nil
 	}
 
-	renderable, err := r.Renderable(ctx)
+	r, err := v.Renderable(ctx)
 	if err != nil {
-		return handleRenderError(ctx, err, r)
+		return handleRenderError(ctx, err, v)
 	}
 
-	out, err := renderable.RenderToHTML(ctx)
+	out, err := RenderToHTML(ctx, r, v)
 	if err != nil {
-		return handleRenderError(ctx, err, r)
+		return empty, err
 	}
 
 	return out, nil
 }
 
-func RenderToHTML(tpl *template.Template, data any) (template.HTML, error) {
+type TemplateRenderable struct {
+	Tpl  *template.Template
+	Data any
+}
+
+func (v TemplateRenderable) RenderToHTML(_ context.Context) (template.HTML, error) {
 	var empty template.HTML
 
-	if tpl == nil {
+	if v.Tpl == nil {
 		return empty, fmt.Errorf("missing template")
 	}
 
 	var bs bytes.Buffer
-	if err := tpl.Execute(&bs, data); err != nil {
+	if err := v.Tpl.Execute(&bs, v.Data); err != nil {
 		return empty, fmt.Errorf("tpl.Execute(): %w", err)
 	}
 
diff --git a/view.go b/view.go
index b39ef50..241cd29 100644
--- a/view.go
+++ b/view.go
@@ -13,15 +13,13 @@ type View struct {
 }
 
 func (v View) RenderToHTML(ctx context.Context) (template.HTML, error) {
-	tpl := v.Tpl
-	if v.Tpl != nil {
-		tpl = v.Slots.addToTemplate(ctx, v.Tpl)
-	}
-
-	return RenderToHTML(tpl, v.Data)
+	return TemplateRenderable{
+		Tpl:  v.Slots.addToTemplate(ctx, v.Tpl),
+		Data: v.Data,
+	}.RenderToHTML(ctx)
 }
 
-func (v View) Renderable(_ context.Context) (Renderable, error) {
+func (v View) Renderable(ctx context.Context) (Renderable, error) {
 	return v, nil
 }
 

Errors

I'm not going to get to revisiting errors here just yet.


Next: