Skip to content

tomazzlender/hecr

Repository files navigation

hecr

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 &lt;world&gt;!</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.

The idea, from the ground up

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.raw is 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 .hecr is 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.

Why it earns its keep

  • 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.build code 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/).

hecr vs stdlib ECR

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.ecr split (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.

Installation

Add to your shard.yml (pre-release — not yet published to a registry):

dependencies:
  hecr:
    github: tomazzlender/hecr

then 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.

Usage

1. Render a template

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 &lt;world&gt;, 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.

2. Attributes

flag = true
Hecr.render <<-'HECR'
  <input disabled={flag} class={["btn", flag && "on", nil]} title={"a<b"}>
  HECR
# => <input disabled class="btn on" title="a&lt;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.

3. Control flow

: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 <%!-- ... --%>.

4. Components are functions

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 lookup
Hecr.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.

5. The default slot

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
end
Hecr.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.

6. Named slots

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
end
record 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>
  HECR

Entries 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")}.

7. Template files

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"
end

Every 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.

8. Serving HTTP

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.listen

9. Built-ins

include Hecr::Components for <.link href="...">, <.dynamic_tag tag_name={name}> (runtime-validated tag names), and <.intersperse enumerable={...} :let={x}> with a <:separator> slot.

10. Tooling

  • hecr-lsp (also from shards 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 in editors/.

  • :class is CSS-modules-aware. Inside a :class value the server reads the template's paired .module.css (colocated, <%@ styles … %>, or the inline Styles module) and offers the real scoped class names: completion (with the CSS rule as documentation), go-to-definition that jumps to the .selector in 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: type Layout. 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 .selector or any :class token, 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 build produces the binary) formats .hecr files whole — and <<-'HECR' heredoc templates inside .cr files. It never changes rendering: whitespace-sensitive content is reproduced exactly; only safe structure is re-indented. Idempotent, CI-friendly via --check.

  • crystal build -Dhecr_annotate wraps rendered output in HTML comments marking every template boundary and component call site with file: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 .cr file 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>
       | ^
    

11. Optional: scoped styles with CSS modules and :class

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 (nil renders nothing, unknown names raise the provider's KeyError).

  • Module-qualified tokens compose across components. A Module.name element 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.name against Mod; cond && Mod.name toggles; and a template whose :class uses 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 a composes base don't repeat it. Dashed names map to methods (icon-leftStyles.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 module

surface 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.

How it works

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.

Status & documents

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 the hecr_live branch. main is the pure rendering engine.

  • docs/guide.mdstart 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, the raw footgun, 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 in grammars/).
  • examples/demo.cr — a full page exercising everything.

Development

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.cr

License

MIT — see LICENSE.

About

HTML + Embedded Crystal. Inspired by Phoenix.Component and HEEx from Elixir ecosystem.

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Contributors