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:

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:

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

When not to use

See CDN for cache-busting embedded assets in production web apps.

← All articles