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:
- Move method calls, hash access, and formatting into handlers, behind Data.define structs, a screen at a time.
- Add a linter in CI.
- 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:
- formatters that build link or badge HTML
- search snippets returned by Postgres
ts_headline - flash messages built by interpolating user-supplied names
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("<b>", "<b>")
.gsub("</b>", "</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.