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 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 restrict Haml to a "dumb" subset:
Allowed:
- Tags:
%tag,.class,#id,{ key: value }attributes - Escaped output:
= field(field access only) - Unescaped output:
!= field(for pre-escaped HTML from handler) - Conditionals:
- if field/- else - Loops:
- items.each do |item| - Partials:
= render "name", key: value - Static text and comments
Banned:
- 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. != outputs raw.
Handlers must pre-escape or pre-sanitize anything that needs !=.
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 and flows through !=,
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.
Rendering
The template engine wraps the custom Haml renderer with layout support and partials:
module Framework
class Template
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
end
end
Templates are pre-loaded into a Concurrent::Map at boot.
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 (raw output).
No content_for or yield. The layout is just another template that receives data.