intertwingly

It’s just data

Automatic ERB-to-JavaScript Conversion for Offline SPAs


Three weeks ago I built an offline-first scoring SPA with Web Components. It worked—judges could score during network outages, dirty scores queued in IndexedDB, everything synced when connectivity returned.

But 4,025 lines of code for a scoring interface felt wrong. The complexity, the unfamiliar developer experience, Shadow DOM debugging—was all that necessary for offline capability?

Last week I explored a radically simpler approach with Turbo navigation interception. The result was 2,604 lines (35% reduction), familiar Rails patterns, and clean architecture. The spike achieved complete feature parity with 146 passing tests.

Then I looked closely at those tests. They were structural stubs—they verified HTML elements existed, not that they contained correct data. The templates had to be manually reimplemented in JavaScript, diverging from ERB logic.

Both rewrites—Web Components and Turbo MVC—suffered from the same fundamental problemx: maintaining parallel template implementations, different mental model and developer experience, and difficulty debugging.

I had written Ruby2JS years ago—a tool for converting Ruby to JavaScript. Maybe the same approach could work here? What if ERB templates were automatically converted to JavaScript functions? Ruby 3.3's Prism parser could handle the Ruby expressions embedded in the templates.

This post documents a third ambitious rewrite—from idea through rapid prototyping to a working system that's simpler, more maintainable, and battle-tested by design.

The key insight: With Claude Code compressing exploration from weeks to days, ambitious rewrites become practical even when payback is uncertain. Had each approach taken weeks or months, I probably wouldn't have attempted the second rewrite—let alone the third. But at days per iteration, experimentation becomes affordable.


The Problem with Parallel Templates

My original Web Components implementation had ~4,000 lines of JavaScript, including hand-written template rendering:

// Web Components approach: manual template reimplementation
renderHeatTable(heat) {
  return html`
    <div class="heat-container">
      ${heat.subjects.map(subject => html`
        <div class="subject-row">
          <span class="back-number">${subject.back}</span>
          <span class="names">${subject.lead.name} & ${subject.follow.name}</span>
          ${this.renderScoreInputs(subject)}
        </div>
      `)}
    </div>
  `;
}

Meanwhile, the ERB template handled the same logic differently:

<div class="heat-container">
  <% @subjects.each do |subject| %>
    <div class="subject-row">
      <span class="back-number"><%= subject.lead.back %></span>
      <span class="names"><%= names(subject) %></span>
      <%= render 'score_inputs', subject: subject %>
    </div>
  <% end %>
</div>

The problems:

  1. Logic divergence: Helper method names() vs. string interpolation
  2. Maintenance burden: Every template change requires updates in two places
  3. Testing complexity: Need parallel test suites to verify behavioral parity
  4. Subtle bugs: Easy to miss edge cases when reimplementing

The Turbo navigation spike didn't solve this—it just moved the problem from Web Components to template strings.


The Insight: Invert the Problem

Instead of maintaining two template systems, what if ERB templates were the single source of truth, and JavaScript templates were automatically generated?

This inversion has profound implications:

  1. Single codebase: Maintain templates in one place (ERB)
  2. Guaranteed parity: JavaScript output matches ERB output exactly (same template, same logic)
  3. Battle-testing: ERB templates are used online, so offline behavior is tested by production usage
  4. Zero duplication: No parallel implementations to drift apart

But could it actually work? ERB templates contain Ruby code—loops, conditionals, helper methods. How do you convert that to JavaScript?


Enter Ruby's Prism Parser

Ruby 3.3 introduced Prism, a new official parser for Ruby code. Unlike the old Ripper parser, Prism produces a clean Abstract Syntax Tree (AST) that's designed for tooling.

From my Ruby2JS experience, I knew Prism could parse Ruby expressions embedded in ERB templates. The key insight: we don't need to execute Ruby code—we just need to translate Ruby expressions to equivalent JavaScript.

Parsing Strategy

ERB templates have three types of content:

  1. Static HTML: <div class="container">
  2. Ruby output: <%= person.name %>
  3. Ruby code: <% @people.each do |person| %>

For a working converter, we need to handle:

The Challenge: Ruby ≠ JavaScript

Not all Ruby code can be mechanically translated:

# ERB template
<% @subjects.each do |subject| %>
  <%= names(subject) %>
<% end %>

Translation challenges:

  1. @subjectsdata.subjects (instance variables → data object)
  2. .each with block → .map() or .forEach()
  3. names(subject)names(subject) (but where does names() come from?)
  4. Block variables (|subject|) → Arrow function parameters

