crystal-html
crystal-html
Macro-driven HTML DSL for Crystal with zero dependencies. Clean syntax, compile-time tag generation, XSS protection by default.
Installation
Add the dependency to your shard.yml:
dependencies:
crystal-html:
github: erayjsx/crystal-html
Run shards install
Usage
require "crystal-html"
html = CrystalHTML.build {
doctype
html(lang: "en") {
head {
meta(charset: "utf-8")
title "My App"
}
body {
h1 "Hello, Crystal!"
p "A modern HTML DSL."
}
}
}
Attributes
Named tuple syntax with automatic underscore-to-hyphen conversion:
div(class: "container", id: "main", data_page: "home") {
text "Content"
}
# <div class="container" id="main" data-page="home">Content</div>
Hash syntax for dynamic attributes:
attrs = {"class" => "btn", "data-action" => "click"}
div(attrs) { text "Click me" }
Boolean Attributes
true renders the attribute name only, false omits it:
input(type: "email", required: true, disabled: false)
# <input type="email" required>
Supported: checked, disabled, required, readonly, hidden, autofocus, autoplay, controls, loop, muted, multiple, selected, open, defer, async, novalidate and more.
Inline Text
Pass a string as the first argument for simple text content:
h1 "Welcome"
a("Home", href: "/")
p "Paragraph content", class: "lead"
XSS Protection
All text content and attribute values are escaped by default:
CrystalHTML.build {
p { text "<script>alert('xss')</script>" }
}
# <p><script>alert('xss')</script></p>
Use raw to insert trusted HTML without escaping:
div { raw "<strong>Trusted HTML</strong>" }
Or use SafeString to explicitly mark content as safe:
trusted = CrystalHTML.safe("<em>safe content</em>")
div { text trusted }
Pretty Print
Generate indented HTML for development/debugging:
html = CrystalHTML.build(pretty: true) {
div(class: "app") {
h1 "Title"
p "Content"
}
}
Output:
<div class="app">
<h1>Title</h1>
<p>Content</p>
</div>
Custom indent size:
CrystalHTML.build(pretty: true, indent: 4) { ... }
IO Streaming
Write directly to any IO without intermediate string allocation:
CrystalHTML.build(STDOUT, pretty: true) {
h1 "Streamed directly"
}
Useful with Kemal or any framework that provides a response IO.
Conditional Rendering
CrystalHTML.build {
div {
render_if(user.admin?) {
a("Admin Panel", href: "/admin")
}
render_unless(cart.empty?) {
span "#{cart.size} items"
}
}
}
Fragment
Render multiple elements without a wrapper tag:
CrystalHTML.build {
fragment {
h1 "Title"
p "No wrapper div needed"
}
}
# <h1>Title</h1><p>No wrapper div needed</p>
Capture
Capture a block's output as a string for reuse:
CrystalHTML.build {
sidebar = capture {
nav { a("Home", href: "/") }
}
div(class: "layout") {
raw sidebar
main { h1 "Content" }
raw sidebar
}
}
Iteration
Use Crystal's native .each directly inside blocks:
items = ["Apple", "Banana", "Cherry"]
CrystalHTML.build {
ul {
items.each do |item|
li item
end
}
}
Select Tag
Crystal's select keyword conflicts with the HTML tag. Use select_tag instead:
CrystalHTML.build {
select_tag(name: "country", required: true) {
option("Turkey", value: "tr", selected: true)
option("Japan", value: "jp")
}
}
# <select name="country" required><option value="tr" selected>Turkey</option><option value="jp">Japan</option></select>
Components
Extend CrystalHTML::Builder to create reusable components:
class PageBuilder < CrystalHTML::Builder
def navbar(links : Array(Tuple(String, String)))
nav(class: "navbar") {
links.each do |label, url|
a(label, href: url)
end
}
end
def card(title : String, &)
div(class: "card") {
h3 title
div(class: "card-body") { with self yield self }
}
end
end
page = PageBuilder.new
page.doctype
page.html {
page.body {
page.navbar([{"Home", "/"}, {"About", "/about"}])
page.card("Hello") {
p "Card content"
}
}
}
puts page.to_s
Kemal Integration
require "kemal"
require "crystal-html"
get "/" do
CrystalHTML.build {
doctype
html(lang: "en") {
head {
meta(charset: "utf-8")
title "My App"
link(rel: "stylesheet", href: "/style.css")
}
body {
h1 "Welcome"
p "Built with CrystalHTML and Kemal."
}
}
}
end
Kemal.run
API Reference
| Method | Description |
|---|---|
CrystalHTML.build { } |
Build HTML and return as String |
CrystalHTML.build(io) { } |
Stream HTML to an IO |
CrystalHTML.build(pretty: true) { } |
Build with indented output |
CrystalHTML.safe(str) |
Wrap string as SafeString (skip escaping) |
text(str) |
Add escaped text content |
raw(str) |
Add unescaped HTML |
comment(str) |
Add HTML comment |
doctype |
Add <!DOCTYPE html> |
fragment { } |
Group elements without a wrapper |
capture { } |
Capture block output as String |
render_if(bool) { } |
Conditional rendering |
render_unless(bool) { } |
Inverse conditional rendering |
select_tag { } |
Render <select> element |
Contributing
- Fork it (https://github.com/erayjsx/crystal-html/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
crystal-html
- 0
- 0
- 0
- 0
- 0
- about 9 hours ago
- February 15, 2026
Sun, 15 Feb 2026 11:01:38 GMT