Views
Views are .ecr templates compiled at build time into typed render methods.
Output is auto-escaped by default — <%= %> escapes, <%== %> is raw —
so user input is XSS-safe out of the box.
Declaring templates
A controller names its view files with templates, declaring the locals
each one receives:
class TasksController < ApplicationController
templates "tasks",
root: __DIR__ + "/../views",
layout: "application",
index: {tasks: Array(Task)},
edit: {task: Task}
end
The macro compiles views/tasks/index.ecr and views/tasks/edit.ecr into
render :index and render :edit. A wrong local name or type in the action
call is a compile error.
Template syntax
<% if tasks.empty? %>
<p class="empty">No tasks yet.</p>
<% else %>
<ul>
<% tasks.each do |task| %>
<li><%= task.title %></li>
<% end %>
</ul>
<% end %>
<%= expr %>— interpolate, escaped<%== expr %>— interpolate, raw<% code %>— plain Crystal- Helper calls (
link_to,content_tag,render, the form builder) are already escaped HTML, so they render unchanged even inside<%= %>.
Layouts
A template renders inside the layout's yield:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Altair</title>
</head>
<body>
<header><h1><a href="/">Altair</a></h1></header>
<main>
<% yield %>
</main>
</body>
</html>
Pass layout: false to render a bare fragment — handy for partial page
updates.
Partials
render a partial by name with its locals; it returns a String you can
embed anywhere:
render "form", locals: {post: post}
Helpers
<%= link_to "Edit", "/tasks/#{task.id}/edit" %>
<%= content_tag :li, "task", id: "task-#{task.id}" %>
<%= button_to "delete", "/tasks/#{task.id}", method: :delete %>
<%= javascript_include_tag :htmx %>
link_to and button_to accept extra attributes and pass them through to
the generated tag, so htmx attributes work directly:
<%= link_to "edit", "/tasks/#{task.id}/edit", hx_get: "/tasks/#{task.id}/edit",
hx_target: "#task-#{task.id}", hx_swap: "outerHTML" %>
Form builder
form_for yields a builder with typed field helpers; the generated form is
auto-escaped:
<% form_for("/tasks") do |f| %>
<%= f.label("title", "What needs doing?") %>
<%= f.text_field("title", placeholder: "Add a task") %>
<%= f.submit("Add") %>
<% end %>
Fields include text_field, email_field, password_field, hidden_field,
label and submit. Pass method and htmx attributes through form_for:
<% form_for("/tasks", hx_post: "/tasks", hx_target: "#task-list", hx_swap: "outerHTML") do |f| %>
<%= f.text_field("title") %>
<%= f.submit("Add") %>
<% end %>
htmx
The htmx layer ships helpers for the request side and the response side. On
the request side, request.hx_request? tells an action whether the browser
sent the HX-Request header, so one action can render a full page or a bare
fragment:
render :index, layout: !request.hx_request?, locals: {tasks: @@tasks}
On the response side, hx_trigger_after_swap(:task_changed) and
hx_trigger_after_settle(:task_changed) emit the HX-Trigger header, which
the page listens for to update the UI.