The Converter Architecture

The approach drew from Ruby2JS patterns, and Claude Code helped rapidly prototype and iterate. Within hours, we had a working hybrid approach:

class ErbToJsConverter
  def convert(erb_template)
    # Step 1: Parse ERB into tokens (Herb gem)
    erb_tokens = Herb::ERBScanner.scan(erb_template)

    # Step 2: Process each token
    js_code = erb_tokens.map do |token|
      case token.type
      when :text
        # Static HTML → String literal
        "html += #{token.content.to_json};\n"
      when :expr
        # <%= ... %> → Append to html string
        js_expr = translate_ruby_to_js(token.content)
        "html += (#{js_expr}) || '';\n"
      when :stmt
        # <% ... %> → JavaScript control flow
        translate_ruby_statement(token.content)
      end
    end.join

    # Step 3: Wrap in JavaScript function
    wrap_as_function(js_code)
  end
end

Translation Examples

Ruby instance variables → Data object access:

@subjects          → data.subjects
@event.open_scoring → data.event.open_scoring

Ruby blocks → JavaScript arrow functions:

@subjects.each do |subject|  →  data.subjects.forEach(subject => {
  # ...                           // ...
end                             });

Ruby helpers → Pass through (defined separately):

names(subject)     →  names(subject)
dom_id(heat)       →  domId(heat)

Handling Complex Cases

Conditionals:

# ERB
<% if @event.assign_judges > 0 %>
  <label>Judge Assignment</label>
<% end %>

# Generated JavaScript
if (data.event.assign_judges > 0) {
  html += `<label>Judge Assignment</label>\n`;
}

Nested Iterations:

# ERB
<% @ballrooms.each do |ballroom, heats| %>
  <div class="ballroom-<%= ballroom %>">
    <% heats.each do |heat| %>
      <%= render 'heat_row', heat: heat %>
    <% end %>
  </div>
<% end %>

# Generated JavaScript
Object.entries(data.ballrooms).forEach(([ballroom, heats]) => {
  html += `<div class="ballroom-${ballroom}">\n`;
  heats.forEach(heat => {
    html += heatRow({heat});
  });
  html += `</div>\n`;
});

The Implementation

Core Components (1,370 Lines Total)

1. ERB to JS Converter (lib/erb_to_js_converter.rb, 232 lines)

Parses ERB templates, translates Ruby to JavaScript, generates template functions.

2. Templates Controller (app/controllers/templates_controller.rb, 73 lines)

Serves converted templates as ES modules:

class TemplatesController < ApplicationController
  def scoring
    erb_template = File.read(Rails.root.join('app/views/scores/_heat.html.erb'))
    converter = ErbToJsConverter.new
    js_code = converter.convert(erb_template)

    render js: js_code, content_type: 'text/javascript'
  end
end

3. Heat Hydrator (app/javascript/lib/heat_hydrator.js, 395 lines)

Shared logic for converting normalized data structures to nested objects:

export function buildHeatTemplateData(heatNumber, rawData, style) {
  // Build lookup tables
  const lookups = buildLookupTables(rawData);

  // Find heats with this number
  const heatsWithNumber = rawData.heats.filter(h => h.number === heatNumber);

  // Hydrate: convert IDs to full objects
  const hydratedHeats = heatsWithNumber.map(h => hydrateHeat(h, lookups));

  // Build complete template data structure
  return {
    event: rawData.event,
    judge: rawData.judge,
    subjects: hydratedHeats,
    // ... complete data structure
  };
}

4. Stimulus Controller (app/javascript/controllers/heat_app_controller.js, 229 lines)

Orchestrates loading and rendering:

export default class extends Controller {
  async connect() {
    // Load converted templates once
    this.templates = await this.loadTemplates();

    // Load normalized data once (176KB for 1362 heats)
    await this.loadAllData();

    // Render current heat
    this.showHeat(this.heatValue);
  }

  async loadTemplates() {
    const response = await fetch('/templates/scoring.js');
    const code = await response.text();
    return await import(`data:text/javascript,${encodeURIComponent(code)}`);
  }

  showHeat(heatNumber) {
    // Build template data (client-side, no fetch)
    const data = buildHeatTemplateData(heatNumber, this.rawData, this.styleValue);

    // Render using converted templates
    const html = this.templates.heat(data);

    // Replace DOM
    this.element.innerHTML = html;
  }

  navigateToHeat(heatNumber) {
    // Update URL
    const url = new URL(window.location);
    url.searchParams.set('heat', heatNumber);
    window.history.pushState({}, '', url);

    // Re-render (no fetch! data already loaded)
    this.showHeat(heatNumber);
  }
}

