jsx_rosetta
Translate React/JSX components into Rails 8.1 ViewComponent
classes (or plain Rails view templates) — and lift React Router config into a
runnable rails generate controller script.
jsx_rosetta parses JSX/TSX via Babel running in a Node sidecar, lowers the
parsed AST into a framework-agnostic semantic intermediate representation (IR), and emits target output
through pluggable backends. The pipeline is end-to-end working: a real React
app's components and routes can be translated, dropped into a fresh Rails 8.1
app, and rendered with only a handful of human edits at the TODO markers the
gem itself emits.
JSX text ──► Babel AST ──► Ruby AST ──► IR ──► backend ──► .rb / .html.erb / _controller.js
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / RoutesScript)
Installation
bundle add jsx_rosetta
bundle exec jsx_rosetta install # npm-installs the gem's Node sidecar deps
Requires:
- Ruby ≥ 3.2
- Node.js ≥ 18 (subprocess used for parsing)
The sidecar's node_modules is not bundled in the gem — jsx_rosetta install
runs npm install in the gem's vendored node/ directory. Set
JSX_ROSETTA_NODE if node is in a non-standard location.
CLI
# Translate a JSX/TSX component into a ViewComponent (sidecar layout, default)
jsx_rosetta translate path/to/Button.tsx -o app/components
# → app/components/button_component.rb
# app/components/button_component/button_component.html.erb
# app/components/button_component/button_controller.js (when inline arrow handlers)
# Translate a route-tied page as a Rails view template (no Ruby class)
jsx_rosetta translate path/to/Home.tsx --as=view -o app/views/home
# → app/views/home/home.html.erb (rename to index.html.erb)
# Extract <Routes><Route> entries into a runnable Ruby script
jsx_rosetta routes path/to/router.tsx -o tmp/generate_controllers.rb
# Review the script. Uncomment the system() calls you want, then:
ruby tmp/generate_controllers.rb
# Inspect the parsed Babel AST
jsx_rosetta parse path/to/Button.tsx > button.ast.json
# Install the Node sidecar deps
jsx_rosetta install
jsx_rosetta version
.tsx files are auto-detected from the extension; pass --tsx to force
TypeScript parsing on a .jsx-named file.
Library API
require "jsx_rosetta"
source = File.read("Button.tsx")
# Just the parsed AST (Babel-shaped, typed Ruby objects)
ast = JsxRosetta.parse(source)
ast.walk.find { |n| n.is_a?(JsxRosetta::AST::JSXElement) }.tag_name
# => "button"
# Lowered IR (semantic, backend-agnostic). Returns the first component.
ir = JsxRosetta.lower(source)
ir.props.map(&:name) # => ["children", "onClick", "variant"]
ir.stimulus_methods # event handlers extracted to Stimulus
ir.react_hooks # useState/useEffect/etc. flagged for the human
# All components in a multi-component file
JsxRosetta::IR.lower_all(JsxRosetta.parse(source), source: source)
# End-to-end translation. Returns an array of File value objects.
files = JsxRosetta.translate(source)
files.first.path # => "button_component.rb"
files.first.contents # => "# frozen_string_literal: true\n…"
# Same source as a plain Rails view (no .rb class, no sidecar dir)
JsxRosetta.translate(source, backend: :rails_view)
# Extract React Router routes
route_tree = JsxRosetta::Routes.lower(JsxRosetta.parse(File.read("router.tsx")))
JsxRosetta::Backend::RoutesScript.new(source_path: "router.tsx").emit(route_tree)
Optional kwargs to JsxRosetta.translate:
backend: :view_component | :rails_view(default:view_component)helpers: nil | Hash | false— JSX-name → Rails-helper mapping (see below)layout: :sidecar | :flat(default:sidecar, ignored for:rails_view)typescript: true— force the TypeScript Babel pluginsource_filename:— surfaces in parse errors
What translates
| JSX construct | Translation |
|---|---|
| Function & arrow components | class XComponent < ::ViewComponent::Base |
| Multi-component files | One .rb + .html.erb pair per exported component |
<button type="x"> |
Literal HTML attribute |
<a href={url}> |
href="<%= @url %>" when url is a prop |
<a href={/p/${slug}}> |
href="/p/<%= @slug %>" (template literal inlined) |
<a aria-label={x}> |
aria-label="<%= @x %>" on Element; "aria-label" => @x on Component |
<button type="button" {...rest}> |
<%= tag.button(type: "button", **@rest) do %>…<% end %> |
<Button variant="primary" /> |
<%= render ButtonComponent.new(variant: "primary") %> |
<Tabs.List> |
<%= render Tabs::ListComponent.new(...) %> |
<Link href="/x"> |
<%= link_to("/x") do %>…<% end %> (default helper map) |
<Image src="..." /> |
<%= image_tag("...", alt: "...") %> (default helper map) |
{children} (when prop) |
<%= content %> (ViewComponent default slot) |
{cond && <X />}, {cond ? X : Y} |
<% if %>…<% end %> / with <% else %> branch |
{items.map((item, i) => <X />)} |
`<% @items.each do \ |
{post.coverImage} |
@post.cover_image (member chains snake-cased) |
{!preview} |
!@preview (unary negation) |
className={cn("a", b, { c: cond })} |
class="a <%= @b %> <%= @cond ? "c" : '' %>" |
style={{ fontSize: 12 }} |
style="font-size: 12;" (kebab-case keys) |
{/* foo */} |
<%# foo %> |
{"foo"}, {42} |
Plain text (<%= " " %> clutter eliminated) |
{true}, {null} |
Dropped (matches React runtime) |
<hr />, <img /> |
Self-closing void elements |
onClick={handler} (handler is prop) |
data-action="<%= @handler %>" (Stimulus action descriptor) |
onClick={() => …} (inline) |
Stimulus controller method + data-action="click->name#methodName" |
const Comp = asChild ? X : "tag" |
Synthesized <% if @as_child %>…<% else %>…<% end %> |
Default values (x = "primary") |
Translated when literal/identifier/member-chain |
| Bare prop identifiers | @snake_case_name |
What's flagged for human review
The gem emits a <%# TODO: … %> comment whenever a translation isn't safe to
auto-perform. Common cases:
- React hooks (
useState,useEffect,useRef, …) → distinct comment block at the top of the template, with the Hotwire/Stimulus alternative noted and the original JS preserved verbatim. - Local non-JSX
constbindings (const date = parseISO(s)) → comment block at the top with the original JS preserved. - Unrecognized JS expressions (function calls, subscripts, complex ternaries) → inline TODO with verbatim source.
- Unresolved identifiers (imports like
EXAMPLE_PATH,CMS_NAME) → inline TODO listing the offending names so the human can wire them up. dangerouslySetInnerHTMLand similar opaque attributes → flagged.- Reserved Rails controller names (in the routes script) →
# WARNING:line listing collisions.
What's deferred
- Translating React state primitives (
useState/useEffectetc.). The policy is to flag and route behavior to Stimulus controllers, not to recreate React's runtime in Ruby. - React Router's data-router form (
createBrowserRouter([...])). Only the declarative<Routes><Route>shape is parsed today. - Backends other than ViewComponent and RailsView (Phlex, Slim, LiveView).
- Suspense → Turbo Frame mapping (depends on a data-fetching translation story we don't have yet).
ViewComponent emission targets
Two backends, picked via --as on the CLI or backend: in the API:
:view_component (default) — emits a sidecar layout matching
ViewComponent's --sidecar generator convention:
app/components/
card_component.rb # at top level
card_component/
card_component.html.erb # sidecar template
card_controller.js # Stimulus, when applicable
Pass layout: :flat to revert to the older flat layout
(card_component.rb + card_component.html.erb side-by-side).
:rails_view — for pages tied to a route. Emits one .html.erb with
no Ruby class and no sidecar. Place at app/views/<controller>/<action>.html.erb;
the controller's instance variables become the template's @x references.
Helper mappings
Capitalized JSX tags that have a direct Rails helper analog skip the
<%= render XComponent.new(...) %> indirection. Defaults:
| JSX | Emits |
|---|---|
<Link> |
<%= link_to(href, **rest) do %>…<% end %> |
<Image> |
<%= image_tag(src, alt:, **rest) %> |
Override:
JsxRosetta.translate(source, helpers: {
"Link" => { method: :link_to, positional: :href },
"Image" => { method: :image_tag, positional: :src },
"Btn" => { method: :button_to, positional: :url }
})
# Disable entirely (everything stays as <%= render XComponent.new(...) %>)
JsxRosetta.translate(source, helpers: false)
Stimulus integration
Inline arrow event handlers (and const-bound arrows referenced from
onX={handler}) extract to a generated <snake>_controller.js skeleton:
function CopyButton({ text }) {
const handleClick = () => navigator.clipboard.writeText(text);
return <button onClick={handleClick}>Copy</button>;
}
emits three files:
# copy_button_component.rb
class CopyButtonComponent < ::ViewComponent::Base
def initialize(text:); @text = text; end
end
<%# copy_button_component/copy_button_component.html.erb %>
<button data-controller="copy-button"
data-action="click->copy-button#handleClick">
Copy
</button>
// copy_button_component/copy_button_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
// TODO: translate from the original JSX handler:
// navigator.clipboard.writeText(text)
handleClick(event) {
// ...
}
}
For Rails to find sidecar Stimulus controllers, add to
config/importmap.rb:
pin_all_from "app/components", under: "components", preload: true
and to app/javascript/application.js:
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
eagerLoadControllersFrom("components", application)
Routes subcommand
jsx_rosetta routes router.tsx parses <Routes><Route> JSX and emits a
runnable Ruby script:
- Lists each parsed
path → elementmapping. - Suggests
system "rails", "generate", "controller", …invocations (commented for review). - Consolidates
/xs+/xs/:idpairs intoresources :xs, only: %i[index show]. - Emits
match "*path", to: …, via: :allfor catch-all routes. - Warns when a generated controller name collides with a reserved Rails term.
- Prints a suggested
config/routes.rbblock.
The script is meant to be reviewed and edited before ruby-running it.
Architecture
lib/jsx_rosetta/
parser.rb # JSX text → AST::File
node_bridge.rb # subprocess plumbing
ast/ # typed Ruby classes mirroring Babel
ir/
types.rb # IR value classes
lowering.rb # AST → IR
routes.rb # AST → IR::RouteTree (React Router)
backend/
base.rb
view_component.rb # IR → .rb + .html.erb (+ _controller.js)
view_component/expression_translator.rb
rails_view.rb # IR → .html.erb only
routes_script.rb # IR::RouteTree → reviewable Ruby script
cli.rb # exe/jsx_rosetta dispatch
node/
parse.js # stdin (JSX request) → stdout (Babel JSON AST)
package.json # @babel/parser
The IR sits between parsing and emission so additional backends (Phlex, Slim, LiveView, …) can be added without changing the frontend.
Development
bin/setup # bundle install + npm install in node/
bundle exec rspec # run the full test suite
bundle exec rubocop
End-to-end fixtures live in spec/fixtures/:
spec/fixtures/jsx/*.{jsx,tsx}— input JSXspec/fixtures/expected/*— hand-written expected output
For a worked example of dropping translated output into a real Rails 8.1 app,
see CHANGELOG.md — Phase 9 and Phase 9b describe the multi-route React
Router → Rails app demo end-to-end.
License
MIT.