Compile-time, HTML-aware component templates for Crystal. You write HTML with {...}
Crystal expressions; hecr parses and validates it at build time and compiles it to ordinary
Crystal — string appends and typed function calls. There is no runtime template engine, and
components are plain functions.
require "hecr"
def hello(name : String) : Hecr::Safe
Hecr.render <<-'HECR'
<h1>Hello {name}!</h1>
HECR
end
hello("<world>") # => <h1>Hello <world>!</h1>A mistyped attribute, a mismatched tag, or a syntax error inside {...} fails the build,
pointing at the template line with a source excerpt and caret.
hecr rests on three convictions; each one makes the next one possible.
1 — A template is HTML, so the engine understands HTML. Most template languages treat
markup as opaque text with <%= %> holes punched in it: they can't tell a <div> from a
<span>, don't know whether the cursor sits in an attribute or in body text, and will
happily ship an unclosed tag. hecr has a real HTML tokenizer and parser. Because it
understands the markup, it can catch structural mistakes before they ship, escape values
correctly for where they appear, and make components and slots first-class pieces of the
template instead of helper calls smuggled through interpolation. Understanding the HTML is
what unlocks everything else.
2 — A template is code, so the compiler checks and compiles it. A template is source
that produces source, and Crystal already has a compiler — so hecr uses it. Templates become
plain Crystal at build time: a mismatched tag or a broken {expr} is a compile error with
a file, line, and caret, not a 500 in production. And because the generated code is ordinary
string appends with capacity hints, rendering costs what hand-written code costs — measured
within ~4% of hand-tuned String.build — with no interpreter to run at request time.
3 — A component is a function. A component is just a function from data to markup, and
Crystal functions already have everything a component contract needs: typed parameters,
defaults, splats, generics. So a hecr component is a Crystal function returning
Hecr::Safe, and its signature is its attribute specification — enforced by the compiler
at every call site, dynamic values included. No component base class, no registry, no
separate props schema; the language's own type system does the checking.
Three properties fall out of these:
- Safe by default. The default failure mode of string templating is XSS, so escaping is
automatic and opt-out (
Hecr.rawis the only bypass), and aware of context — the parser knows an attribute value from body text. - The scope is the contract. Expressions see ordinary lexical scope: locals and method
arguments. There is no assigns map and no magic
@variables— the data a template needs is the data already in scope. - Errors land in the template. A
.hecris a real source file, so compile errors and runtime backtraces point at its line and column (from app/pages/index.hecr:9:3), never into generated code.
- Total attribute checking. A missing or mistyped component attribute is a compile error — dynamic values included — with the component's full signature printed in the message.
- A real expression parser. Embedded
{...}expressions are parsed by Crystal's own lexer/parser, so anything valid in Crystal is valid in a template —{h({1 => "}"})}and all — and an invalid expression fails at build time with a precise location, before any code is generated. - Scoped CSS, checked. Pair a template with a CSS module and
:class="primary"compiles to a method call on a generated styles module — a misspelled class name is a build error pointing at the template, not a silently unstyled element. - Zero-cost rendering — measured. Compiled string appends with capacity hints; default
slots are non-capturing blocks the compiler inlines; streaming straight into a socket is
built in. Benchmarked within ~4% of hand-written
String.buildcode with identical allocations, and ~2.7× faster than idiomatic stdlib ECR (bench/). - Specified, regression-proof semantics. Every rendering decision — escaping, attribute
semantics, class lists, spreads,
:if/:for, components, slots — is locked by a byte-for-byte differential harness that runs in CI, so the HTML hecr emits is exact and stable, not improvised (differential/).
Crystal ships ECR (require "ecr"), and both compile templates to string
appends at build time — hecr's processor is ECR's architecture generalized.
Whether the extra machinery earns its keep depends on what you render:
| ECR | hecr | |
|---|---|---|
| Output format | any text (HTML, mail, config, SQL…) | HTML only |
| HTML escaping | none — every <%= %> injects raw (escape by hand, forget once and it's XSS) |
escaped by default; Hecr.raw is the only bypass |
| HTML validation | none — unclosed/mismatched tags ship | compile error with template line + caret |
| Components | none (call helpers via <%= %>) |
<.button> = typed function call; contracts, defaults, globals, slots, :let |
| Declarative control flow | <% if %>/<% each %> only |
same, plus :if/:for attributes, spreads, class lists, boolean-attr semantics |
| Inline templates | no — files only (ECR.embed "file.ecr") |
yes (Hecr.render <<-'HECR') and files |
| Streaming to IO | yes | yes (Hecr.embed) |
| Error mapping into templates | yes (loc pragmas) | yes (same mechanism), plus expression syntax checked before codegen |
| Rendering speed | baseline | ~2.7× faster on the benchmarked page (bench/ — capacity hints, fewer intermediate strings) |
| Tooling | none | formatter, full LSP (diagnostics, completion, rename, code actions, …), tree-sitter + TextMate grammars, debug annotations |
| Dependency | stdlib, zero deps | a shard, pre-release |
| First build | seconds | ~45 s once (processor compile, then cached) |
| Learning surface | trivial | a dialect to learn (documented in docs/dialect.md) |
Use ECR when:
- the output is not HTML — escaping-by-default and tag validation would fight you on plain-text mail, config files, or SQL;
- you render deliberately unbalanced fragments — the classic
header.ecr/footer.ecrsplit (open<div>in one file, close it in another) is illegal in hecr, which requires every template to balance (the hecr way is a layout component with slots instead); - you want zero dependencies or maximum API stability.
Use hecr when the output is HTML an application serves. Escaping by default removes a whole vulnerability class; tag validation catches the broken markup ECR happily ships; components give you typed, compiler-checked reuse that ECR has no answer to — and you give up no performance for any of it. The honest costs are the dialect to learn, a dependency, the one-time processor compile, and HTML-only scope.
Add to your shard.yml (pre-release — not yet published to a registry):
dependencies:
hecr:
github: tomazzlender/hecrthen shards install and require "hecr". The first build compiles the template
processor once (~45 s); it is cached after that, and each template adds ~0.4 ms to builds.
For a task-oriented walkthrough, see docs/guide.md. What follows is the
same ground as a progressive tour.
Hecr.render takes an inline template and returns Hecr::Safe — a zero-cost wrapper
marking already-escaped HTML. Use a raw heredoc (<<-'HECR') so Crystal doesn't
interpolate #{} before the template compiler sees it; interpolation in templates is
{expr} and is HTML-escaped by default:
name = "<world>"
Hecr.render(<<-'HECR').to_s
<p>Hello {name}, 2 + 2 = {2 + 2}</p>
HECR
# => <p>Hello <world>, 2 + 2 = 4</p>Expressions see whatever is in lexical scope — locals, method arguments. There is no
assigns map and no @foo: the scope is the contract. Hecr.raw(string) is the only
escape hatch from escaping; Safe values pass through untouched, nil renders nothing.
flag = true
Hecr.render <<-'HECR'
<input disabled={flag} class={["btn", flag && "on", nil]} title={"a<b"}>
HECR
# => <input disabled class="btn on" title="a<b">The attribute rules: true renders the bare attribute, false/nil omit it
(class/style render ="" instead), class lists flatten with falsy members dropped,
and static attributes (class="panel") are author-trusted and emitted verbatim. A {...}
standing alone in attribute position is a spread:
extra = {id: "save", "data-confirm": "Sure?"}
Hecr.render <<-'HECR'
<button {extra} class="btn">Save</button>
HECR
# => <button id="save" data-confirm="Sure?" class="btn">Save</button>On HTML elements a spread can be a NamedTuple, Hash, or any enumerable of pairs.
:if and :for work on any element (and on components and slot entries below); combined,
:if filters per item. :for patterns are block-argument forms, including tuple
destructuring:
users = [{"Ada", true}, {"Bob", false}]
Hecr.render <<-'HECR'
<ul>
<li :for={{name, admin} <- users} :if={admin} class="admin">{name}</li>
</ul>
HECR
# => <ul>
# <li class="admin">Ada</li>
# </ul>For multi-statement logic, EEx tags hold plain Crystal: <% if x %> ... <% else %> ... <% end %>, output with <%= expr %>, compile-time comments with <%!-- ... --%>.
A component is any function returning Hecr::Safe. The signature is the attribute
specification: required attrs are required arguments, defaults are defaults, and the
double splat collects global attributes (data-*, aria-*, anything extra):
module Components
extend self
def button(label : String, variant : String = "primary", **rest) : Hecr::Safe
Hecr.render <<-'HECR'
<button class={["btn", "btn-#{variant}"]} {rest}>{label}</button>
HECR
end
end
include Components # local components resolve by ordinary method lookupHecr.render <<-'HECR'
<.button label="Delete" variant="danger" data-confirm="Sure?" disabled/>
HECR
# => <button class="btn btn-danger" data-confirm="Sure?" disabled>Delete</button><.button> is a call to button(...) — resolved by method lookup (use include, or call
remotely as <Components.button .../>). Because it is just a typed call, the compiler
enforces the contract at every call site, dynamic values included. Forget label and the
build fails with:
Error: missing argument: label
Overloads are:
- Components#button(label : String, variant : String = "primary", **rest)
Spreads work on components too (<.button {attrs}/>) with merge semantics — explicit
attributes win — but must be NamedTuples, since component attributes are checked at
compile time.
Body content becomes a block; inside the component's template, <content/> renders it.
<content {value}/> passes values back to the caller's :let:
def panel(title : String, &) : Hecr::Safe
Hecr.render <<-'HECR'
<section class="panel"><h2>{title}</h2><content/></section>
HECR
end
def list(items : Array(T), &) : Hecr::Safe forall T
Hecr.render <<-'HECR'
<ul><li :for={item <- items}><content {item}/></li></ul>
HECR
endHecr.render <<-'HECR'
<.list items={[1, 2, 3]} :let={n}><b>{n * 10}</b></.list>
HECR
# => <ul><li><b>10</b></li><li><b>20</b></li><li><b>30</b></li></ul>Generic components (forall T) are fully type-checked per call site — the :let binding
is typed without any annotation in the template.
Declare a slot builder with Hecr.slots; the component yields it and renders the
collected entries however it likes. Each entry carries its attributes and a
render(let_value) : Hecr::Safe:
module Components
Hecr.slots TableSlots(T) do
col label : String, let : T
end
def table(rows : Array(T), & : TableSlots(T) ->) : Hecr::Safe forall T
slots = TableSlots(T).new
yield slots
Hecr.render <<-'HECR'
<table>
<thead><tr><th :for={c <- slots.col}>{c.label}</th></tr></thead>
<tbody><tr :for={row <- rows}><td :for={c <- slots.col}>{c.render(row)}</td></tr></tbody>
</table>
HECR
end
endrecord User, name : String, email : String, admin : Bool
users = [User.new("Ada", "ada@example.com", true), User.new("Bob", "bob@example.com", false)]
Hecr.render <<-'HECR'
<.panel title="Users">
<.table rows={users}>
<:col label="Name" :let={u}>{u.name}</:col>
<:col label="Email" :let={u}><.button label={u.email} variant="link"/></:col>
<:col label="Admin" :let={u}><span :if={u.admin}>yes</span></:col>
</.table>
</.panel>
HECREntries accumulate in source order (slots.col is an array — iterate it, count it, use
entry attributes), :for/:if work on entries, body-less entries (<:col label="x"/>)
render nothing instead of raising, and the default slot coexists with named ones (an
inner slot is generated automatically; slots.render_inner). Empty-slot fallbacks are
plain Crystal: {slots.footer.first?.try(&.render) || Hecr.raw("no footer")}.
Three file modes, all paths relative to the calling file:
# Returns Safe:
page = Hecr.template "pages/index.hecr"
# Streams into any IO — no intermediate string:
Hecr.embed "pages/index.hecr", io
# One typed component per file:
module Pages
extend self
Hecr.embed_templates "components/*.hecr"
endEvery embed_templates file opens with a contract directive — a verbatim Crystal def
head whose name matches the file basename. The file carries its own typed interface, and
an optional <%!-- ... --%> comment right before it becomes the generated function's doc
comment in crystal docs:
<%!-- A labelled counter badge. --%>
<%@ def badge(label : String, count : Int32 = 0) %>
<span class="badge">{label} ({count})</span>Pages.badge(label: "from a file", count: 7)
# => <span class="badge">from a file (7)</span>Calling it without label, from Crystal or from any template, is a compile error showing
the signature — sourced from the .hecr file.
Hecr.embed writes straight to the response socket:
require "http/server"
require "hecr"
server = HTTP::Server.new do |ctx|
ctx.response.content_type = "text/html; charset=utf-8"
heading = "hecr"
items = ["compile-time templates", "streamed straight to the socket"]
Hecr.embed "pages/index.hecr", ctx.response.output
end
server.bind_tcp 8080
server.listeninclude Hecr::Components for <.link href="...">, <.dynamic_tag tag_name={name}>
(runtime-validated tag names), and <.intersperse enumerable={...} :let={x}> with a
<:separator> slot.
-
hecr-lsp(also fromshards build): a full language server — diagnostics as you type (incl. unknown-component warnings), completion, references, rename (with contract-file renames both directions), code-action quick fixes, hover, signature help, linked tag editing, folding, call hierarchy, document formatting, a show-generated-code command, and go-to-component definition — wiring ineditors/. -
:classis CSS-modules-aware. Inside a:classvalue the server reads the template's paired.module.css(colocated,<%@ styles … %>, or the inlineStylesmodule) and offers the real scoped class names: completion (with the CSS rule as documentation), go-to-definition that jumps to the.selectorin the stylesheet, hover showing the rule, and live warnings on class names absent from the stylesheet — the same check the compiler enforces at build time, now as you type. This works for module-qualified tokens too: typeLayout.to complete a shared module's classes, and jump/hover/warn resolve into that module's stylesheet. Find-references, document-highlight and rename span both sides: from a.selectoror any:classtoken, see every usage across the workspace, and rename a class to rewrite the stylesheet and every template (bare and qualified) in one edit. -
hecr format [--check] PATH...(shards buildproduces the binary) formats.hecrfiles whole — and<<-'HECR'heredoc templates inside.crfiles. It never changes rendering: whitespace-sensitive content is reproduced exactly; only safe structure is re-indented. Idempotent, CI-friendly via--check. -
crystal build -Dhecr_annotatewraps rendered output in HTML comments marking every template boundary and component call site withfile:line— debug annotations toggled by a compile flag, not by configuration. -
Errors point at templates. A mismatched tag inside an inline heredoc at line 11 of your
.crfile reports:app.cr:11:1: unmatched closing tag. Expected </div> for <div> at line 10, got: </section> | 9 | <section> 10 | <div class="card"> 11 | </section> | ^
Everything above renders fine with plain class="..."; this section is entirely optional,
and hecr takes on no CSS dependency to offer it. It is worth reading only if one specific
problem bothers you.
Class names are the one part of a template the compiler can't see. You write
class="card-title" in the markup and .card-title { … } in a stylesheet, and nothing
connects the two: misspell either side and the element is silently unstyled — no error, no
warning, just a missing style you notice by eye. Rename a class and you hand-hunt every use
across templates and CSS. And class names are global, so two components that each define
.title quietly collide — the reason whole naming conventions and utility frameworks exist
to work around it. In an otherwise type-checked codebase, class names are a stringly-typed
seam.
:class closes that seam. Pair a template with a CSS module and its class names become
compile-checked references to a generated module: a typo fails the build pointing at the
template line, names are scoped (hashed) so two components can't collide, and the
language server completes them, jumps to the CSS rule, and renames a class across the
stylesheet and every template at once. If unchecked, colliding, hard-to-refactor class names
are a pain you actually feel, this is for you; if not, skip it and keep using class="...".
Here is how it works.
The :class attribute resolves class names through a CSS modules provider at compile
time. lightening_css_modules-crystal
is the reference provider — it turns .module.css files into modules of scoped class
names (Lightning CSS underneath). Colocate a stylesheet with each template and name the
provider once:
components/
├── button.hecr
└── button.module.css
/* button.module.css */
.base { padding: .5rem 1rem; border-radius: .25rem; }
.primary { composes: base; background: royalblue; color: white; }
.danger { composes: base; background: crimson; color: white; }
.disabled { opacity: .5; pointer-events: none; }<%@ def button(label : String, kind : String = "primary", disabled : Bool = false) %>
<button :class={["base", kind, disabled && "disabled"]} class="js-hook" type="button">{label}</button>module Components
extend self
Hecr.embed_templates "components/*.hecr", styles: LightningCSSModules
end
Components.button("Save")
# => <button class="base_x4f2 primary_x4f2 js-hook" type="button">Save</button>
# (scoped names as hashed by the CSS module; the composed base appears once)The value forms, and what each buys you:
-
String tokens are compile-checked.
:class="primary"— or the"base"literal in the array above — compiles to a method call on the generated styles module, so a misspelled class name fails the build, pointing into the template:In components/button.hecr:3:30 3 | <button :class={["basee", kind, disabled && "disabled"]} ... Error: undefined method 'basee' for Components::ButtonStyles:Module Did you mean 'base'? -
cond && "name"toggles a class and keeps the compile check;:class={kind}and other expressions are runtime lookups (nilrenders nothing, unknown names raise the provider'sKeyError). -
Module-qualified tokens compose across components. A
Module.nameelement reaches into any styles module in scope — not just the paired default — and is compile-checked against it:<button :class={[Layout.row, Type.heading, "primary"]} type="button">{label}</button> # ^shared module ^shared module ^this template's default
This is
className={cx(layout.row, type.heading, styles.primary)}from the React/CSS- modules world — except every token is checked at compile time. Bare strings still resolve against the paired default;Mod.nameagainstMod;cond && Mod.nametoggles; and a template whose:classuses only qualified tokens needs no pairing at all. So shared, cross-component styling stays fully inside CSS modules — no global utility classes. (Both the array form above and a single:class={Type.heading}work.) -
A literal
class="..."on the same tag merges in after the scoped names (handy for JS hooks); duplicate tokens collapse, so classes sharing acomposesbase don't repeat it. Dashed names map to methods (icon-left→Styles.icon_left).
Pairing is by convention — <basename>.module.css next to the template — or explicit
with a directive: <%@ styles "./ui.module.css" %> shares one stylesheet across
templates, <%@ styles SomeModule %> names an existing styles module. Inline
Hecr.render templates resolve :class against a Styles module in scope, defined once
with LightningCSSModules.styles next to the component.
Several modules in one component. A component isn't limited to one stylesheet. Bare
tokens resolve to the template's own paired module; module-qualified Mod.name tokens reach
any other styles module in scope. So a component can layer its own local classes with shared
design-system modules — typography, layout, a colour palette — in a single :class, every
token compile-checked against the module it names. Define the shared modules once, anywhere
in scope:
module Components
extend self
# shared design-system modules, reachable from every template below
LightningCSSModules.styles "design/typography.module.css", name: Typography
LightningCSSModules.styles "design/layout.module.css", name: Layout
Hecr.embed_templates "components/*.hecr", styles: LightningCSSModules
end<%@ def card(title : String) %>
<article :class={["surface", Layout.stack, Typography.heading]}>{title}</article>
# ^card.module.css ^layout module ^typography modulesurface is card's own scoped class (from its colocated card.module.css), while
Layout.stack and Typography.heading pull scoped classes from the two shared modules —
three stylesheets composed on one element, none of them global, each name still checked at
build time. A component with no styles of its own can skip the colocated file and use only
qualified tokens (then it needs no pairing at all).
hecr itself never reads CSS and takes no CSS dependency. styles: accepts any module
exposing a .styles(path, name:) macro that generates one method per class plus
[](String) — that is the entire provider contract, so the CSS toolchain stays
swappable.
The Hecr.* macros invoke a compile-time processor (via Crystal's run macro — the same
mechanism as stdlib ECR) that tokenizes the template, validates HTML structure and every
embedded expression with Crystal's own parser, and emits plain Crystal — string appends
and function calls — spliced at the call site. <.button> is method lookup; attributes
are named arguments; #<loc> pragmas map errors and backtraces back to templates. There
is no runtime engine. The full language is specified in
docs/dialect.md, including the precise semantics of every attribute
and built-in.
All planned milestones (M0–M4) are complete: dialect + front end, the rendering engine,
typed contracts and named slots, built-ins, compile-checked scoped styles (:class + CSS
modules), formatter, CLI, language server, debug annotations, CI, differential rendering
tests, benchmarks, and editor grammars (TextMate + tree-sitter). 228 specs green.
Structured rendering and
Hecr::Live(static/dynamic diffing for real-time, live-updating views) are developed on thehecr_livebranch.mainis the pure rendering engine.
docs/guide.md— start here: task-oriented guide from install to named slots, file contracts, streaming, and tooling.docs/dialect.md— the normative template-language spec.SECURITY.md— the escaping model, therawfootgun, and DoS bounds.docs/discovery.md— verified knowns, decision log, milestones.differential/— byte-for-byte differential tests pinning rendering semantics.bench/— rendering benchmarks vs hand-written code and ECR.spikes/— 10 runnable discovery experiments, all passing.editors/— setup for VS Code, Neovim, Sublime, IntelliJ, Zed, Helix (grammars ingrammars/).examples/demo.cr— a full page exercising everything.
crystal spec # 228 specs, incl. end-to-end template compilation
crystal tool format --check
shards build && ./bin/hecr format --check spec/fixtures/
# run the demo as a shard consumer would:
CRYSTAL_PATH=$PWD/src:$(crystal env CRYSTAL_PATH) crystal run examples/demo.crMIT — see LICENSE.