go / html templates

I ported my ruby / html templates to Go as hml, a package with no dependencies outside the standard library. The parser reads .hml files at runtime. There is no build step.

Why a tree

html/template interpolates values into raw markup, so a forgotten closing tag is expressible. In hml, indentation maps to nesting, the parser builds a tree, and the renderer emits matched pairs. hml cannot express <div><p></div></p>. Hugo Landau makes the argument for tree-based HTML generation.

The subset

Templates read pre-computed fields from the context and nothing else:

%tag, .class, #id, { key: value }   tags and attributes
= field                            escaped output
= name(field)                      app-registered transform
= helper(a, b)                     allowlisted helper call
- if expr / - else if / - else     conditionals
- for item in items                loops (optional index: for i, item)
= render "name", key: val          partials
:javascript / :css                 filter blocks
-#                                 comments
static text with #{field}          escaped interpolation

Method calls on data, hash access, ternaries, case/when, variable assignment, and raw output (!=) are parse errors.

Inline content on a tag's line is a parse error, so nesting is always indentation.

- else if keeps a chain at one indentation level:

- if co.active
  %span.ok
    Active
- else if co.pending
  %span.wait
    Pending
- else
  %span.off
    Closed

A loop names its binding:

%table
  - for i, row in rows
    %tr
      %td
        = i
      %td
        %a{ href: row.edit_url }
          Edit

The API

import "github.com/croaky/hml"

tmpl, err := hml.Parse(src, "views/companies/index.hml", transforms)
if err != nil {
    log.Fatal(err)
}

out, err := tmpl.Render(locals, partialFn)

I parse at startup. A syntax error, an unknown transform, or a non-bool condition fails there, before the process serves a request.

Checking locals

A parsed template reports what it reads, so an app checks its locals at startup:

tmpl.Names()   // free top-level identifiers the template reads
tmpl.Renders() // partials it renders by literal name

Names answers for one file. A partial inherits its caller's locals, so follow Renders to check a whole page.

Transforms

The engine has no built-in transforms. The app registers each one, and each sanitizes its own output. The renderer emits it unescaped:

transforms := map[string]hml.Transform{
	"markdown": func(s string) string {
		var buf bytes.Buffer
		if err := goldmark.Convert([]byte(s), &buf); err != nil {
			return ""
		}
		return mdPolicy.Sanitize(buf.String())
	},
}

A transform takes exactly one field, so a template cannot assemble its input. An unregistered name is a parse error.

Conditions are bools

- if, - else if, and ! require a bool. Anything else is an error that names the view, the line, and the type.

There is no truthiness. The template states a comparison, or the handler computes a bool:

- if co.website != ""
- if co.score > 0
- if co.has_website

&& and || keep truthiness, so title || "Untitled" works in an output or an attribute. In a condition, the same expression is a type error.

Render coverage

Parse checks a condition's syntax and nothing about its type. The type is the handler's to know, so a non-bool in an - if is a render error. A view that no test renders is a view that nothing type-checks. It drifts until someone copies it as a pattern, or a handler renders it in front of a user. A test that every view parses proves each view is well-formed and nothing more.

The first time I traced the gap, I found two views whose endpoint had been deleted out from under them, and 31 views with a condition and no test. Most of the 31 sat behind a branch no test took: an empty result set, or a partial reached only through a runtime-computed name that no grep for a caller finds.

So I check it. tmpl.HasCondition() names the views that need a render test. The viewcover package and command find the ones that lack it.

Where my loader resolves a view, I call viewcover.Trace(path). With HML_TRACE set, it prints hmltrace: <path> to stdout, once per process. A -v test run then carries one line per view the run reached. The command parses each .hml file under a directory, keeps the ones with a condition, and reports the ones the run did not name:

go get -tool github.com/croaky/hml/cmd/viewcover
HML_TRACE=1 go test -v ./... > run.txt
go tool viewcover -views ui/views -trace run.txt

In CI, one script runs the suite traced and pipes it to the command. One run answers both checks, and there is one way to make a trace.

The trace goes to stdout rather than a file so the run stays cached. Go's test cache records what a test binary printed and replays it on a hit, and it keys on the environment variables the binary read. An unchanged package replays its trace lines without running. A run with HML_TRACE unset is a separate cache entry, so an ordinary run neither traces nor evicts the traced results. A file the renders append to is a side effect the cache does not know about: a hit would write nothing, and the check would report every view as missed. See go / test.

A rendered view is a view something reached, not a view whose every branch was taken. An - if inside a - for over an empty slice counts as covered. Reaching the view is what the check buys.

I keep no allowlist. A view that no test renders gets a test. A view that is unreachable during a refactor gets deleted: nothing renders it, so nothing checks it, and it will be wrong by the time it comes back.

Attribute contexts

A URL attribute takes a relative URL or http, https, mailto, or tel. Anything else renders as #ZgotmplZ, as in html/template.

An on* attribute or style takes a string literal, or a value the handler marked hml.SafeJS or hml.SafeCSS. A plain dynamic value there is a render error. The mark travels with the value, so a partial means what it meant inline. An interpolated string is data.

Output shape

The renderer writes each node on its own line. One exception: a tag whose only child is text holds it on the tag's line, as <a href="/">home</a>. Text on its own line puts a space before the closing tag, and an anchor underlines that space. A pre keeps its whitespace.

Editors

The hml repo also holds a tree-sitter grammar: grammar.js, an external scanner for indentation, and queries under queries/. nvim-treesitter runs tree-sitter generate at install time, so the repo does not commit the parser. See vim / tree-sitter grammar.

Embedding templates

I embed the .hml files in the binary with //go:embed.

Single file

One file as a byte slice:

import _ "embed"

//go:embed views/companies/index.hml
var indexTemplate []byte

Or as a string:

//go:embed views/layout.hml
var layout string

Multiple files

Multiple files into an embed.FS:

import "embed"

//go:embed views/*.hml
var viewsFS embed.FS

func loadTemplate(name string) ([]byte, error) {
    return viewsFS.ReadFile("views/" + name)
}

The ReadFile path includes the directory prefix.

Patterns

Patterns work like filepath.Glob:

//go:embed views/*.hml
var viewsFS embed.FS

//go:embed templates/*.html templates/*.css
var assetsFS embed.FS

Two directives can share a variable:

//go:embed views/*.hml
//go:embed partials/*.hml
var viewsFS embed.FS

HTTP file server

Serve embedded files over HTTP:

import (
    "embed"
    "io/fs"
    "net/http"
)

//go:embed static/*
var staticFS embed.FS

func main() {
    // Strip "static/" prefix so /app.css serves static/app.css
    stripped, _ := fs.Sub(staticFS, "static")
    http.Handle("/", http.FileServer(http.FS(stripped)))
    http.ListenAndServe(":8080", nil)
}

When to use

When not to use

See CDN for cache-busting embedded assets in production web apps.

← All articles