5. Node Hydration Script (scripts/hydrate_heats.mjs, 58 lines)

Enables testing and debugging outside the browser:

#!/usr/bin/env node
import fs from 'fs';
import { buildHeatTemplateData } from '../app/javascript/lib/heat_hydrator.js';

const [,, judgeId, heatNumber, style = 'radio', dataFile] = process.argv;

const allData = JSON.parse(fs.readFileSync(dataFile, 'utf-8'));
const templateData = buildHeatTemplateData(parseInt(heatNumber), allData, style);

console.log(JSON.stringify(templateData, null, 2));

The Debugging Tool: render_erb_and_js.rb

This turned out to be the most valuable piece of infrastructure. When template behavior differs between ERB and JavaScript, this script renders both versions side-by-side and saves intermediate artifacts:

$ RAILS_APP_DB=2025-barcelona-november ruby scripts/render_erb_and_js.rb 83 136

================================================================================
Rendering ERB version...
================================================================================
✓ ERB rendered: 12,847 bytes, 8 <tr> tags
  Saved to: /tmp/erb_rendered.html

================================================================================
Rendering JavaScript version...
================================================================================
✓ Templates fetched: 38,912 bytes
  Saved to: /tmp/scoring_templates.js
✓ Heat 136 hydrated with 1 subjects
  Saved template data to: /tmp/js_template_data.json
✓ JS rendered: 12,847 bytes, 8 <tr> tags
  Saved to: /tmp/js_rendered.html

================================================================================
Comparison Summary
================================================================================
ERB: 8 rows, 12,847 bytes
JS:  8 rows, 12,847 bytes
✓ Row counts match!

Files saved to /tmp/ for analysis:

  HTML outputs:
    /tmp/erb_rendered.html         - ERB template output
    /tmp/js_rendered.html          - JavaScript template output

  JavaScript (for debugging):
    /tmp/scoring_templates.js      - Converted templates from /templates/scoring.js

  JSON data (for debugging):
    /tmp/heats_data.json           - Raw normalized data from /heats/data endpoint
    /tmp/js_template_data.json     - Complete template data (after buildHeatTemplateData)

Why This Is Powerful:

When a test fails or behavior differs, you can:

  1. Run the script with specific judge/heat parameters
  2. Inspect intermediate artifacts:
    • What data did the converter receive? (/tmp/js_template_data.json)
    • What JavaScript was generated? (/tmp/scoring_templates.js)
    • What HTML was rendered? (/tmp/js_rendered.html vs. /tmp/erb_rendered.html)
  3. Diff the outputs: diff /tmp/erb_rendered.html /tmp/js_rendered.html
  4. Isolate the problem: Pinpoint exactly where the conversion failed

This script compresses hours of debugging into minutes. Instead of running the full Rails app, starting a browser, navigating to the right heat, and inspecting the DOM, you get five files with complete debugging context in seconds.


Comparison: Code Size & Complexity

Original Web Components Approach

New ERB-to-JS Approach

Result: 66% less code (1,370 vs. 4,025 lines)

Bandwidth: Both Approaches Identical

Both approaches use the same bulk download strategy:

The key insight: normalized data structures are incredibly efficient. Instead of sending complete nested objects for each heat (10KB × 1,362 = 13.6MB), send lookup tables once (176KB) and hydrate client-side.


Testing Strategy: Automated Parity Verification

The converter includes comprehensive tests (383 lines) that verify conversion correctness:

class ErbToJsConverterTest < ActiveSupport::TestCase
  def assert_converts(erb, expected_js)
    converter = ErbToJsConverter.new
    actual_js = converter.convert(erb)
    assert_equal normalize_js(expected_js), normalize_js(actual_js)
  end

  test "converts instance variables to data access" do
    assert_converts(
      '<%= @event.name %>',
      'html += (data.event.name) || "";'
    )
  end

  test "converts each loops to forEach" do
    assert_converts(
      '<% @subjects.each do |subject| %><%= subject.name %><% end %>',
      'data.subjects.forEach(subject => { html += (subject.name) || ""; });'
    )
  end

  test "handles nested conditionals" do
    assert_converts(
      '<% if @event.assign_judges > 0 %><% if @show == "only" %>Active<% end %><% end %>',
      'if (data.event.assign_judges > 0) { if (data.show == "only") { html += "Active\n"; } }'
    )
  end
end

42 tests covering:

But the real test is behavioral: does the JavaScript render the same HTML as ERB?

