ruby / templates

The web framework renders HTML with Haml templates, layouts, partials, and content_for.

Typed view data with Data.define

Passing raw hashes to templates is error-prone. Instead, define Data.define structs in the handler that specify the exact contract with the template:

module CompaniesHandler
  class Index < Framework::Handler
    PageData = Data.define(:summary, :rows)
    Row = Data.define(:name, :status, :edit_url)

    def handle
      companies = Companies::All.new(db).call
      count = companies.count { |co| co["active"] }

      data = PageData.new(
        summary: "#{count} active",
        rows: companies.map { |co|
          Row.new(
            name: co["name"].to_s,
            status: co["status"].to_s,
            edit_url: "/companies/edit?id=#{co["id"]}"
          )
        }
      )

      render "companies/index", data: data
    end
  end
end

The template receives a data struct and renders HTML. It accesses data.field and row.field but does not call formatters, access hash keys, or transform data:

= content_for :title, "Companies"

%h1
  = data.summary

%table
  - data.rows.each do |row|
    %tr
      %td
        = row.name
      %td
        = row.status
      %td
        %a{href: row.edit_url}
          Edit

This gives hard guarantees, not just style guidance.

Row = Data.define(:name, :status, :edit_url)

Row.new(name: "Acme", status: "Active")
# ArgumentError: missing keyword: :edit_url

With hashes, the same bug often renders as blank UI:

row = { "name" => "Acme", "status" => "Active" }
row["stauts"] # nil (typo)

Handlers also pre-compute booleans and strings, so templates don't contain nil-safety logic:

-# before
- if person["headline"].to_s.strip != ""
  = person["headline"]

-# after
- if person.headline
  = person.headline

This keeps formatting and branching in handlers, which is easier to unit test and debug.

Dumb templates

Templates should render pre-formatted data into HTML, not run logic. I restrict Haml to a "dumb" subset:

Allowed:

Banned:

= always HTML-escapes. != outputs raw. Handlers must pre-escape or pre-sanitize anything that needs !=.

This removes arbitrary Ruby execution from templates. Templates can't call methods, access constants, or build raw HTML strings. All raw HTML construction happens in handlers and flows through !=, which concentrates XSS-sensitive code in one auditable layer.

Why Haml

Haml is structure-aware. Indentation maps to HTML nesting, so the parser builds an AST rather than concatenating strings. This prevents entire classes of XSS bugs that string template systems create by interpolating untrusted data into raw HTML strings.

Because the subset is small (field access, if/else, loops, partials), a custom renderer replaced the haml gem. It parses templates into an AST and renders by walking the tree. The renderer rejects anything outside the subset at parse time. It IS the linter.

Rendering

The template engine wraps the custom Haml renderer with layout support and partials:

module Framework
  class Template
    CACHE = Concurrent::Map.new
    VIEWS_PATH = File.expand_path("../../ui/views", __dir__)

    def self.cache_all(views_path = VIEWS_PATH)
      Dir.glob(File.join(views_path, "**/*.haml")).each do |path|
        CACHE.compute_if_absent(path) {
          Haml::Subset.new(File.read(path, encoding: "UTF-8"), path: path)
        }
      end
    end

    def self.render(name, locals = {}, layout: "layouts/application")
      context = Context.new(locals)
      renderer = partial_renderer(context)
      content = render_template(name, locals,
        context: context, partial_renderer: renderer)

      if layout
        layout_context = context.clone
        render_template(layout, locals.merge(body: content),
          context: layout_context,
          partial_renderer: partial_renderer(layout_context))
      else
        content
      end
    end

    def self.render_template(name, locals, context:, partial_renderer:)
      path = File.join(VIEWS_PATH, "#{name}.haml")
      template = CACHE.compute_if_absent(path) {
        Haml::Subset.new(File.read(path, encoding: "UTF-8"), path: path)
      }
      template.render(locals, context: context,
        partial_renderer: partial_renderer)
    end
  end
end

Templates are pre-loaded into a Concurrent::Map at boot. Since the renderer rejects invalid templates at parse time, cache_all catches banned constructs before serving requests.

The layout receives page content as a body local and renders it with != body (raw output). No content_for or yield. The layout is just another template that receives data.

← All articles