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:

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