Dominik Süß

fighting computers since 1999

in

Context aware html/template

Reduce complexity in your Go templates with this one weird trick


Recently, I started working on sharepa again. While I still love the simplicity and ease of use of htmx, the html/template package of the Go standard library is getting increasingly more frustrating as the templates grow more complex.

The issue I want to address in this blog post is passing down per-request context information like information about the authenticated user to templates. For simple templates, it is usally enough to pass this information to the template alongside your data. But once you introduce nested templates or {{ block }} directives, this quickly becomes a hard to maintain mess of inline variables and scope-wrangling.

When faced with this issue again, I had enough and took a look at other Go projects using plain old html/template. Surely I can't be the only one annoyed by this… right?

I found various solutions but the approach I liked the best is the one in use by forgejo. It works by overriding the functions available to the template just before rendering it.

Let's take a look at a sample application and how to use this pattern to clean up its template!

Imagine a small demo application that renders a list of projects and their associated tasks, as well as their current assignee. Innovative - right?

The following template renders the list:

{{ define "overview" }}
  <html>
    <body>
      <h1>Overview</h1>
      <p>Hello {{ .User }}!</p>
      {{ $user := .User }}
      {{ range .Projects }}
      <h2>Project: {{ .Name }}</h2>
      <ul>
        {{ range .TODOs }}
          <li>
            {{ .Content }}
            --
            <span>
              {{- if eq .Assignee $user -}}
                You!
              {{- else -}}
                {{ .Assignee }}
              {{ end }}
            </span>
          </li>
        {{ end }}
      </ul>
      <hr/>
      {{ end }}
    </body>
  </html>
{{ end }}

Saving the .User data in a variable allows it to be used even after the root scope changes within the range expression.

This works as expected and shows a list of all projects, their TODOs and if the current user is assigned, it substitues the value with You!. But what if you want to reuse this list in another template?

Let's try! First, wrap the list in a {{ block }} which defines and immediately executes a new template:

{{ define "overview" }}
  <html>
    <body>
      <h1>Overview</h1>
      <p>Hello {{ .User }}!</p>
      {{ $user := .User }}
      {{ range .Projects }}
        <h2>Project: {{ .Name }}</h2>
        {{ block "todo-list" .TODOs }}
          <ul>
            {{ range . }}
              <li>
                {{ .Content }}
                --
                <span>
                  {{- if eq .Assignee $user -}}
                    You!
                  {{- else -}}
                    {{ .Assignee }}
                  {{ end }}
                </span>
              </li>
            {{ end }}
          </ul>
        {{ end }}
        <hr />
      {{ end }}
    </body>
  </html>
{{ end }}
panic: template: overview.html:16: undefined variable "$user"

And here's the issue! Variables don't get inherited by the newly defined template so the template refuses to compile. Ideally, I'd love to add the user as a parameter to the newly defined template but that's not possible using the default Go template engine.

The solution is to add a function retrieving the current user to the template execution environment. The Go template package allows developers to define custom template functions by providing a template.FuncMap. At first, the documentation scared me away by specifying that this must be called before the template is parsed. But one interesting quirk of this function is that it is legal to override elements!

To implement this, first specify a stub implementation when parsing the templates:

templates, err := template.New("").Funcs(map[string]any{
        "currentUser": func() string { return "" },
}).ParseGlob("*.html")

This is required as otherwise the templates are not syntactically correct.

When rendering the template, this function can then be replaced by a more specific implementation:

	templates.Lookup("overview").Funcs(map[string]any{
		"currentUser": func() string { return req.URL.Query().Get("user") },
	}).Execute(w,data)

Now you can use currentUser instead of $user in the template and it will be replaced with the currently authenticated user.

With this in place, a new template can reuse the todo list like this:

{{ define "project" }}
  <html>
    <body>
      <h1>Project: {{ .Name }}</h1>
      {{ template "todo-list" .TODOs }}
      <hr/>
    </body>
  </html>
{{ end }}

Using this pattern allowed me to greatly reduce the duplication in templates and resulted in cleaner, more maintainable templates. This isn't only useful for authenticaton information but applies to all information that needs to be available everywhere. It is also not limited to returning strings. You can pass entire structs in whatever shape you want to the templates. In my case, I use this approach to define a ctx function that allows me to access all relevant information as a context.Context wherever I need it. Other template functions can also access these overridden runtime functions, although it's a bit more tricky. I use this trick to define a {{ i18n }} function that returns localized strings:

func NewFuncMap(opts ...funcMapOption) template.FuncMap {
	var out template.FuncMap
	out = template.FuncMap{
		"ctx": func() context.Context { return nil },
		"i18n": func(key string, data ...any) template.HTML {
			ctx := out["ctx"].(func() context.Context)()
			s := translate(ctx, key, data...)
			return template.HTML(s)
		},
	}
	for _, o := range opts {
		o.Apply(out)
	}
	return out
}

In the initial parsing step, I pass this function without any options. When rendering pages, I pass a customized ctx function:

func (r *TemplateRenderer) renderWithContext(ctx context.Context, w http.ResponseWriter, name string, rd *RenderData) error {
	tmpl := r.templates.Lookup(name)
	tmpl = tmpl.Funcs(NewFuncMap(
		WithFunc("ctx", func() context.Context {
			return ctx
		},
		)))
	return tmpl.Execute(w, rd)
}

I'm very happy to have found this pattern and can continue developing sharepa with as little external dependencies as possible!