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:
- Tags:
%tag,.class,#id,{ key: value }attributes - Escaped output:
= field(field access only;SafeStringvalues pass unescaped) - Conditionals:
- if field/- else - Loops:
- items.each do |item| - Partials:
= render "name", key: value - Static text and comments
Banned:
- Raw output syntax
!=(unsupported; useSafeStringor transform builtins instead) - Ruby method calls:
.map,.join,.any?,.to_s,.size - Module/class references in templates
- String interpolation with logic
- Hash access:
company["name"] - Variable assignment:
- x = expr
= 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:
CACHE: parsedHaml::Subsetobjects, keyed by absolute path. Pre-loaded at boot bycache_all; also populated lazily on first access for any path missed at startup.PATH_CACHE: resolved file paths, keyed by partial name.render_partialcallsFile.exist?up to three times to find_partial.haml,partial.haml, or_partial.html. Without caching, a table that loops over hundreds of rows and renders several partials per row produces thousands of redundant filesystem checks per request.PATH_CACHEensures each unique partial name resolves its path exactly once for the lifetime of the process.
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:
lib/haml/subset.rb: parser and rendererlib/haml/expr.rb: constrained expression evaluator
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:
- formatters that build link or badge HTML
- search snippets returned by Postgres
ts_headline - flash messages built by interpolating user-supplied names
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("<b>", "<b>")
.gsub("</b>", "</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.