Introducing JSX, but for Ruby

rubyrailsopen-sourcebuilding-in-public

We’ve been developing HERB internally for almost two years now. We’ve been dogfooding it in Sirius2, wrestling with the slot problem, converting Journey’s views. It’s been the template language we wished existed for ViewComponents: JSX-like syntax that makes component trees readable instead of burying them in ERB ceremony.

Today we’re open-sourcing it as ORB Template. New name, clean slate. The language is the same one we’ve been using internally, now packaged as a gem anyone can drop into a Rails project.

For anyone encountering it for the first time, here’s the problem it solves. When you lean heavily on ViewComponents, which we do across Sirius2, Meera, Journey, and more, ERB becomes a problem:

<%= render CardComponent.new(title: "Dashboard") do |card| %>
  <% card.with_section(title: "Stats") do %>
    <p>Content here</p>
  <% end %>
<% end %>

Five lines to render a card with one section. In a real design system where components nest three or four deep, it’s unreadable. The ERB tags blur together, you lose track of which do |variable| belongs to which component, and the actual structure of the page, the thing you care about, is buried under Ruby syntax.

Meanwhile the React folks are writing <Card title="Dashboard"> and calling it a day. Why can’t we have nice things?

Well, because nobody was bold enough, or naive enough, to invent a whole new HTML-like template language with extensions, a parser, and a compiler for it. And figure out all the edge cases. And deal with all the possible content security issues.

We started with one clear goal: we wanted to be able to write our markup like this and have it mapped to ViewComponents and rendered as plain HTML.

<Card title="Dashboard">
  <Card:Section title="Stats">
    <p>Content here</p>
  </Card:Section>
</Card>

That’s what ORB gives you. Files end in .html.orb, they compile to Ruby, and a Railtie hooks everything up so you drop them into your Rails views directory and they just work.

Visiting the Temple

I didn’t want to build a code generator from scratch. Temple already exists. It’s the compilation pipeline behind SLIM and HAML, and it handles all the boring parts: escaping, static string merging, control flow, output buffering. Parse your template into Temple’s intermediate representation, and Temple generates the Ruby that ActionView runs.

So the pipeline goes: ORB source → tokenizer → parser → AST → compiler → Temple IR → filters → Ruby string. Temple’s standard filter chain handles the tail end. I just had to get from ORB source to Temple IR.

The tokenizer was where most of the time went. HTML isn’t a regular language, and ORB layers expressions on top of it. The first pass was a character-by-character state machine using StringIO. It works but it’s slow, and I know I’ll be rewriting it with StringScanner eventually. The state machine ended up with more states than I expected. Every parsing context (inside a tag, inside an attribute, inside an expression, inside a comment) needs its own state, and the transitions get fiddly.

What it can do

Standard HTML passes through unchanged, which matters more than it sounds because most of a template is just HTML. Components start with a capital letter: <Button> resolves to ButtonComponent. Namespaces use dots: <Admin.Button> becomes Admin::ButtonComponent. Slots use colons: <Card:Section> maps to the section slot on Card.

Expressions: {{user.name}} for output, {% some_code %} for silent execution, class={active? ? "on" : "off"} for dynamic attributes. Splatting with **attributes.

Control flow comes in two flavors: block syntax ({#if condition}...{/if}) and directive syntax (:if={logged_in?} on a tag). They compile to the same Ruby. I use directives when the condition is simple and blocks when there’s an else branch.

Component resolution

When you write <Button>, the compiler needs to find ButtonComponent. In a small app that’s obvious. In an app with ButtonComponent, Admin::ButtonComponent, and Design::ButtonComponent, it’s not.

I went with a configurable namespace search. ORB.namespaces is an ordered array of module prefixes, resolution walks the list and returns the first match. For Sirius, we add the design system’s namespace once and every component tag just works without qualifying it.

The Component suffix gets stripped from the tag name. You write <Card>, not <CardComponent>. That suffix is a ViewComponent convention, not something the template author should think about.

Rails integration

This part was almost suspiciously easy. A Railtie registers .orb as a template handler. The handler calls the Temple engine. ActionView evaluates the result. Maybe fifty lines across three files.

The one catch was output buffering. Temple’s default generator returns a value, Rails expects writes to a buffer. Temple has a RailsOutputBuffer for this, but I needed to subclass it to handle captures correctly. When a component yields to a block, the inner content gets captured into its own buffer, and getting that handoff right took some digging into how ActionView actually processes template output.

What’s next

Version 0.1.0 is published. It handles everything we need for Sirius right now. What I know is missing: {#else} and {#elsif}, {#case} and {#unless}, and the tokenizer rewrite.

But I’ve been writing <Card title="Dashboard"> in our views and it’s hard to imagine going back to the ERB version. The component tree reads like a component tree now, not like a pile of Ruby with angle brackets scattered around.