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 provides:

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 !=.

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.

Rendering

The template engine wraps Haml with layout support, partials, and content_for:

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

    # Pre-load all templates at boot
    def self.cache_all(views_path = VIEWS_PATH)
      Dir.glob(File.join(views_path, "**/*.haml")).each do |path|
        CACHE.compute_if_absent(path) {
          Haml::Template.new(path, default_encoding: "UTF-8")
        }
      end
    end

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

      if layout
        context.content_blocks[:_main] = content
        render_template(layout, context, locals) { |name = nil|
          if name.nil?
            content
          else
            context.content_blocks[name].to_s
          end
        }
      else
        content
      end
    end

    def self.render_template(name, context, locals, &block)
      path = File.join(VIEWS_PATH, "#{name}.haml")
      template = CACHE.compute_if_absent(path) {
        Haml::Template.new(path, default_encoding: "UTF-8")
      }
      template.render(context, locals, &block)
    end
  end
end

Templates are pre-loaded into a Concurrent::Map at boot to catch missing files early and avoid disk reads per-request.

← All articles