ruby / html templates

My web framework rendered HTML with Haml templates, layouts, partials, and content_for. The templates used a restricted subset of Haml, rendered by a custom engine that replaced the haml gem and rejected anything outside the subset at parse time.

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 restricted Haml to a "dumb" subset:

Allowed:

Banned:

= always HTML-escapes unless the value is a trusted SafeString (such as layout body or partial output) or is rendered via an engine transform builtin. != is unsupported and raises a parse error.

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, 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.

The template engine

My template engine wraps my custom Haml renderer with layout support and partials:

module Framework
  class Template
    CACHE = Concurrent::Map.new
    PATH_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

    def self.render_partial(name)
      PATH_CACHE.compute_if_absent(name) do
        # Try _-prefixed partial first, then exact path, then .html.
        dir = File.dirname(name)
        base = File.basename(name)
        partial_name = dir == "." ? "_#{base}" : File.join(dir, "_#{base}")

        haml_path = File.join(VIEWS_PATH, "#{partial_name}.haml")
        exact_path = File.join(VIEWS_PATH, "#{name}.haml")
        html_path  = File.join(VIEWS_PATH, "#{partial_name}.html")

        if File.exist?(haml_path)   then haml_path
        elsif File.exist?(exact_path) then exact_path
        elsif File.exist?(html_path)  then html_path
        else raise Errno::ENOENT, "No such partial: #{partial_name}.haml"
        end
      end
    end
  end
end

Two caches keep rendering fast:

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 (escaped output is bypassed for trusted SafeString content). No content_for or yield. The layout is just another template that receives data.

Replacing the haml gem

The haml gem evaluates arbitrary Ruby at render time. Templates can call methods, access constants, assign variables. My template subset uses none of this.

A custom renderer enforces the subset by construction: if the parser has no node type for a construct, it can't appear in templates. This removes Ruby eval from the rendering path and drops a dependency. Two files, ~1,200 lines total:

The prerequisite was restricting all ~360 templates to the dumb subset first: moving method calls, hash access, and formatting into handlers with Data.define structs. Once every template conformed, a CI linter prevented regressions, and the renderer could be built against a frozen grammar.

Parser

Haml::Subset.new(source, path:) parses source into a tree at construction time. Lines are classified into node types:

:doctype     # !!!
:comment     # -# ...
:filter      # :javascript, :css
:if          # - if expr
:elsif       # - elsif expr
:else        # - else
:each        # - collection.each do |item|
:render      # = render "name", key: value
:output      # = expr (HTML-escaped)
:transform   # = markdown(field) etc.
:tag         # %tag.class#id{ attrs }
:text        # static text

There is no :eval or :ruby node. A method call, constant reference, or variable assignment has no node type to parse into, so the parser raises.

Indentation determines nesting. The parser walks lines at each indent level and recursively parses children:

private def parse(lines, base_indent, from, to)
  nodes = []
  i = from

  while i < to
    line = lines[i]
    stripped = line.lstrip
    indent = line.length - line.lstrip.length

    if stripped == ""
      i += 1
      next
    end

    if indent != base_indent
      raise "#{@path}:#{i + 1}: expected indent #{base_indent}, got #{indent}"
    end

    # Find children (lines with greater indent)
    child_end = i + 1
    while child_end < to
      next_line = lines[child_end]
      next_stripped = next_line.lstrip
      if next_stripped != ""
        if (next_line.length - next_stripped.length) <= indent
          break
        end
      end
      child_end += 1
    end

    node = parse_line(stripped, indent, lines, i + 1, child_end)
    nodes << node
    i = child_end
  end

  nodes
end

The parser extracts tag name, classes, ID, and attributes:

