haml.cr
haml.cr
A compile-time Haml templating engine for Crystal. Inspired by the Ruby Haml gem (source, rubygems).
A template contains Haml markup and Crystal expressions, compiled ahead of time into a Crystal macro that writes to an IO (Haml.embed(filename, io_name)) or returns a string (Haml.render(filename)).
Table of Contents
- Quick demo
- Interface
- Syntax
- Installation
- Key differences from Ruby Haml
- Issues and pull requests
- Author
- Development note
Quick demo
- Create a template file
haml_demo.html.haml:
- # I'm a comment and won't be rendered into the output HTML!
%section.container
%h1= post.title
%h2= post.subtitle
.content
= post.content
- Call
Haml.renderfrom your Crystal codehaml_demo.cr:
require "haml"
record Post, title : String, subtitle : String, content : String
example_post = Post.new(title: "Welcome to Haml", subtitle: "A nice way to write templates", content: "Ruby & Crystal love Haml!")
def my_view(post : Post) : String
Haml.render("#{__DIR__}/haml_demo.html.haml")
end
puts my_view(example_post)
Run it with crystal run haml_demo.cr. Output:
<section class="container">
<h1>Welcome to Haml</h1>
<h2>A nice way to write templates</h2>
<div class="content">
Ruby & Crystal love Haml!
</div>
</section>
Please observe:
- The
&is automatically HTML-escaped to&.- (If you are viewing this README.md with a Markdown renderer, it may or may not show that, so look at the raw Markdown, or run the example yourself.)
- Just like Crystal's stdlib ECR:
- The template file is fully compiled into the binary at Crystal compile time: the template file is not read and not needed at runtime.
- The
Haml.rendercompiles the template file into a Crystal macro, so it can access in-scope variables likepost, and run other arbitrary Crystal code.
Interface
Macros
Just like Crystal's stdlib ECR, there are three supported macros:
Haml.embed(filename, io_name)- writes to io_nameHaml.render(filename)- returns a String (i.e. it wrapsHaml.embedinString.build)Haml.def_to_s(filename)- defines a#to_s(io)method
As shown above, using Haml.render(filename) from your view method is probably the most straightforward way to use Haml templates in your Crystal code.
One-off Haml.render_string
A Haml.render_string(s) macro can be used for quick testing (but can become confusing because #{...} interpolation may happen before the string is passed to the macro):
crystal eval 'require "haml" ; puts Haml.render_string("%h1 Hello World\n%h2\n from Crystal\n = Crystal::VERSION")'
<h1>Hello World</h1>
<h2>
from Crystal
1.21.1
</h2>
hamlc binary compiler
In this directory, shards build will build a hamlc binary which compiles a .haml file into a Crystal macro:
hamlc --no-locations examples/haml_demo.html.haml
outputs:
__haml_io << "<section class=\"container\">\n<h1>"
::HTML.escape((post.title
).to_s, __haml_io)
__haml_io << "</h1>\n<h2>"
::HTML.escape((post.subtitle
).to_s, __haml_io)
__haml_io << "</h2>\n<div class=\"content\">\n"
::HTML.escape((post.content
).to_s, __haml_io)
__haml_io << "\n</div>\n</section>\n"
nil
In general you won't need this: just use Haml.render in your code as shown above.
Syntax
See SYNTAX.md. Quick overview:
Interpolation and HTML escaping
=escapes every dynamic value usingHTML.escape.!=inserts explicitly trusted raw markup.- There is no
html_safebypass like in Ruby on Rails. - Attribute values always escape.
Examples
str = "A&B"
| Haml input | HTML output | Notes |
|---|---|---|
%p Hello A&B |
<p>Hello A&B</p> |
your text passes through unescaped |
%p Hello A&B |
<p>Hello A&B</p> |
|
%p Hello #{str} |
<p>Hello A&B</p> |
#{...} is interpolated and escaped |
<p>Hello #{str}</p> |
<p>Hello A&B</p> |
your own HTML tags + escaped interpolated expressions |
%p& Hello A&B |
<p>Hello A&B</p> |
the & forces escaping even on your own text |
%p= str |
<p>A&B</p> |
|
%p!= str |
<p>A&B</p> |
!= unsafely inserts raw output. (Caution: XSS risk.) |
The #{str} and = str forms will be the ones you use most frequently. Save != for when you want to insert pre-escaped content (such as raw HTML).
If/else
%p
Coin flip:
%strong
- if Random.rand >= 0.5
Heads
- else
Tails
Loops
%ul
- entries.each do |entry|
%li= entry.title
HTML attributes
%a{href: entry.url, target: "_blank"}
= entry.title
Multi-line attributes are supported:
%a{
href: entry.url,
target: "_blank"
}
= entry.title
CSS Classes and Styles
%h2#my_id Subheading with an ID
%h2.mb-0.fw-bold#another_id With classes and ID
%h2{class: ["mb-0", "fw-bold", dynamic_class], id: dynamic_id} With Crystal expressions to set class and ID
%div.container
-# is the same as
.container
Multiline Crystal
Multiline Crystal interpolated strings:
- value = "Crystal expression"
.example-1
=%(
This entire line, including its dynamically interpolated #{value}, will be HTML-escaped.
And this #{4 - 3} too.
)
.example-2
!=%(
This entire line, including its dynamically interpolated #{value}, will NOT be HTML-escaped. (XSS risk!)
And this #{4 - 3} too.
)
Multiline inline Crystal code (notice = vs. != vs. -):
.example-1
= (
now_1 = Time.utc
# The value will be HTML-escaped and inserted into the div:
now_1 + 1.hour
)
.example-2
!= (
now_2 = Time.utc
# The value NOT be HTML-escaped, and will be inserted RAW into the div: (XSS risk!)
now_2 + 1.hour
)
.example-3
- (
now_3 = Time.utc
# The value will not be inserted.
now_3 + 1.hour
)
= now_3 # but the value can be used later (will not have 1.hour added to it)
Including partial templates
In fruits.html.haml:
- fruits.each do |fruit|
!= Haml.render("#{__DIR__}/_fruit.html.haml")
In _fruit.html.haml:
.btn.mb-2{id: "fruit_#{fruit}"}
= fruit
Render it:
require "haml"
fruits = ["apple", "banana", "peach"]
puts Haml.render("#{__DIR__}/fruits.html.haml")
Outputs:
<div class="btn mb-2" id="fruit_apple">
apple
</div>
<div class="btn mb-2" id="fruit_banana">
banana
</div>
<div class="btn mb-2" id="fruit_peach">
peach
</div>
Note that __DIR__ in Haml.render("#{__DIR__}/fruits.html.haml") is used to tell the compiler that fruits.html.haml is in the same directory as this file. In your project, you can just specify paths from the build root, such as Haml.render("src/templates/fruits.html.haml").
(Advanced: to reduce String allocations, it is possible to avoid having the partial build its own intermediate String by instead passing it the same IO name as the parent, and using, for example, != Haml.embed("_fruit.html.haml", __haml_io) in place of != Haml.render(...).)
Installation
-
Add the dependency to your
shard.yml:dependencies: haml: github: compumike/haml.cr -
Run
shards install -
Add
require "haml" -
Call
Haml.render("src/templates/my_template.html.haml")
Key differences from Ruby Haml
See supported syntax for details and the Ruby Haml reference for comparison.
Some important differences:
| Feature | Difference |
|---|---|
| HTML escaping | = always HTML-escapes. != always emits raw HTML. (There is no Rails-style html_safe bypass.) |
| Compilation | Templates compile ahead of time. Runtime template evaluation is not supported. |
| Attributes | False data-* and aria-* values render as "false". |
| Object references | Object references such as %div[object] (which pull class and id from object) are unsupported. |
| HTML output | HTML5 only. Whitespace handling is simpler and may differ slightly from Ruby Haml. |
| Filters | Only :plain, :escaped, :preserve, :css, and :javascript are supported. CSS and JavaScript filters do not interpolate. |
Issues and pull requests
- This is a hobby side project.
- Issues and PRs are unlikely to be accepted.
- If you find a reproducible bug, you may file a brief issue.
Author
- compumike - creator and maintainer
Development note
This project was written almost entirely by AI coding agents, including an extensive test suite. Use at your own risk.
haml.cr
- 1
- 0
- 0
- 0
- 0
- about 1 hour ago
- September 27, 2026
MIT License
Wed, 30 Sep 2026 00:26:46 GMT