crystal-html

Macro-driven HTML DSL for Crystal with zero dependencies. Clean syntax, compile-time tag generation, XSS protection by default.

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>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</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

  1. Fork it (https://github.com/erayjsx/crystal-html/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request
Repository

crystal-html

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 9 hours ago
  • February 15, 2026
License

Links
Synced at

Sun, 15 Feb 2026 11:01:38 GMT

Languages