private def parse_tag(stripped, indent, lines, child_from, child_to)
  rest = stripped.dup
  tag_name = "div"
  classes = []
  id = nil

  if rest.start_with?("%")
    m = rest.match(/\A%(\w[\w-]*)/)
    tag_name = m[1]
    rest = rest[m[0].length..]
  end

  while rest.match?(/\A[.#]/)
    if rest.start_with?(".")
      m = rest.match(/\A\.(-?[a-zA-Z_][\w-]*)/)
      classes << m[1]
      rest = rest[m[0].length..]
    elsif rest.start_with?("#")
      m = rest.match(/\A#([a-zA-Z_][\w-]*)/)
      id = m[1]
      rest = rest[m[0].length..]
    end
  end

  # Reject inline content: inner content must be on a new line
  rest = rest.strip
  if rest != ""
    raise "#{@path}: inline content on tags is not allowed: #{stripped}"
  end

  children = parse(lines, indent + 2, child_from, child_to)
  { type: :tag, tag: tag_name, classes: classes, id: id,
    children: children }
end

Inline content on tags is banned. %h1 Title must be written as:

%h1
  Title

This simplifies parsing (every tag's content is children) and makes the structure explicit.

Expression evaluator

Expressions in = field, - if expr, and #{} interpolation go through Haml::Expr, a recursive-descent parser with a constrained grammar:

expr         ::= or_expr
or_expr      ::= and_expr ('||' and_expr)*
and_expr     ::= not_expr ('&&' not_expr)*
not_expr     ::= '!' not_expr | cmp_expr
cmp_expr     ::= primary (('==' | '!=') primary)?
primary      ::= STRING | NUMBER | BOOL | NIL | field_access
field_access ::= IDENT ('.' IDENT)*

There are three stages. Tokenize, parse to AST, evaluate:

def self.eval_string(src, ctx)
  tokens = tokenize(src.strip)
  parser = Parser.new(tokens)
  node = parser.parse_expr
  evaluate(node, ctx)
end

The evaluator walks the AST and resolves values against a context object:

def self.evaluate(node, ctx)
  case node[:type]
  when :string  then node[:value]
  when :number  then node[:value]
  when :bool    then node[:value]
  when :nil     then nil
  when :field   then eval_field(node[:parts], ctx)
  when :cmp
    left = evaluate(node[:left], ctx)
    right = evaluate(node[:right], ctx)
    case node[:op]
    when "==" then left == right
    when "!=" then left != right
    end
  when :and
    evaluate(node[:left], ctx) && evaluate(node[:right], ctx)
  when :or
    evaluate(node[:left], ctx) || evaluate(node[:right], ctx)
  when :not
    !evaluate(node[:operand], ctx)
  end
end

Field access resolves through send:

def self.eval_field(parts, ctx)
  val = ctx.send(parts[0].to_sym)
  i = 1
  while i < parts.length
    val = val.send(parts[i].to_sym)
    i += 1
  end
  val
end

= data.name becomes ctx.send(:data).send(:name), which works with Data.define structs and singleton methods on the context.

The evaluator also handles hash literals with ** splat (for tag attributes), array literals, interpolated strings, and function calls (for ViewHelper methods on the context).

Rendering the AST

The renderer walks the AST, appending HTML to a buffer:

private def render_nodes(nodes, buf, ctx, partial_renderer)
  i = 0
  while i < nodes.length
    node = nodes[i]
    case node[:type]
    when :doctype
      buf << "<!DOCTYPE html>\n"
    when :comment
      nil
    when :text
      buf << Expr.interpolate(node[:text], ctx) << "\n"
    when :output
      val = eval_output(node[:expr], ctx)
      buf << escape_val(val) << "\n"
    when :transform
      val = Expr.eval_string(node[:field], ctx)
      buf << Haml::TRANSFORMS.fetch(node[:name]).call(val).to_s << "\n"
    when :render
      buf << render_partial_call(node[:expr], ctx, partial_renderer)
    when :tag
      render_tag(node, buf, ctx, partial_renderer)
    when :filter
      render_filter(node, buf, ctx)
    when :if
      chain = [node]
      while i + 1 < nodes.length &&
          (nodes[i + 1][:type] == :elsif || nodes[i + 1][:type] == :else)
        i += 1
        chain << nodes[i]
      end
      render_conditional(chain, buf, ctx, partial_renderer)
    when :each
      render_each(node, buf, ctx, partial_renderer)
    end
    i += 1
  end
end

= expr HTML-escapes output unless the value is a trusted SafeString (such as partial results or layout body). Raw output (!=) is unsupported and raises a parse error. Rich text renders exclusively via engine transforms (e.g. = markdown(field)), which sanitize inside the engine.

Tags emit opening and closing HTML with escaped attributes:

private def render_tag(node, buf, ctx, partial_renderer)
  tag = node[:tag]
  attrs = build_attrs(node, ctx)
  attr_str = attrs.map { |k, v|
    if v == true
      " #{k}"
    else
      " #{k}=\"#{CGI.escapeHTML(v.to_s)}\""
    end
  }.join

  if VOID_ELEMENTS.include?(tag)
    buf << "<#{tag}#{attr_str}>\n"
    return
  end

  if node[:children] != []
    buf << "<#{tag}#{attr_str}>\n"
    render_nodes(node[:children], buf, ctx, partial_renderer)
    buf << "</#{tag}>\n"
  else
    buf << "<#{tag}#{attr_str}></#{tag}>\n"
  end
end

Context

Templates receive data through a context object. Locals become singleton methods:

private def make_context(locals, context: nil)
  env = context || Object.new
  locals.each do |k, v|
    env.define_singleton_method(k) { v }
  end
  env
end

Loop variables clone the context to avoid mutating the parent binding:

private def clone_context(ctx, name, value)
  child = ctx.clone
  child.define_singleton_method(name.to_sym) { value }
  child
end

Without cloning, - items.each do |data| would overwrite the parent data local for the rest of the template.

The renderer is the linter

Before the custom renderer, a regex-based linter scanned templates for banned constructs. It was incomplete. Regexes couldn't parse nested expressions, and every new violation pattern needed a new rule.

The custom renderer replaced the linter. Templates are parsed at boot by cache_all. A template with a construct outside the subset crashes the process before it serves a request.

If it parses, it's in the subset. If it's not in the subset, it doesn't parse.

Escaping at the source

= HTML-escapes by default and != is a parse error, so the template layer is safe by construction. The only raw HTML comes from a few code paths that build markup in Ruby and hand back a trusted SafeString:

Each of those is a place to escape, not a place to trust input.

A formatter that builds HTML escapes every dynamic piece, even when it looks safe today:

require "cgi"

module Fmt
  module Lists
    def self.list(name, url)
      esc_name = CGI.escapeHTML(name.to_s)
      esc_url  = CGI.escapeHTML(url.to_s)
      %(<a href="#{esc_url}">#{esc_name}</a>)
    end
  end
end

A formatter that returns HTML and forgets to escape one parameter is one of the most common XSS sources in a server-rendered app.

Postgres' ts_headline returns a snippet with <b>...</b> markers around matching terms. The terms are user input. Escape the entire snippet, then reintroduce only the markers the function adds:

require "cgi"

def safe_headline(snippet)
  CGI.escapeHTML(snippet.to_s)
    .gsub("&lt;b&gt;",  "<b>")
    .gsub("&lt;/b&gt;", "</b>")
end

Wrap the result as a SafeString so the template renders it without re-escaping.

A flash that interpolates a user-controlled name renders in the next response. Escape on the way in:

flash_next(:notice, "Merged into #{CGI.escapeHTML(target.name)}")

If every flash value is treated as HTML by the layout, the producer is the right place to escape. The reader cannot tell which strings are safe.

See go / html templates for the Go port of this custom engine.

← All articles