go / html templates
I ported the ruby / html templates Haml
renderer to Go as the haml package. It allows Go web routes and background jobs
(eds-jobs) to render the exact same .haml files as the
Ruby web application without invoking a Ruby process.
Both renderers accept the same "dumb Haml" subset and produce the same HTML for the same inputs.
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.