go / html templates
I ported my ruby / html templates to Go as the haml
package. It allows Go web routes and background jobs (eds-jobs) to render the
same .haml files as the Ruby web application.
Both renderers accept the same "dumb Haml" subset and produce the same HTML for the same inputs.
Why Haml
Haml is a Ruby-world template language, so seeing it in Go is a surprise.
Go templates are string-based: text/template and html/template
interpolate values into raw markup with {{ }}. Haml is structure-aware
instead. Indentation maps to HTML nesting, so the parser builds an AST
rather than concatenating strings. Tag open/close pairings are guaranteed
by the tree, and untrusted data can't be spliced into raw HTML the way
string interpolation invites.
Haml also lets both stacks share templates. The Go web routes and
background jobs render the same .haml files as the Ruby app, so there is
one set of views, not two.
A stricter subset
Developers who know Haml will find this subset stricter than the haml
gem. Real Haml evaluates arbitrary Ruby: method calls, string
interpolation with logic, inline tag content, filters. This engine allows
none of that. Templates read pre-computed fields from the context and
nothing else:
- Tags:
%tag,.class,#id,{ key: value }attributes - Escaped output:
= field(field access only) - Conditionals:
- if field/- else - Loops:
- items.each do |item| - Partials:
= render "name", key: value - Static text and comments
Method calls, hash access, variable assignment, and raw output (!=) are
all parse errors. If it parses, it's in the subset. See
ruby / html templates for the reasoning behind the
dumb subset and the parser that enforces it.
The files keep the .haml extension even though the grammar is narrower.
This buys existing Haml tooling for free: editors highlight .haml syntax
out of the box, and editor helpers (for example, jump-to-partial on
= render "help/sidebar") work without custom filetype plumbing.
The API
The Go API is minimal, exposing Parse and Render:
import "eds/haml"
// Parse template once at startup
tmpl, err := haml.Parse(sourceCode, "views/companies/index.haml")
if err != nil {
log.Fatal(err)
}
// Render with a context map and optional partial resolver
locals := map[string]any{
"name": "Acme Corp",
"score": 9.4,
}
html, err := tmpl.Render(locals, func(name string, partialLocals map[string]any) (string, error) {
// Resolve = render "partial" calls
return renderPartial(name, partialLocals)
})
Structure-Aware AST
Like the Ruby version, the parser constructs an Abstract Syntax Tree (AST) rather than concatenating strings:
type Template struct {
path string
nodes []node
}
type node struct {
kind nodeKind
indent int
text string // static text, output expressions
tag string // %tag
classes []string // .class
id string // #id
children []node
}
This prevents invalid HTML nesting. Since indentation defines nesting, tag open/close pairings are mathematically guaranteed.
Security Model
The Go port enforces the same strict safety invariants:
- Escaping by Default:
= fieldHTML-escapes output viahtml.EscapeString. - No Raw Syntax:
!=is a parse error. The only unescaped paths are trustedhaml.SafeStringvalues (returned by the layout or CSRF helpers) and the engine's transform builtins. - No Arbitrary Code: The parser rejects ternaries, variable assignments, method calls, and constant lookups. Templates only read pre-computed data from the context.
- Secure Transforms: Rich text renders exclusively through built-in
engine transforms:
= markdown(field),= slack(field), and= search_highlight(field). The engine processes and sanitizes these values directly.
Embedding templates
I use //go:embed to bundle the .haml 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 schema.sql
var schema []byte
func initDB(db *sql.DB) error {
_, err := db.Exec(string(schema))
return err
}
Or as a string:
//go:embed version.txt
var version string
Multiple files
Embed multiple files into an embed.FS:
import "embed"
//go:embed templates/*.html
var templatesFS embed.FS
func loadTemplate(name string) ([]byte, error) {
return templatesFS.ReadFile("templates/" + name)
}
The path in ReadFile must match the embedded path exactly,
including the directory prefix.
Patterns
Patterns work like filepath.Glob:
//go:embed static/*
var staticFS embed.FS
//go:embed *.sql
var migrations embed.FS
//go:embed templates/*.html templates/*.css
var assetsFS embed.FS
Multiple directives can target the same variable:
//go:embed index.json
//go:embed *.gotmpl
//go:embed *.json
var templatesFS 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)
}
Migrations
Bundle SQL migrations with the binary so production deploys carry their own DDL:
const MigrateDir = "db/migrate"
//go:embed db/migrate/*.sql
var MigrateFS embed.FS
Load and run them via io/fs:
entries, _ := fs.ReadDir(MigrateFS, MigrateDir)
for _, e := range entries {
sql, _ := fs.ReadFile(MigrateFS, MigrateDir+"/"+e.Name())
db.Exec(string(sql))
}
No separate migration directory to ship, no path-finding code.
When to use
- CLI tools that need bundled templates, configs, or assets
- Web servers with static files (HTML, CSS, JS, images)
- Database migrations shipped with the binary
- 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.