ruby / html templates

My web framework rendered HTML with Haml templates, layouts, and partials. Over a few years I restricted those templates to a subset small enough for a custom renderer to replace the haml gem, and then to port to Go.

go / html templates specifies the language. This article describes the restricting.

Typed view data with Data.define

A Data.define struct in the handler states the 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 reads data.field and row.field and calls nothing else:

= 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

The struct enforces the contract:

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

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

With a hash, the same bug renders blank:

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

Handlers pre-compute booleans and strings, so a template holds no nil-safety logic:

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

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

Formatting and branching stay in the handler, where I can unit test them.

Restrict before replacing

The haml gem evaluates Ruby at render time. A template subset with no Ruby expressions needs no eval.

The renderer is two files and about 1,200 lines. The templates were the hard part. All 360 had to conform before the renderer could reject anything. The order was:

  1. Move method calls, hash access, and formatting into handlers, behind Data.define structs, a screen at a time.
  2. Add a linter in CI.
  3. Write the renderer against the grammar that was left, and delete the linter.

The linter was regexes. A regex cannot parse a nested expression, so it caught only patterns I had already seen.

The renderer parses every template at boot. A construct outside the subset crashes the process before it serves a request.

By then the templates held only field access and control flow, so they ported to Go.

Escaping at the source

= HTML-escapes and != is a parse error. Raw HTML comes only from Ruby code that returns a SafeString:

A formatter escapes every dynamic value:

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

Postgres' ts_headline wraps matching terms in <b>...</b>, and the terms are user input. safe_headline escapes the whole snippet, then restores the <b> markers:

require "cgi"

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

The caller wraps the result in a SafeString so the template does not escape it again.

A flash that interpolates a user name renders in the next response. The producer escapes it:

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

The layout treats every flash value as HTML and cannot tell which strings are safe.

← All articles