That's what render_erb_and_js.rb verifies—not just unit tests of conversion logic, but end-to-end rendering parity with actual production data.


The Path Forward

The ERB-to-JS converter delivers three critical properties:

  1. Maintainability: Single template codebase (66% less code than Web Components)
  2. Correctness: Guaranteed parity through automatic conversion
  3. Debuggability: Tools like render_erb_and_js.rb isolate problems in seconds

What remains?

But the architecture is sound. The converter works. The tests pass. The bandwidth is efficient.

Sometimes the best solution comes from inverting the problem: instead of maintaining two template systems, generate one from the other automatically.


Lessons Learned

1. Fast Iteration Enables Ambitious Rewrites

Three ambitious rewrites in three weeks:

  1. Web Components SPA (4,025 lines) - proved offline capability, exposed complexity
  2. Turbo MVC spike (2,604 lines) - proved simpler architecture works, exposed template duplication
  3. ERB-to-JS conversion (1,370 lines) - eliminated template duplication entirely

Without Claude Code, each rewrite would take weeks or months. The uncertain payback would make attempting even the first rewrite questionable—let alone the second or third.

With Claude Code, each iteration took days. Exploration became affordable:

Claude Code compressed what could have been weeks of exploration into hours. The key was bringing domain knowledge (Ruby2JS experience, Prism parser capabilities) and letting Claude Code handle rapid iteration:

The value isn't just code generation—it's making ambitious rewrites practical by compressing the feedback loop from idea to working prototype. When iteration is fast, you can afford to be wrong twice before finding the right solution.

2. Build Debugging Tools Early

render_erb_and_js.rb emerged organically during development—"I need to see what data this template receives"—but its value compounded over time. Now it's the first tool I reach for when investigating template issues.

Design for debuggability from the start. Tools that expose intermediate artifacts pay dividends throughout the project lifecycle.

3. Guaranteed Parity Beats Manual Verification

The Web Components approach required constant vigilance: "Did I implement this Ruby helper correctly in JavaScript?" With automatic conversion, that question disappears.

Make correctness automatic whenever possible. The converter might generate verbose JavaScript, but it generates correct JavaScript—and that's worth more than elegance.

4. Productive Dead Ends Are Part of Exploration

The Turbo spike looked promising—intercept navigation, use Service Workers, render with template strings. But as implementation progressed, the templates revealed themselves as stubs. The complexity of maintaining parallel template logic became clear.

That "failure" was valuable. It clarified the real problem (template duplication) and pointed toward automatic conversion as the solution. The code got thrown away, but the understanding didn't.

Dead ends aren't wasted effort when they compress the solution space.

5. Complexity Has a Cost

The Web Components implementation was architecturally sound—Shadow DOM, custom elements, encapsulation. But was that complexity necessary? For this use case, no.

Choose the simplest architecture that solves the problem. Sometimes Web Components are the right tool. Sometimes Stimulus + template conversion is simpler.


Conclusion

Three ambitious rewrites in three weeks. Each iteration revealed new insights:

  1. Web Components (4,025 lines): Offline capability works, but complexity and unfamiliar developer experience were concerning
  2. Turbo MVC (2,604 lines): Simpler architecture works, but template duplication remained
  3. ERB-to-JS (1,370 lines): Automatic conversion eliminates duplication entirely

The journey wasn't just about reducing code size—it was about finding the right level of abstraction. Web Components provided structure but required reimplementation. Template strings provided simplicity but required duplication. Automatic conversion provided parity without duplication.

The key insight: ERB templates can be the single source of truth for both server and client rendering. With the right tooling (Prism parser, shared hydration logic, debugging scripts), you get offline-capable SPAs without maintaining parallel template implementations.

Would I have attempted three rewrites without Claude Code? Probably not. The uncertain payback—each rewrite might fail to improve things—would make the investment questionable. But when each iteration takes days instead of weeks, ambitious exploration becomes practical.

Is this the final architecture? There's more work ahead (offline posting, production validation). But it's a solid foundation built on:

The broader lesson: AI-assisted development doesn't just make individual tasks faster—it changes the economics of exploration. When iteration is fast, you can afford to be wrong twice before finding the right solution. That makes ambitious rewrites practical when payback is uncertain.

Sometimes the best solution emerges after multiple attempts. Fast iteration makes multiple attempts affordable.


The code for this project is available in the showcase repository. The ERB-to-JS converter is in lib/erb_to_js_converter.rb, and the debugging script is in scripts/render_erb_and_js.rb. Full test suite in test/lib/erb_to_js_converter_test.rb.