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:
- Logic divergence: Helper method
names()vs. string interpolation - Maintenance burden: Every template change requires updates in two places
- Testing complexity: Need parallel test suites to verify behavioral parity
- 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:
- Single codebase: Maintain templates in one place (ERB)
- Guaranteed parity: JavaScript output matches ERB output exactly (same template, same logic)
- Battle-testing: ERB templates are used online, so offline behavior is tested by production usage
- 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:
- Static HTML:
<div class="container"> - Ruby output:
<%= person.name %> - Ruby code:
<% @people.each do |person| %>
For a working converter, we need to handle:
- Static HTML → String literals in JavaScript
- Ruby expressions → JavaScript equivalents
- Ruby blocks → JavaScript functions
The Challenge: Ruby ≠ JavaScript
Not all Ruby code can be mechanically translated:
# ERB template
<% @subjects.each do |subject| %>
<%= names(subject) %>
<% end %>
Translation challenges:
@subjects→data.subjects(instance variables → data object).eachwith block →.map()or.forEach()names(subject)→names(subject)(but where doesnames()come from?)- 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:
- Run the script with specific judge/heat parameters
- 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.htmlvs./tmp/erb_rendered.html)
- What data did the converter receive? (
- Diff the outputs:
diff /tmp/erb_rendered.html /tmp/js_rendered.html - 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
- Custom Elements components: 3,691 lines
- Heat data manager: 334 lines
- Total hand-written code: 4,025 lines
New ERB-to-JS Approach
- Infrastructure code: 1,370 lines
- ERB to JS converter: 232 lines
- Converter tests: 383 lines
- Stimulus controller: 229 lines
- Hydration logic: 395 lines
- Node hydration script: 58 lines
- Templates controller: 73 lines
- Auto-generated templates: 1,097 lines (from existing ERB)
- Hand-written code: 1,370 lines
Result: 66% less code (1,370 vs. 4,025 lines)
Bandwidth: Both Approaches Identical
Both approaches use the same bulk download strategy:
- Initial download: 176KB normalized JSON (all 1,362 heats)
- Navigation: Client-side only (no additional fetches)
- Templates: 38KB JavaScript (one-time download, cached)
- Total bandwidth: ~214KB
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:
- Instance variable conversion
- Block iteration (
.each,.map,.select) - Conditionals (
.if,.elsif,.unless) - String interpolation
- Helper method pass-through
- Nested structures
- Edge cases (empty strings, nil handling)
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:
- Maintainability: Single template codebase (66% less code than Web Components)
- Correctness: Guaranteed parity through automatic conversion
- Debuggability: Tools like
render_erb_and_js.rbisolate problems in seconds
What remains?
- Offline score posting: Queue score updates when offline, upload when connected (similar to Web Components
DirtyScoresQueue) - Progressive enhancement: Graceful fallback when JavaScript unavailable
- Production validation: Real-world usage at upcoming showcase events
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:
- Web Components SPA (4,025 lines) - proved offline capability, exposed complexity
- Turbo MVC spike (2,604 lines) - proved simpler architecture works, exposed template duplication
- 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:
- Web Components → Turbo spike pivot: weekend
- Turbo spike → ERB conversion pivot: days
- Total calendar time: ~3 weeks for three complete implementations
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:
- "Here's the Prism AST for a Ruby block—translate it to an arrow function"
- "These two outputs don't match—diff them and identify the bug"
- "Generate test cases for this edge case"
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:
- Web Components (4,025 lines): Offline capability works, but complexity and unfamiliar developer experience were concerning
- Turbo MVC (2,604 lines): Simpler architecture works, but template duplication remained
- 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:
- Simplicity: 66% less code than Web Components
- Correctness: Automatic parity through conversion
- Debuggability: Tools for rapid problem isolation
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.