Same Code, Same Output
Tests tell you the behavior is correct. They don't tell you the presentation is correct. A test can assert that an article was created and that the response redirected — but it won't catch that the label attributes are in a different order, or that a <textarea> is missing a newline, or that rows comes after class instead of before it. Tests verify what happens. Rendering verifies what the user sees.
To trust that a transpiler produces the right output, you need both. The blog demo — a standard Rails blog transpiled to JavaScript — passes all its tests on both runtimes. But that's necessary, not sufficient. So I built a tool that renders each page via both Rails and Juntos, and compares the HTML. Not approximately. Exactly.
Five pages, zero differences.
The Blog
The demo is the Rails Getting Started tutorial modernized with Turbo Streams, Action Cable, and Tailwind. Articles, nested comments, real-time broadcasting. Nothing exotic. Nothing modified for transpilation.
You can try it live in your browser right now — no install required. Or open the editor and change the Ruby source to see transpilation happen in real time. If you want it locally:
npx github:ruby2js/juntos --demo blog
cd blog
That gives you a real Rails app. bin/rails server runs it on Rails. bin/juntos dev -d dexie runs the same code in your browser. Same models, same controllers, same views.
Comparing Output
juntos compare renders each page via both Rails and Juntos, normalizes the HTML (stripping CSRF tokens, asset fingerprints, importmaps — the infrastructure differences), and diffs the rest:
$ npx juntos compare / /articles /articles/1 /articles/new /articles/1/edit
Rendering via Rails... done (5 pages)
Rendering via Juntos... done (5 pages)
/ ... match
/articles ... match
/articles/1 ... match
/articles/new ... match
/articles/1/edit ... match
5/5 pages match
Both sides read the same SQLite database. Both produce the same HTML structure, the same links, the same form fields, the same attributes in the same order. Add --diff and you get nothing — because there's nothing to show.
Same Tests, Too
The blog has 20 tests: model validations, controller CRUD, system tests for full user flows. They pass on Rails. They also pass on Juntos — the same test code, transpiled and run against the JavaScript version:
- Rails: 20 tests, 51 assertions, 0 failures
- Juntos: 22 tests, 0 failures (2 extra from transpiler-added coverage)
The system tests exercise real user flows: create an article with invalid data (see the validation errors), fix it, edit it, delete it, add comments, delete them. Same flows, same assertions, both runtimes.
What I Found
Reaching 5/5 took work. Not on the blog — on the transpiler. juntos compare surfaced real differences, and I fixed them:
<label>attribute order — Rails emitsclassbeforefor. Juntos had them reversed. The HTML was semantically identical but structurally different. Fixed.<textarea>attribute order — Rails putsrowsbeforeclass. Juntos putnamefirst. Fixed.<textarea>newline — Rails inserts a newline after the opening<textarea>tag. Juntos didn't. Fixed.belongs_tovalidation — Rails 5+ validates presence onbelongs_toby default. The JavaScript runtime didn't. A test caught this. Fixed.- Foreign key errors — Rails returns
falsefromsavewhen a FK constraint fails. The JavaScript runtime threw an exception. Another test caught this. Fixed.
None of these were visible in the running app — they only showed up in automated comparison. That's the point of the tool.
Three Things That Aren't Idiomatic
Honesty requires noting what's not perfect:
rescue nil in the Ruby source. The after_create_commit callback on Comment broadcasts to the articles channel. During seeding, there are no listeners, and Rails throws. The workaround: article.broadcast_replace_to("articles") rescue nil. Ironically, this is only needed for Rails — Juntos handles the no-listener case gracefully.
$new in the JavaScript output. Rails controllers have a new action. JavaScript reserves new as a keyword. The transpiler prefixes it: $new. It's the one place where the generated JavaScript can't match Ruby naming exactly. It works, it's consistent, and it's internal to the module — route dispatch handles the mapping.
BroadcastChannel.broadcast(...) with inline turbo stream HTML. When you write broadcasts_to ->(article) { "articles" }, Rails registers three callbacks internally. The transpiled JavaScript makes them explicit — you can see exactly what happens on create, update, and destroy. It's more verbose than the Ruby, but it's also more inspectable. Same tradeoff Rails makes internally, just visible.
Beyond Demos
A blog is a small app. But it exercises the core Rails stack: models with associations and validations, controllers with callbacks and strong parameters, ERB views with partials and form helpers, nested routes, and real-time broadcasting. If the transpiler gets this right — provably, automatically, identically — it can get larger apps right too.
The ballroom app is where that's being tested. It's a real dance competition management system: 34 models, 30 controllers, 547 Juntos tests passing. It's a work in progress — weeks, maybe months from complete — but it's being built with a strict discipline: write idiomatic Rails first, fix the transpiler to support it, verify the Juntos tests pass, compare the rendered output both ways. Every transpiler fix in this post came from that cycle. The blog surfaces them; ballroom drives them.
When ballroom is done, we'll have gone beyond demo apps to supporting a full Rails application — one with STI models, complex associations, Stimulus controllers, drag-and-drop reordering, and real competition data. The blog proves the approach works. Ballroom will prove it scales.
Juntos is open source: github.com/ruby2js/ruby2js. The blog demo docs have the full install and deployment guide.