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
- CLI tools that need bundled templates, configs, or assets
- Web servers with static files (HTML, CSS, JS, images)
- Any deployment where fewer moving parts is valuable
When not to use
- Files that change frequently without code changes
- Very large assets (increases binary size and memory usage)
- Content that users should be able to modify at runtime
See CDN for cache-busting embedded assets in production web apps.