jsx_rosetta 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # jsx_rosetta
2
+
3
+ Translate React/JSX components into Rails 8.1 [ViewComponent](https://viewcomponent.org/)
4
+ classes (or plain Rails view templates) — and lift React Router config into a
5
+ runnable `rails generate controller` script.
6
+
7
+ `jsx_rosetta` parses JSX/TSX via Babel running in a Node sidecar, lowers the
8
+ parsed AST into a framework-agnostic semantic intermediate representation (IR), and emits target output
9
+ through pluggable backends. The pipeline is end-to-end working: a real React
10
+ app's components and routes can be translated, dropped into a fresh Rails 8.1
11
+ app, and rendered with only a handful of human edits at the TODO markers the
12
+ gem itself emits.
13
+
14
+ ```
15
+ JSX text ──► Babel AST ──► Ruby AST ──► IR ──► backend ──► .rb / .html.erb / _controller.js
16
+ (Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / RoutesScript)
17
+ ```
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ bundle add jsx_rosetta
23
+ bundle exec jsx_rosetta install # npm-installs the gem's Node sidecar deps
24
+ ```
25
+
26
+ Requires:
27
+ - Ruby ≥ 3.2
28
+ - Node.js ≥ 18 (subprocess used for parsing)
29
+
30
+ The sidecar's `node_modules` is **not** bundled in the gem — `jsx_rosetta install`
31
+ runs `npm install` in the gem's vendored `node/` directory. Set
32
+ `JSX_ROSETTA_NODE` if `node` is in a non-standard location.
33
+
34
+ ## CLI
35
+
36
+ ```bash
37
+ # Translate a JSX/TSX component into a ViewComponent (sidecar layout, default)
38
+ jsx_rosetta translate path/to/Button.tsx -o app/components
39
+ # → app/components/button_component.rb
40
+ # app/components/button_component/button_component.html.erb
41
+ # app/components/button_component/button_controller.js (when inline arrow handlers)
42
+
43
+ # Translate a route-tied page as a Rails view template (no Ruby class)
44
+ jsx_rosetta translate path/to/Home.tsx --as=view -o app/views/home
45
+ # → app/views/home/home.html.erb (rename to index.html.erb)
46
+
47
+ # Extract <Routes><Route> entries into a runnable Ruby script
48
+ jsx_rosetta routes path/to/router.tsx -o tmp/generate_controllers.rb
49
+ # Review the script. Uncomment the system() calls you want, then:
50
+ ruby tmp/generate_controllers.rb
51
+
52
+ # Inspect the parsed Babel AST
53
+ jsx_rosetta parse path/to/Button.tsx > button.ast.json
54
+
55
+ # Install the Node sidecar deps
56
+ jsx_rosetta install
57
+
58
+ jsx_rosetta version
59
+ ```
60
+
61
+ `.tsx` files are auto-detected from the extension; pass `--tsx` to force
62
+ TypeScript parsing on a `.jsx`-named file.
63
+
64
+ ## Library API
65
+
66
+ ```ruby
67
+ require "jsx_rosetta"
68
+
69
+ source = File.read("Button.tsx")
70
+
71
+ # Just the parsed AST (Babel-shaped, typed Ruby objects)
72
+ ast = JsxRosetta.parse(source)
73
+ ast.walk.find { |n| n.is_a?(JsxRosetta::AST::JSXElement) }.tag_name
74
+ # => "button"
75
+
76
+ # Lowered IR (semantic, backend-agnostic). Returns the first component.
77
+ ir = JsxRosetta.lower(source)
78
+ ir.props.map(&:name) # => ["children", "onClick", "variant"]
79
+ ir.stimulus_methods # event handlers extracted to Stimulus
80
+ ir.react_hooks # useState/useEffect/etc. flagged for the human
81
+
82
+ # All components in a multi-component file
83
+ JsxRosetta::IR.lower_all(JsxRosetta.parse(source), source: source)
84
+
85
+ # End-to-end translation. Returns an array of File value objects.
86
+ files = JsxRosetta.translate(source)
87
+ files.first.path # => "button_component.rb"
88
+ files.first.contents # => "# frozen_string_literal: true\n…"
89
+
90
+ # Same source as a plain Rails view (no .rb class, no sidecar dir)
91
+ JsxRosetta.translate(source, backend: :rails_view)
92
+
93
+ # Extract React Router routes
94
+ route_tree = JsxRosetta::Routes.lower(JsxRosetta.parse(File.read("router.tsx")))
95
+ JsxRosetta::Backend::RoutesScript.new(source_path: "router.tsx").emit(route_tree)
96
+ ```
97
+
98
+ Optional kwargs to `JsxRosetta.translate`:
99
+ - `backend: :view_component | :rails_view` (default `:view_component`)
100
+ - `helpers: nil | Hash | false` — JSX-name → Rails-helper mapping (see below)
101
+ - `layout: :sidecar | :flat` (default `:sidecar`, ignored for `:rails_view`)
102
+ - `typescript: true` — force the TypeScript Babel plugin
103
+ - `source_filename:` — surfaces in parse errors
104
+
105
+ ## What translates
106
+
107
+ | JSX construct | Translation |
108
+ | ------------------------------------ | ---------------------------------------------------------- |
109
+ | Function & arrow components | `class XComponent < ::ViewComponent::Base` |
110
+ | Multi-component files | One `.rb` + `.html.erb` pair per exported component |
111
+ | `<button type="x">` | Literal HTML attribute |
112
+ | `<a href={url}>` | `href="<%= @url %>"` when `url` is a prop |
113
+ | `<a href={`/p/${slug}`}>` | `href="/p/<%= @slug %>"` (template literal inlined) |
114
+ | `<a aria-label={x}>` | `aria-label="<%= @x %>"` on Element; `"aria-label" => @x` on Component |
115
+ | `<button type="button" {...rest}>` | `<%= tag.button(type: "button", **@rest) do %>…<% end %>` |
116
+ | `<Button variant="primary" />` | `<%= render ButtonComponent.new(variant: "primary") %>` |
117
+ | `<Tabs.List>` | `<%= render Tabs::ListComponent.new(...) %>` |
118
+ | `<Link href="/x">` | `<%= link_to("/x") do %>…<% end %>` (default helper map) |
119
+ | `<Image src="..." />` | `<%= image_tag("...", alt: "...") %>` (default helper map) |
120
+ | `{children}` (when prop) | `<%= content %>` (ViewComponent default slot) |
121
+ | `{cond && <X />}`, `{cond ? X : Y}` | `<% if %>…<% end %>` / with `<% else %>` branch |
122
+ | `{items.map((item, i) => <X />)}` | `<% @items.each do \|item, i\| %>…<% end %>` |
123
+ | `{post.coverImage}` | `@post.cover_image` (member chains snake-cased) |
124
+ | `{!preview}` | `!@preview` (unary negation) |
125
+ | `className={cn("a", b, { c: cond })}`| `class="a <%= @b %> <%= @cond ? "c" : '' %>"` |
126
+ | `style={{ fontSize: 12 }}` | `style="font-size: 12;"` (kebab-case keys) |
127
+ | `{/* foo */}` | `<%# foo %>` |
128
+ | `{"foo"}`, `{42}` | Plain text (`<%= " " %>` clutter eliminated) |
129
+ | `{true}`, `{null}` | Dropped (matches React runtime) |
130
+ | `<hr />`, `<img />` | Self-closing void elements |
131
+ | `onClick={handler}` (handler is prop)| `data-action="<%= @handler %>"` (Stimulus action descriptor) |
132
+ | `onClick={() => …}` (inline) | Stimulus controller method + `data-action="click->name#methodName"` |
133
+ | `const Comp = asChild ? X : "tag"` | Synthesized `<% if @as_child %>…<% else %>…<% end %>` |
134
+ | Default values (`x = "primary"`) | Translated when literal/identifier/member-chain |
135
+ | Bare prop identifiers | `@snake_case_name` |
136
+
137
+ ## What's flagged for human review
138
+
139
+ The gem emits a `<%# TODO: … %>` comment whenever a translation isn't safe to
140
+ auto-perform. Common cases:
141
+
142
+ - **React hooks** (`useState`, `useEffect`, `useRef`, …) → distinct comment
143
+ block at the top of the template, with the Hotwire/Stimulus alternative
144
+ noted and the original JS preserved verbatim.
145
+ - **Local non-JSX `const` bindings** (`const date = parseISO(s)`) → comment
146
+ block at the top with the original JS preserved.
147
+ - **Unrecognized JS expressions** (function calls, subscripts, complex
148
+ ternaries) → inline TODO with verbatim source.
149
+ - **Unresolved identifiers** (imports like `EXAMPLE_PATH`, `CMS_NAME`) →
150
+ inline TODO listing the offending names so the human can wire them up.
151
+ - **`dangerouslySetInnerHTML` and similar opaque attributes** → flagged.
152
+ - **Reserved Rails controller names** (in the routes script) → `# WARNING:`
153
+ line listing collisions.
154
+
155
+ ## What's deferred
156
+
157
+ - Translating React state primitives (`useState` / `useEffect` etc.). The
158
+ policy is to flag and route behavior to Stimulus controllers, not to
159
+ recreate React's runtime in Ruby.
160
+ - React Router's data-router form (`createBrowserRouter([...])`). Only the
161
+ declarative `<Routes><Route>` shape is parsed today.
162
+ - Backends other than ViewComponent and RailsView (Phlex, Slim, LiveView).
163
+ - Suspense → Turbo Frame mapping (depends on a data-fetching translation
164
+ story we don't have yet).
165
+
166
+ ## ViewComponent emission targets
167
+
168
+ Two backends, picked via `--as` on the CLI or `backend:` in the API:
169
+
170
+ **`:view_component` (default)** — emits a sidecar layout matching
171
+ ViewComponent's `--sidecar` generator convention:
172
+
173
+ ```
174
+ app/components/
175
+ card_component.rb # at top level
176
+ card_component/
177
+ card_component.html.erb # sidecar template
178
+ card_controller.js # Stimulus, when applicable
179
+ ```
180
+
181
+ Pass `layout: :flat` to revert to the older flat layout
182
+ (`card_component.rb` + `card_component.html.erb` side-by-side).
183
+
184
+ **`:rails_view`** — for pages tied to a route. Emits one `.html.erb` with
185
+ no Ruby class and no sidecar. Place at `app/views/<controller>/<action>.html.erb`;
186
+ the controller's instance variables become the template's `@x` references.
187
+
188
+ ## Helper mappings
189
+
190
+ Capitalized JSX tags that have a direct Rails helper analog skip the
191
+ `<%= render XComponent.new(...) %>` indirection. Defaults:
192
+
193
+ | JSX | Emits |
194
+ | -------- | ---------------------------------------------- |
195
+ | `<Link>` | `<%= link_to(href, **rest) do %>…<% end %>` |
196
+ | `<Image>`| `<%= image_tag(src, alt:, **rest) %>` |
197
+
198
+ Override:
199
+
200
+ ```ruby
201
+ JsxRosetta.translate(source, helpers: {
202
+ "Link" => { method: :link_to, positional: :href },
203
+ "Image" => { method: :image_tag, positional: :src },
204
+ "Btn" => { method: :button_to, positional: :url }
205
+ })
206
+
207
+ # Disable entirely (everything stays as <%= render XComponent.new(...) %>)
208
+ JsxRosetta.translate(source, helpers: false)
209
+ ```
210
+
211
+ ## Stimulus integration
212
+
213
+ Inline arrow event handlers (and const-bound arrows referenced from
214
+ `onX={handler}`) extract to a generated `<snake>_controller.js` skeleton:
215
+
216
+ ```jsx
217
+ function CopyButton({ text }) {
218
+ const handleClick = () => navigator.clipboard.writeText(text);
219
+ return <button onClick={handleClick}>Copy</button>;
220
+ }
221
+ ```
222
+
223
+ emits three files:
224
+
225
+ ```ruby
226
+ # copy_button_component.rb
227
+ class CopyButtonComponent < ::ViewComponent::Base
228
+ def initialize(text:); @text = text; end
229
+ end
230
+ ```
231
+
232
+ ```erb
233
+ <%# copy_button_component/copy_button_component.html.erb %>
234
+ <button data-controller="copy-button"
235
+ data-action="click->copy-button#handleClick">
236
+ Copy
237
+ </button>
238
+ ```
239
+
240
+ ```javascript
241
+ // copy_button_component/copy_button_controller.js
242
+ import { Controller } from "@hotwired/stimulus";
243
+
244
+ export default class extends Controller {
245
+ // TODO: translate from the original JSX handler:
246
+ // navigator.clipboard.writeText(text)
247
+ handleClick(event) {
248
+ // ...
249
+ }
250
+ }
251
+ ```
252
+
253
+ For Rails to find sidecar Stimulus controllers, add to
254
+ `config/importmap.rb`:
255
+
256
+ ```ruby
257
+ pin_all_from "app/components", under: "components", preload: true
258
+ ```
259
+
260
+ and to `app/javascript/application.js`:
261
+
262
+ ```js
263
+ import { application } from "controllers/application"
264
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
265
+ eagerLoadControllersFrom("controllers", application)
266
+ eagerLoadControllersFrom("components", application)
267
+ ```
268
+
269
+ ## Routes subcommand
270
+
271
+ `jsx_rosetta routes router.tsx` parses `<Routes><Route>` JSX and emits a
272
+ runnable Ruby script:
273
+
274
+ - Lists each parsed `path → element` mapping.
275
+ - Suggests `system "rails", "generate", "controller", …` invocations
276
+ (commented for review).
277
+ - Consolidates `/xs` + `/xs/:id` pairs into `resources :xs, only: %i[index show]`.
278
+ - Emits `match "*path", to: …, via: :all` for catch-all routes.
279
+ - Warns when a generated controller name collides with a reserved Rails term.
280
+ - Prints a suggested `config/routes.rb` block.
281
+
282
+ The script is meant to be reviewed and edited before `ruby`-running it.
283
+
284
+ ## Architecture
285
+
286
+ ```
287
+ lib/jsx_rosetta/
288
+ parser.rb # JSX text → AST::File
289
+ node_bridge.rb # subprocess plumbing
290
+ ast/ # typed Ruby classes mirroring Babel
291
+ ir/
292
+ types.rb # IR value classes
293
+ lowering.rb # AST → IR
294
+ routes.rb # AST → IR::RouteTree (React Router)
295
+ backend/
296
+ base.rb
297
+ view_component.rb # IR → .rb + .html.erb (+ _controller.js)
298
+ view_component/expression_translator.rb
299
+ rails_view.rb # IR → .html.erb only
300
+ routes_script.rb # IR::RouteTree → reviewable Ruby script
301
+ cli.rb # exe/jsx_rosetta dispatch
302
+ node/
303
+ parse.js # stdin (JSX request) → stdout (Babel JSON AST)
304
+ package.json # @babel/parser
305
+ ```
306
+
307
+ The IR sits between parsing and emission so additional backends (Phlex,
308
+ Slim, LiveView, …) can be added without changing the frontend.
309
+
310
+ ## Development
311
+
312
+ ```bash
313
+ bin/setup # bundle install + npm install in node/
314
+ bundle exec rspec # run the full test suite
315
+ bundle exec rubocop
316
+ ```
317
+
318
+ End-to-end fixtures live in `spec/fixtures/`:
319
+ - `spec/fixtures/jsx/*.{jsx,tsx}` — input JSX
320
+ - `spec/fixtures/expected/*` — hand-written expected output
321
+
322
+ For a worked example of dropping translated output into a real Rails 8.1 app,
323
+ see `CHANGELOG.md` — Phase 9 and Phase 9b describe the multi-route React
324
+ Router → Rails app demo end-to-end.
325
+
326
+ ## License
327
+
328
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/jsx_rosetta ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "jsx_rosetta"
5
+
6
+ exit JsxRosetta::CLI.new(ARGV.dup).run
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsxRosetta
4
+ module AST
5
+ # Internal helpers for converting between Babel's camelCase field names
6
+ # and Ruby's snake_case conventions.
7
+ module Inflector
8
+ module_function
9
+
10
+ def underscore(string)
11
+ string
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .downcase
15
+ end
16
+
17
+ def camelize(string)
18
+ parts = string.split("_")
19
+ parts[0] + parts[1..].map(&:capitalize).join
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inflector"
4
+
5
+ module JsxRosetta
6
+ module AST
7
+ # Base class for every Babel-shaped AST node. Wraps the raw JSON hash
8
+ # and provides:
9
+ # * Field access via `node[:opening_element]` (snake_case symbols or
10
+ # camelCase strings — both resolve to the same field).
11
+ # * Source location accessors (`loc`, `range`, `start_pos`, `end_pos`).
12
+ # * Tree traversal via `each_child` / `walk`.
13
+ # * Pattern-matching support via `deconstruct_keys`.
14
+ #
15
+ # Specific Babel node types may register subclasses that add named
16
+ # accessors (e.g. JSXElement#opening_element). Unknown types fall
17
+ # through to the generic Node class so the parser doesn't crash on
18
+ # ESNext additions.
19
+ class Node
20
+ TYPE_REGISTRY = {} # rubocop:disable Style/MutableConstant
21
+
22
+ attr_reader :raw
23
+
24
+ def self.register(*type_names)
25
+ type_names.each { |name| TYPE_REGISTRY[name] = self }
26
+ end
27
+
28
+ def self.wrap(value)
29
+ case value
30
+ when Hash
31
+ if value.key?("type")
32
+ klass = TYPE_REGISTRY.fetch(value["type"], Node)
33
+ klass.new(value)
34
+ else
35
+ value
36
+ end
37
+ when Array
38
+ value.map { |element| wrap(element) }
39
+ else
40
+ value
41
+ end
42
+ end
43
+
44
+ def initialize(raw)
45
+ @raw = raw
46
+ end
47
+
48
+ def type
49
+ @raw["type"]
50
+ end
51
+
52
+ def loc
53
+ @raw["loc"]
54
+ end
55
+
56
+ def range
57
+ @raw["range"]
58
+ end
59
+
60
+ def start_pos
61
+ @raw["start"]
62
+ end
63
+
64
+ def end_pos
65
+ @raw["end"]
66
+ end
67
+
68
+ # Field access. Accepts snake_case symbols/strings (translated to
69
+ # camelCase) and camelCase strings (used verbatim).
70
+ def [](key)
71
+ raw_key = key.to_s
72
+ return Node.wrap(@raw[raw_key]) if @raw.key?(raw_key)
73
+
74
+ camel_key = Inflector.camelize(raw_key)
75
+ Node.wrap(@raw[camel_key])
76
+ end
77
+
78
+ def dig(*keys)
79
+ keys.reduce(self) do |current, key|
80
+ break nil if current.nil?
81
+
82
+ current[key]
83
+ end
84
+ end
85
+
86
+ def each_child(&block)
87
+ return enum_for(:each_child) unless block
88
+
89
+ @raw.each_value { |value| yield_descendant_nodes(value, &block) }
90
+ end
91
+
92
+ def children
93
+ each_child.to_a
94
+ end
95
+
96
+ def walk(&block)
97
+ return enum_for(:walk) unless block
98
+
99
+ yield self
100
+ each_child { |child| child.walk(&block) }
101
+ end
102
+
103
+ # Pattern-matching support. Returns a hash with snake_case symbol
104
+ # keys; values are wrapped (Node instances or arrays of Node/raw values).
105
+ def deconstruct_keys(keys)
106
+ if keys.nil?
107
+ @raw.each_with_object({}) do |(k, v), out|
108
+ out[Inflector.underscore(k).to_sym] = Node.wrap(v)
109
+ end
110
+ else
111
+ keys.each_with_object({}) do |key, out|
112
+ camel_key = Inflector.camelize(key.to_s)
113
+ actual_key = @raw.key?(key.to_s) ? key.to_s : camel_key
114
+ out[key] = Node.wrap(@raw[actual_key]) if @raw.key?(actual_key)
115
+ end
116
+ end
117
+ end
118
+
119
+ def ==(other)
120
+ other.is_a?(Node) && other.raw == @raw
121
+ end
122
+ alias eql? ==
123
+
124
+ def hash
125
+ @raw.hash
126
+ end
127
+
128
+ def inspect
129
+ "#<#{self.class.name || "JsxRosetta::AST::Node"} type=#{type.inspect} loc=#{loc_summary}>"
130
+ end
131
+
132
+ private
133
+
134
+ def yield_descendant_nodes(value, &block)
135
+ case value
136
+ when Hash
137
+ yield Node.wrap(value) if value.key?("type")
138
+ when Array
139
+ value.each { |element| yield_descendant_nodes(element, &block) }
140
+ end
141
+ end
142
+
143
+ def loc_summary
144
+ return "?" unless loc
145
+
146
+ start_loc = loc["start"] || {}
147
+ "#{start_loc["line"]}:#{start_loc["column"]}"
148
+ end
149
+ end
150
+ end
151
+ end