HTML, written in Crystal.
A view is a Crystal class whose methods write HTML, and everything it writes is escaped unless you mark it as trusted.
A view is a class
Views live under app/views/. Each one is a Blueprint class that subclasses App::ApplicationView. That base is a Caramel::View with the application’s path helpers.
The view’s inputs are typed in initialize. Its markup goes in private def blueprint. The compiler checks both.
module App::Views::Books
class Show < App::ApplicationView
def initialize(@book : App::Book, @csrf_token : String)
end
private def blueprint
h1 { @book.title }
p { "by #{@book.author}" }
end
end
endAn action renders it as the page: page "Book", Views::Books::Show.new(book, csrf_token). A view renders once. Build a new one for each response.
app/views/application_view.crThe base class every view subclasses.app/views/layouts/application.crThe layout around every page.app/views/home/index.crThe generated home page.Elements and text
Each HTML element is a method. The value its block returns is escaped text, so h1 { "<Dune>" } writes <h1><Dune></h1>.
Inside a block that writes elements, plain writes escaped text beside them.
p do
plain "by "
strong { @book.author || "Unknown" }
endwhitespacewrites one space, for instance between two links.comment "text"writes an HTML comment.doctypewrites<!DOCTYPE html>.element("book-card") { … }writes a custom tag.
Attributes
Attributes are keyword arguments. Their values are escaped like text, so &, <, > and both quotes are safe.
hx_get: "/books"hx-get="/books": underscores become dashes."hx-swap:inherited": "innerMorph"The name exactly as quoted, for names a symbol cannot spell.checked: truechecked. false and nil leave the attribute out.class: ["card", (tagged ? "tagged" : nil)]class="card tagged", dropping nil.data: {book_id: book.id}data-book-id="1".htmx 4 attributes that children inherit carry an :inherited suffix. A symbol cannot spell the colon, so quote the name. An attribute name that contains <, >, & or a quote raises an error.
div "hx-swap:inherited": "innerMorph" do
input type: "checkbox", name: "done", checked: true, disabled: false
endChild views, islands and trusted HTML
render Card.new(book)writes a child view in place:@books.each { |book| render Card.new(book) }.island "Rating", {stars: 4}writes a<caramel-island>mount point with the props as JSON. The name must be PascalCase. Inapp/assets/javascript/app.js,CaramelIslands.define("Rating", (element, props) => { … })brings it to life.raw valuewrites aCaramel::HTML::Safeor asafe(...)value unescaped. It accepts nothing else, so an ordinary string cannot reach the page raw by accident.
Only HTML your application built itself belongs in Safe. Never wrap user input, stored text or a third-party response. Escaped text is the default for a reason.
Small fragments with markup
An action or a view can build a fragment too small for a class with markup { … }. It escapes like a view and returns a Caramel::HTML::Safe.
morph "#count", with: markup { span { count.to_s } }Inside the block, element methods such as span, label or title come first. The surrounding methods and local variables stay available.
Forms
A form that posts carries the CSRF token in a hidden _csrf field. A form for PATCH or DELETE posts and names its method in _method.
form action: book_path(@book.id), method: "post" do
input type: "hidden", name: "_csrf", value: @csrf_token
input type: "hidden", name: "_method", value: "DELETE"
button(type: "submit") { "Delete" }
endhtmx requests need no hidden field. The layout’s hx-headers sends the token as X-CSRF-Token.
frappe make resource generates a complete form to start from. Its labelled helper writes a label, the control and the field’s errors. Its error summary is an alert listing each message.
private def labelled(name : String, text : String, &) : Nil
id = "book_#{name}"
label(for: id) { text }
yield id
div id: "#{id}_errors" do
(@errors[name]? || [] of String).each { |error| p(class: "field-error") { error } }
end
endThe application layout
The layout receives the rendered Caramel::Page and the CSRF token. It writes the page’s HTML with raw @page.html.
html lang: Caramel.languagenames the request’s language:enunless the app usescaramel/i18n.- The head loads
app.css, htmx,caramel-islands.jsandapp.js, the scripts deferred. - The body sets inherited htmx attributes: boosted links,
#contentas the target,innerMorphswaps and the CSRF header.
body "hx-boost:inherited": "true", "hx-target:inherited": "#content", "hx-swap:inherited": "innerMorph",
"hx-headers:inherited": {"X-CSRF-Token" => @csrf_token}.to_json do
main(id: "content") { raw @page.html }
endTesting views
Test what a view writes through a real response. Corretto’s HTML expectations describe elements and decoded text, recorded apart from Blueprint’s renderer. A shared escaping defect cannot make both sides agree.
Test the HTML →