go / html templates

I ported my ruby / html templates to Go as hml, a standalone package with no dependencies outside the standard library.

.hml files are source and runtime input. There is no transpiler, no generated code, no build step.

Why a tree

Go templates are string-based. text/template and html/template interpolate values into raw markup with {{ }}, so a forgotten closing tag or a misplaced {{end}} is expressible. hml is structure-aware. Indentation maps to HTML nesting, the parser builds a tree, and the renderer emits matched open and close pairs. <div><p></div></p> cannot be written.

The argument is Hugo Landau's: AST-based HTML generation prevents a class of bugs that string concatenation invents.

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. If it parses, it's in the subset.

Content never sits on a tag's line in the source. Inline content is a parse error, so nesting is always indentation.

- else if is one control rather than an - else holding a nested - if, so a chain reads at one indentation level:

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

Loops spell the binding rather than borrowing Ruby's block form:

%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)

Parse once at startup. A syntax error, an unknown transform, or a condition the parser can settle as non-bool fails there, before the process serves a request.

Checking locals

A parsed template reports what it reads, so an app checks its locals once at startup instead of one page at a time in production:

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 ships zero built-ins. Rich text renders through app-registered transforms, invoked as = name(field). Each sanitizes its own output, which the renderer then emits 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's argument must be exactly one field access. No literals, no interpolation, no nesting, so a template cannot assemble content to hand it. An unregistered name is a parse error. The parser stays the linter.

Conditions are bools

- if, - else if, and ! require a bool. Anything else names the view, the line, and the type, at parse time when the expression settles it and at render otherwise.

There is no implicit truthiness to fall back on, because presence is not a property the engine can read off an untyped value. "" and 0 are present, a nil pointer is not, and which of those a field is depends on how the handler built it. Go knows the type, so the decision belongs there, as a comparison the template states or as a bool the handler computed:

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

&& and || keep truthiness, because they are also the default-value idiom: a || b yields a when a is truthy, else b, so title || "Untitled" works in an output or an attribute. The two rules meet in a condition. hasX || hasY is fine because the operand returned is a bool. title || "Untitled" is a type error there.

Attributes carry their context

An attribute value is constrained by the context the attribute puts it in. A URL attribute takes a relative URL or http, https, mailto, or tel. Anything else renders as #ZgotmplZ, as in html/template, so javascript:alert(1) never reaches the browser.

An on* attribute is a JavaScript context and style is a CSS context. Each takes code the template author wrote, meaning a string literal, or a value the handler marked hml.SafeJS or hml.SafeCSS. A plain dynamic value there is a render error, because no escaping makes untrusted data safe as code.

Authorship travels with the value rather than the position. A literal stays authored through a hash literal, a partial argument, and a ** splat, so markup factored into a partial means what it meant inline. What ends authorship is the template assembling something. An interpolated string is data, however literal its segments, because a value the template did not write is now part of it.

Output shape

Each node is written on its own line, so the output reads as the tree it came from. One exception: a tag whose only child is a run of text holds it on the tag's line, as <a href="/">home</a>.

Whitespace inside an element is not always free. Left on its own line, the newline before the closing tag collapses to a space, which inside an anchor is a space the underline runs through, past the end of the word.

One child only. Two lines of text are two the author separated, and joining them would close a gap that is in the source on purpose. A pre keeps everything, which is why a diff hunk renders inside one.

Editors

The hml repo is also a tree-sitter grammar: grammar.js, an external scanner for indentation, and highlight and injection queries under queries/. nvim-treesitter runs tree-sitter generate at install time, so the parser isn't committed. See vim / tree-sitter grammar.

Embedding templates

I use //go:embed to bundle the .hml files into the Go binary at compile time, so deploys carry their own templates.

Single file

Embed a single 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

Embed 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 path in ReadFile must match the embedded path exactly, including 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

Multiple directives can target the same 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