A new Template Language for Ruby

rubyrailsbuilding-in-public

This idea has been stewing in my head since 2018, when I first worked with JSX and React. Say what you want about the React ecosystem. The way JSX expresses component trees is genuinely great. <Button variant="primary"> is clean, readable, and looks like the HTML it produces. I wanted that for Ruby.

But the Ruby world wasn’t ready. We didn’t have a component model, just partials and helpers and the occasional presenter object. Then ViewComponent came along and gave us proper encapsulated components with typed interfaces. And over in Elixir, the Surface library brought JSX-like syntax to Phoenix templates, proving the concept worked for a server-rendered language.

Meanwhile we were building Wave CI and Meera the old way. ERB partials, Slim templates, <%= render ComponentName.new(...) do %> ceremony everywhere. Every do |block_variable| threaded through nested components. Every <% end %> closer. And now with Sirius2 we have even more components to wrangle. I knew there had to be a better way.

So I finally sat down with one goal: bring JSX-like syntax to Ruby. We’re calling it HERB.

What a template language actually is

It sounds intimidating but the architecture is well-understood. You need three things:

A tokenizer: a state machine that reads source text and produces tokens. This is where you decide what the syntax looks like. The tokenizer needs to distinguish HTML tags from component tags (lowercase vs. uppercase first letter), recognize expression delimiters ({{ }} for output, {% %} for control flow), handle attribute values in all their forms (quoted, unquoted, expression-bound), and deal with comments.

The tricky part is that meaning is context-dependent. A { inside an attribute value starts an expression binding, but a { in text content only starts a printing expression if followed by another {. Every parsing context (inside a tag, inside an attribute, inside an expression, inside a comment) has its own rules, and the state machine has to track which context it’s in.

A parser: takes the token stream and builds a tree. Tags become TagNodes with children. Expressions become PrintingExpressionNode or ControlExpressionNode. Text stays as TextNode. The tree mirrors the document structure. This part is actually the most straightforward. Once you have clean tokens, building the tree is mechanical.

A compiler: walks the AST and produces something executable. This is where I got lucky: Temple exists. It’s the compilation pipeline behind Slim and HAML, and it handles all the tedious parts: output escaping, buffer management, static string merging, control flow. Instead of generating Ruby strings directly, the compiler produces Temple’s intermediate representation. An HTML tag becomes [:html, :tag, name, attributes, content]. A printing expression becomes [:escape, true, [:dynamic, expression]]. Temple takes it from there.

The Rails integration is almost anticlimactically simple. A Railtie registers .herb as a template handler, the handler calls the Temple engine, ActionView evaluates the result.

What surprised me

The tokenizer is the hard part. Not conceptually, because the state machine idea is simple. But the number of states grows fast. You need separate states for: inside a tag name, inside an attribute name, inside a quoted attribute value, inside an expression attribute value, inside text content, inside a comment, inside a printing expression, inside a control expression. Each state has its own transition rules. My first implementation reads the source character by character using StringIO, and it’s already getting unwieldy. I can see a rewrite to StringScanner in the future.

Context-dependent meaning is everywhere. The > character closes a tag, unless you’re inside an attribute value, where it’s just text. The / character makes a tag self-closing, unless you’re inside an attribute value. The { character means different things in five different contexts. Every rule has exceptions that depend on where you are in the parse.

Component resolution is a design decision, not an implementation detail. When someone writes <Button>, HERB needs to find ButtonComponent. But which one? In a large app there might be ButtonComponent, Admin::ButtonComponent, Design::ButtonComponent. The resolution order matters, and there’s no obviously right answer. For now I’m using a simple namespace search, but this will need to become configurable.

Error messages are a feature, not an afterthought. My first iteration produced error messages like “unexpected token :tag_close in state :attribute_value_expression”. Completely useless unless you wrote the tokenizer. The error needs to say “line 12: expected closing } for expression starting at line 12, column 15.” Getting error reporting right is going to take real effort because it means tracking source locations through the entire pipeline.

What works now

Standard HTML passes through unchanged. Components render from uppercase tags. Expressions use {{ }} for escaped output and {% %} for silent execution. Static and dynamic attributes both work.

<Card title="Settings">
  <Card:Section title="General">
    {{@user.name}}
  </Card:Section>
</Card>

What doesn’t work yet: control flow blocks ({#if}, {#for}), directives on elements, splat attributes, and slots, which I suspect are going to be their own adventure. But the foundation is solid: tokenizer, parser, Temple compiler, Rails integration. Everything else is features on top of that pipeline.

Next step: start using HERB in a real project and find out where it breaks.