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
- CLI tools that bundle templates, configs, or assets
- Web servers with static files
When not to use
- Files that change without a code change
- Large assets, which grow the binary
See CDN for cache-busting embedded assets in production web apps.