0.0
The project is in a healthy, maintained state
jsx_rosetta is a JSX-to-Rails translator. It parses JSX via Babel (over a Node sidecar), lowers the parsed AST into a framework-agnostic semantic IR, and emits target output via pluggable backends. The initial backend produces ViewComponent classes paired with ERB templates; additional backends (Phlex, Slim, Phoenix LiveView, etc.) can be added against the same IR.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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 / Phlex / 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 as a single-file Phlex 2.x view class (no separate ERB)
jsx_rosetta translate path/to/Button.tsx --as=phlex -o app/components
# →   app/components/button.rb
#     app/components/button_controller.js     (when inline arrow handlers)
# With suffix:
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-suffix=Component -o app/components
# →   app/components/button_component.rb       (class ButtonComponent)
# With namespace:
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-namespace=Components -o app/components
# →   app/components/button.rb                 (class Components::Button)

# 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 | :phlex (default :view_component)
  • backend_options: Hash — per-backend options (see below)
  • helpers: nil | Hash | false — JSX-name → Rails-helper mapping (see below; ViewComponent/RailsView only)
  • layout: :sidecar | :flat (default :sidecar; ViewComponent only)
  • typescript: true — force the TypeScript Babel plugin
  • source_filename: — surfaces in parse errors

Phlex backend options (pass via backend_options:):

  • suffix: "Component" — append a suffix to the class name and file name. Mutually exclusive with namespace:.
  • namespace: "Components" — wrap the class in a module Namespace block. Mutually exclusive with suffix:.

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 |item, i| %>…<% end %>
{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 const bindings (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.
  • dangerouslySetInnerHTML and similar opaque attributes → flagged.
  • Reserved Rails controller names (in the routes script) → # WARNING: line listing collisions.

Resolving TODOs (optional Claude Code skill)

The repository ships an optional Claude Code skill and worker agent that automate the last-mile work of clearing # TODO: comments from the generated output. The skill is fully generic — it ships no app-specific assumptions, no design-system data, and no host-stack conventions. You bring those.

What it does:

  • A pure-Ruby corpus scanner (tools/discover_bailouts.rb) tallies the dropped-expression patterns in your generated output and surfaces likely design-system / config-namespace clusters.
  • A pure-Ruby substitution pass (tools/apply_substitutions.rb) reads a YAML you supply (match: regex + tokens: value table) and splices values into the generated render Foo.new(...) calls. Always validates with ruby -c before writing; reverts on failure.
  • LLM-driven recipes for React hooks, Apollo data fetching, event handlers, Next.js navigation, and module-level constants. These default to sharpening verbose TODOs into single-decision items rather than guessing translations.
  • A jsx-rosetta-resolve-todo-file worker agent for parallel fan-out across a corpus.

A reference Ant Design v5 token mapping ships under examples/ — drop-in usable if your source app uses Ant Design with default theming.

Layout in this repo:

skills/jsx-rosetta-resolve-todos/
├── SKILL.md                     # workflow + routing table
├── recipes/                     # per-TODO-class recipes
├── tools/                       # discover_bailouts.rb, apply_substitutions.rb
├── data/design_tokens.template.yml   # blank schema for your design system
└── examples/design_tokens.ant_design_v5.yml   # reference for Ant-using apps

agents/
└── jsx-rosetta-resolve-todo-file.md   # fan-out worker definition

Installing into your environment:

User-level (available in every Claude Code session on your machine):

mkdir -p ~/.claude/skills ~/.claude/agents
ln -s "$(pwd)/skills/jsx-rosetta-resolve-todos" ~/.claude/skills/
ln -s "$(pwd)/agents/jsx-rosetta-resolve-todo-file.md" ~/.claude/agents/

Project-level (only available in the consuming Rails app):

# from your Rails app's repo root
mkdir -p .claude/skills .claude/agents
cp -R /path/to/jsx_rosetta/skills/jsx-rosetta-resolve-todos .claude/skills/
cp /path/to/jsx_rosetta/agents/jsx-rosetta-resolve-todo-file.md .claude/agents/

In either case, your app-specific configuration (the populated data/design_tokens.yml, data/target_app_conventions.md, etc.) belongs inside the consuming app's .claude/skills/jsx-rosetta-resolve-todos/data/ — not here. Anything app-specific should be .gitignored in your Rails repo if it shouldn't be shared.

The scripts under tools/ are runnable directly with ruby — Claude Code is not required to use them. The recipes and the agent are what require Claude Code.

What's deferred

  • Translating React state primitives (useState / useEffect etc.). 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, RailsView, and Phlex (Slim, LiveView).
  • Suspense → Turbo Frame mapping (depends on a data-fetching translation story we don't have yet).

Emission targets

Three 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.

:phlex — emits a single-file Phlex 2.x view class. The JSX template lives inside view_template as Ruby method calls, not in a separate ERB file:

# app/components/card.rb (default mode)
# app/components/card_component.rb (suffix: "Component")
class Card < Phlex::HTML
  def initialize(title: nil)
    @title = title
  end

  def view_template
    div(class: "card") do
      h2 { plain @title }
      yield
    end
  end
end

Three mutually exclusive naming strategies (configurable):

  • default — class FlashyHeader < Phlex::HTML (collision-prone in large apps where names like Card / User / Image overlap with models).
  • suffix: "Component" — class FlashyHeaderComponent < Phlex::HTML, matches the ViewComponent layout, safest for migrations.
  • namespace: "Components" — wraps the class in a module Components block. Cross-references inside the namespace stay bare (render Card.new) and resolve via Ruby's constant lookup.

Stimulus handlers still emit a sibling _controller.js skeleton alongside the .rb — the data-controller/data-action attrs go inline on the element (emitted as data_controller:/data_action: kwargs; Phlex auto-hyphenates underscores at render time), the handler body goes into the JS skeleton as a TODO comment.

Attribute emission specifics:

  • Hyphenated JSX attrs (data-testid, aria-label) emit as snake_case kwargs (data_testid:, aria_label:). Phlex 2.x converts the underscores back to hyphens on render.
  • camelCase JSX attrs (viewBox, preserveAspectRatio) preserve verbatim — Phlex only converts underscores, so SVG attributes stay intact.
  • Attributes with characters that aren't valid Ruby identifiers (e.g. xml:lang) fall back to a quoted string key inside **{ "xml:lang" => x }.

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 → element mapping.
  • Suggests system "rails", "generate", "controller", … invocations (commented for review).
  • Consolidates /xs + /xs/:id pairs into resources :xs, only: %i[index show].
  • Emits match "*path", to: …, via: :all for catch-all routes.
  • Warns when a generated controller name collides with a reserved Rails term.
  • Prints a suggested config/routes.rb block.

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 JSX
  • spec/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.