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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ae2ca5d2ff613e264c7a6027c5a95c57781916a38b52090d6c9d62ba7cafd8c
4
+ data.tar.gz: 3e6a2e6f2a54e1dd01002e3e1312d3c1144e3af7e315c55cb3eeee137b858c79
5
+ SHA512:
6
+ metadata.gz: 11b3d8dea5bb887a1b55ebccc0119ce5703bf2c39251a0144dda973d01dd05f7e9b58337fb383e87f3c66d1dfac9a465e6cc5a14dca827e0612073401e4f908c
7
+ data.tar.gz: 2742e096f9947b4999183e682c0f1f3b405a68894bc9a2bbb4f140fb45f22a5c611c7e5759f88ea9b987bbd98a501f0bbb248638e83b494825881a6ccac60e76
data/CHANGELOG.md ADDED
@@ -0,0 +1,149 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added — translator (lowering)
6
+
7
+ - **Arrow-function components** — `const X = () => …` (with implicit
8
+ return or block body), and exported variants. Previously only
9
+ `function X() { … }` was recognized.
10
+ - **Multi-component files** — `lower_all` walks the entire program body
11
+ and returns one IR::Component per matched declaration. Backends emit
12
+ one output pair per component.
13
+ - **Compound component tags** — `<Tabs.List>` lowers as a
14
+ `ComponentInvocation(name: "Tabs.List")`; the backend renders it as
15
+ `Tabs::ListComponent.new(...)` (was previously emitting invalid
16
+ `Tabs.ListComponent.new`).
17
+ - **Spread props** — `JSXSpreadAttribute` lowers to `IR::SpreadAttribute`
18
+ (replaces the broken `__spread__` placeholder). Component rest-binding
19
+ (`function X({ a, ...rest })`) captured on `Component#rest_prop_name`.
20
+ - **`asChild` polymorphism** — `const Comp = cond ? Slot : "button"` plus
21
+ `<Comp/>` synthesizes a Conditional with both branches expanded
22
+ (Element for string-literal tags, ComponentInvocation for identifiers).
23
+ - **`cn()` / `clsx()` / `classnames()`** — recognized at lowering time;
24
+ decomposed into IR::ClassList with literal segments, identifier
25
+ interpolations, and conditional segments from object-literal arguments.
26
+ - **Inline styles** — `style={{ fontSize: 12 }}` lowers to IR::Style with
27
+ kebab-case property names; identifier values become Interpolations.
28
+ - **Local JSX const inlining** — `const image = <Image .../>; <div>{image}</div>`
29
+ inlines the JSX at the use site (also through ternary branches).
30
+ - **Local non-JSX `const` bindings** — preserved verbatim in a
31
+ `<%# TODO: translate JS to Ruby %>` block at the top of the rendered
32
+ template (not auto-translated; see policy note below).
33
+ - **React hooks detection** (`useState`, `useEffect`, `useRef`,
34
+ `useContext`, `useMemo`, `useCallback`, `useReducer`,
35
+ `useImperativeHandle`, `useLayoutEffect`, `useDebugValue`) — surfaced
36
+ in their own TODO block pointing at the Hotwire/Stimulus alternative.
37
+ Both `const [x, setX] = useState(...)` and bare `useEffect(...)`
38
+ ExpressionStatements are captured.
39
+ - **Stimulus method extraction** — inline arrow event handlers
40
+ (`onClick={() => …}`) and const-bound arrow handlers referenced from
41
+ `onX={…}` lower to IR::StimulusBinding + IR::StimulusMethod. Backend
42
+ emits a sibling `_controller.js` skeleton with the original JS body
43
+ preserved as a comment.
44
+ - **Literal expression containers** — `{"foo"}`, `{42}` lower to plain
45
+ text instead of `<%= "foo" %>`. `{true}` / `{null}` are dropped
46
+ (matches React's runtime).
47
+ - **JSX comments** — `{/* foo */}` lowers to IR::Comment; renders as
48
+ `<%# foo %>`.
49
+ - **JSX text whitespace normalization** — Babel's
50
+ `cleanJSXElementLiteralChild` algorithm. Removes the indented blank
51
+ lines that previously surrounded inline text.
52
+ - **Void element handling** — `<img />`, `<hr />`, `<br />`, `<input />`,
53
+ etc. self-close; no spurious closing tag.
54
+ - **`key={…}` dropped** on both ComponentInvocation and Element (it's a
55
+ React-only reconciliation hint, not a DOM attribute).
56
+ - **JSX member chains snake-cased** — `post.coverImage` → `@post.cover_image`
57
+ (each chain segment, not just the root).
58
+ - **Template literals with member chains** — `` `/posts/${post.id}` `` →
59
+ `"/posts/#{@post.id}"` (was previously flagged as TODO).
60
+ - **Unary expressions** — `!preview`, `-x`, `+x`, `!!flag` translate
61
+ through to Ruby (`!@preview` etc.).
62
+ - **Lowering errors** carry line/column from the failing AST node.
63
+
64
+ ### Added — backends
65
+
66
+ - **`Backend::ViewComponent`**:
67
+ - **Sidecar layout** (default) per ViewComponent's `--sidecar`
68
+ generator: `.rb` at the top level, `.html.erb` and any sibling
69
+ Stimulus controller in a `<snake>_component/` subdirectory. Pass
70
+ `layout: :flat` for the previous flat layout.
71
+ - **Helper-call mapping** — `<Link>` → `link_to(...)`, `<Image>` →
72
+ `image_tag(...)`. Override or extend via the `helpers:` kwarg;
73
+ disable entirely with `helpers: false`.
74
+ - **Element tag-builder mode** — switches to Rails' `tag.button(...)`
75
+ builder when a SpreadAttribute is present on an HTML element
76
+ (literal HTML stays for the no-spread case).
77
+ - **Hyphenated kwargs** on ComponentInvocations emit as quoted-key
78
+ hash entries (`"aria-label" => @x`) so they're valid Ruby.
79
+ - **Initializer with `**rest`** when the source destructures a rest
80
+ binding.
81
+ - **Inlined attribute interpolation** — `href="/posts/<%= @slug %>"`
82
+ instead of `href="<%= "/posts/#{@slug}" %>"` (template-literal
83
+ inlining, previously only applied to className).
84
+ - **Unresolved-identifier flagging** — interpolations whose translator
85
+ result reports an unresolved name get a `<%# TODO: unresolved
86
+ identifier "X" %>` marker.
87
+
88
+ - **`Backend::RailsView`** (new) — emits a single `<snake>.html.erb`
89
+ with no Ruby class and no sidecar dir. Appropriate for pages tied to
90
+ a route. Pass `--as=view` on the CLI or `backend: :rails_view` in the
91
+ API. Stimulus controllers still emit alongside when applicable.
92
+
93
+ - **`Backend::RoutesScript`** (new) — turns IR::RouteTree into a
94
+ reviewable Ruby script with `system "rails", "generate", "controller"`
95
+ invocations and a suggested `config/routes.rb` block.
96
+
97
+ ### Added — routes subcommand
98
+
99
+ - **`jsx_rosetta routes <input>`** parses `<Routes><Route>` JSX and
100
+ emits the routes script. Member-expression element references
101
+ (`<Layout.Home />`) flatten to the rightmost name. Recognized
102
+ patterns: `path` is a string literal (`path="/x"` or `path={"/x"}`);
103
+ `element` is a JSX element.
104
+ - **Resource consolidation** — `/xs` + `/xs/:id` pairs collapse into
105
+ `resources :xs, only: %i[index show]` with a single
106
+ `rails generate controller xs index show` call.
107
+ - **Catch-all routes** — `<Route path="*">` emits
108
+ `match "*path", to: …, via: :all` (bare `*` is normalized to `*path`).
109
+ - **Reserved-name collision warning** — when a generated controller
110
+ name matches a Rails reserved term, the script flags it.
111
+
112
+ ### Added — IR types
113
+
114
+ `IR::SpreadAttribute`, `IR::ClassList`, `IR::ConditionalSegment`,
115
+ `IR::Style`, `IR::StyleDeclaration`, `IR::Comment`, `IR::LocalBinding`,
116
+ `IR::StimulusBinding`, `IR::StimulusMethod`, `IR::ReactHookCall`,
117
+ `IR::RouteTree`, `IR::RouteEntry`. `IR::Component` gains
118
+ `rest_prop_name`, `local_bindings`, `stimulus_methods`, and
119
+ `react_hooks` fields.
120
+
121
+ ### Translation policy
122
+
123
+ - **Don't translate arbitrary JS to Ruby.** Bindings whose RHS isn't
124
+ one of the narrowly-recognized shapes (literals, identifiers,
125
+ member chains, simple template literals, `cn()`-style calls,
126
+ inline-style object literals) get preserved verbatim in a TODO
127
+ comment block. Speculative translation produced broken Ruby that
128
+ looked plausible — worse than an obvious TODO.
129
+ - **Behavioral JS → Stimulus controllers.** Inline event-handler arrows
130
+ extract to a generated `_controller.js` skeleton; `data-action`
131
+ descriptors get auto-wired.
132
+ - **React hooks → Hotwire/Stimulus + server-side rendering.** Detected
133
+ but not auto-translated; surfaced in a distinct TODO with
134
+ alternatives noted.
135
+
136
+ ### Verified end-to-end
137
+
138
+ - `vercel/next.js` `examples/blog-starter` — 15/16 components translate
139
+ cleanly; the one failure (`theme-switcher.tsx`) uses
140
+ `memo`+`useState`+`useEffect` and is structurally out of scope.
141
+ - A multi-route React Router app translates into a Rails 8.1 app with
142
+ five routes (Home, PostsIndex, PostShow, About, NotFound)
143
+ end-to-end. See `app/views/<controller>/<action>.html.erb` placement
144
+ via `--as=view`.
145
+
146
+ ## [0.1.0] - 2026-05-09
147
+
148
+ - Initial release. Phases 0–6: AST, IR, ViewComponent backend, slots,
149
+ conditionals, events, loops, CLI.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "jsx_rosetta" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["seanmcc@gmail.com"](mailto:"seanmcc@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Sean McCleary
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/PLAN.md ADDED
@@ -0,0 +1,236 @@
1
+ # jsx_rosetta — Implementation Plan
2
+
3
+ > **Status (2026-05-10):** Phases 0–6 below are all shipped. Work after
4
+ > Phase 6 (arrow components, spread props, cn/clsx, Stimulus extraction,
5
+ > sidecar layout, helper mappings, RailsView backend, routes subcommand,
6
+ > compound components, asChild polymorphism, React hooks detection, …)
7
+ > is described in [CHANGELOG.md](CHANGELOG.md) — preserved here as
8
+ > historical context for the original v0.1.0 design.
9
+
10
+ `jsx_rosetta` is a Ruby gem that translates JSX into Rails ViewComponent
11
+ (Ruby class + ERB template) via a three-stage pipeline. Other output
12
+ formats (Phlex, Slim, Phoenix LiveView, etc.) are anticipated by design
13
+ and accommodated by the IR — adding one is a new backend, not a rewrite.
14
+
15
+ ## Pipeline
16
+
17
+ ```
18
+ JSX text ──► Babel AST ──► Ruby AST ──► IR ──► ViewComponent backend ──► .rb + .html.erb
19
+ (Node + Babel) (Ruby tree) (sema) (string-built ERB + Ruby)
20
+ ```
21
+
22
+ ## Architectural decisions (locked)
23
+
24
+ - **JSX parsing:** Shell out to a Node sidecar running `@babel/parser`.
25
+ No native JS engine in the Ruby process.
26
+ - **Node dependencies:** Not vendored. `node/package.json` declares deps;
27
+ `node/node_modules/` is gitignored. The gem provides an install
28
+ command (`jsx_rosetta install`) that runs `npm install` for the user.
29
+ - **AST:** Typed Ruby classes mirroring Babel node shapes 1:1. The gem
30
+ does not normalize at the AST layer — that's the IR's job.
31
+ - **IR:** Required. Framework-agnostic, semantic. The multi-backend
32
+ abstraction lives here. Nothing in the IR mentions Ruby, ERB, or Rails.
33
+ - **Initial backend:** ViewComponent (one `.rb` class + one `.html.erb`
34
+ per JSX component).
35
+ - **ERB writer:** String-built in Ruby. Optional structural validation
36
+ via the `herb` gem in tests. Migrate to programmatic Herb construction
37
+ only if string-building causes real pain.
38
+ - **No RBS.** Skeleton `sig/` directory removed.
39
+ - **Fixtures:** `spec/fixtures/jsx/` for JSX inputs,
40
+ `spec/fixtures/expected/` for golden output files.
41
+
42
+ ## Project layout
43
+
44
+ ```
45
+ lib/jsx_rosetta/
46
+ version.rb
47
+ parser.rb # public entry: JSX text → AST::Program
48
+ node_bridge.rb # subprocess plumbing
49
+ parse_error.rb
50
+ ast/
51
+ node.rb # base, with deconstruct_keys for pattern matching
52
+ builder.rb # JSON hash → typed nodes
53
+ visitor.rb
54
+ <one-per-type>.rb
55
+ ir/
56
+ node.rb
57
+ lowering.rb # AST::Program → IR::Component
58
+ <one-per-type>.rb
59
+ backend/
60
+ base.rb # backend interface; pluggable
61
+ view_component.rb # IR::Component → { ruby:, erb: }
62
+ cli.rb # exe/jsx_rosetta dispatch
63
+ node/
64
+ package.json
65
+ parse.js # stdin (JSX) → stdout (JSON AST)
66
+ .gitignore # node_modules/
67
+ spec/
68
+ parser_spec.rb
69
+ ast/{builder,visitor}_spec.rb
70
+ ir/lowering_spec.rb
71
+ backend/view_component_spec.rb
72
+ fixtures/
73
+ jsx/{button,dialog,combobox}.{jsx,tsx}
74
+ expected/{button,dialog,combobox}.{rb,html.erb}
75
+ exe/jsx_rosetta
76
+ ```
77
+
78
+ ## Components
79
+
80
+ ### Node sidecar (`node/`)
81
+
82
+ - `package.json` — declares `@babel/parser` as the only runtime dep.
83
+ - `parse.js` — reads a JSON request from stdin (`{ source, typescript,
84
+ source_filename }`), runs `@babel/parser`, writes a JSON response to
85
+ stdout. Errors surface as `{ error: { message, line, column } }`.
86
+ - `node_modules/` — gitignored. Users install via `jsx_rosetta install`.
87
+
88
+ ### Parser (`lib/jsx_rosetta/parser.rb`)
89
+
90
+ ```ruby
91
+ JsxRosetta::Parser.new.parse(source, typescript: false) # => AST::Program
92
+ ```
93
+
94
+ - Locates `node` on `PATH`, with `JSX_ROSETTA_NODE` env override.
95
+ - Spawns the sidecar via `Open3.capture3` (one-shot mode for MVP).
96
+ - Parses the JSON response and hands it to `AST::Builder`.
97
+ - A long-lived worker (newline-delimited base64-framed requests) is a
98
+ Phase 6 optimization, not a Phase 0 concern.
99
+
100
+ ### AST (`lib/jsx_rosetta/ast/`)
101
+
102
+ - `Node` base — `type`, `loc`, `range`, `children`, `deconstruct_keys`.
103
+ - Typed subclasses for each Babel node type the corpus actually uses.
104
+ Start narrow; expand as fixtures demand. Unknown types fall through to
105
+ a generic `Node` so we don't crash on ESNext additions.
106
+ - `Builder.build(json_hash) → Node` — factory dispatching on `type`,
107
+ recursing into children.
108
+ - `Visitor` — `visit(node)` dispatches to `visit_<TypeName>`,
109
+ default-recurses children.
110
+
111
+ ### IR (`lib/jsx_rosetta/ir/`)
112
+
113
+ Initial node types (expanded by phase as fixtures require):
114
+
115
+ ```
116
+ Component { name, props[], slots[], body }
117
+ Element { tag, attributes[], children[] }
118
+ Text { value }
119
+ Interpolation { expression } # opaque token, emitted verbatim
120
+ Loop { iterable, item, index?, body }
121
+ Conditional { test, consequent, alternate? }
122
+ ComponentInvocation { name, props[], children[] }
123
+ Fragment { children[] }
124
+ Attribute { name, value } # literal or Interpolation
125
+ EventBinding { event, handler } # onClick, onChange — backend decides
126
+ Slot { name } # children prop or named slot
127
+ StyleBinding { classes[], conditional[] } # className={cn(...)} lowered here
128
+ ```
129
+
130
+ `Lowering.lower(ast_program) → IR::Component` is the AST→IR pass. It
131
+ normalizes JSX-specific patterns: the three "render-if" forms
132
+ (`{cond && x}`, `{cond ? x : null}`, `{cond ? x : y}`) all collapse to
133
+ one `Conditional`. JS expressions stay as opaque tokens during MVP —
134
+ they're emitted verbatim into ERB and flagged with a comment when
135
+ non-trivial.
136
+
137
+ ### Backend (`lib/jsx_rosetta/backend/`)
138
+
139
+ - `Base` — interface every backend implements:
140
+ ```ruby
141
+ Backend::Base.new.emit(ir_component) # => { files: [{ path:, contents: }, ...] }
142
+ ```
143
+ Pluggable from day one so adding Phlex/Slim/LiveView later is mechanical.
144
+ - `ViewComponent` — string-builds the `.rb` class and `.html.erb`
145
+ template. Optional: parse the emitted ERB with the `herb` gem in
146
+ tests as a structural sanity check.
147
+
148
+ ### CLI (`exe/jsx_rosetta`)
149
+
150
+ - `jsx_rosetta install` — runs `npm install` in the gem's vendored
151
+ `node/` directory.
152
+ - `jsx_rosetta translate input.jsx --backend=view_component --out dir/` —
153
+ end-to-end translation.
154
+ - `jsx_rosetta parse input.jsx` — emit the parsed AST as JSON. Useful
155
+ for debugging fixtures.
156
+
157
+ ## Phases
158
+
159
+ ### Phase 0 — Node sidecar + Ruby round-trip
160
+
161
+ - Scaffold `node/` with `package.json`, install `@babel/parser`,
162
+ gitignore `node_modules`.
163
+ - Write `node/parse.js` (one-shot mode).
164
+ - `JsxRosetta::Parser#parse` calls the sidecar, returns the parsed
165
+ JSON as a nested Hash (no typed nodes yet).
166
+ - Drop `spec/fixtures/jsx/button.jsx`.
167
+ - Specs: parser returns a Program containing the expected JSXElement;
168
+ `ParseError` surfaces line/column on invalid JSX.
169
+ - Strip the gemspec's TODO placeholders; delete `sig/`.
170
+
171
+ ### Phase 1 — Typed AST + visitor
172
+
173
+ - `AST::Node` base + Babel node subtypes Button uses.
174
+ - `AST::Builder` factory; pattern-match support on nodes.
175
+ - `AST::Visitor` with default recursion. Spec: visitor collects every
176
+ JSXElement tag from Button.
177
+
178
+ ### Phase 2 — IR + lowering for Button
179
+
180
+ - IR node types Button needs (`Component`, `Element`, `Attribute`,
181
+ `Interpolation`, `StyleBinding`, `ComponentInvocation`).
182
+ - `Lowering` pass producing IR from AST for Button.
183
+ - Spec: parsing Button JSX yields the expected IR tree.
184
+
185
+ ### Phase 3 — ViewComponent backend, end-to-end Button
186
+
187
+ - `Backend::Base` interface.
188
+ - `Backend::ViewComponent` for the Button IR subset.
189
+ - Hand-write `spec/fixtures/expected/button.rb` and `button.html.erb`.
190
+ - Golden-file test: `JsxRosetta.translate(button.jsx)` matches the
191
+ expected files. **First end-to-end vertical slice.**
192
+
193
+ ### Phase 4 — Dialog: state, events, slots
194
+
195
+ - IR additions: `EventBinding`, `Slot`, `Conditional`. Lowering for
196
+ compound components.
197
+ - ViewComponent backend learns slots, derived state (lowered to
198
+ component methods), Stimulus `data-action` / `data-controller` /
199
+ `data-target` attributes for events and refs.
200
+ - Decide where the Stimulus runtime lives (likely a sibling JS package,
201
+ out of this gem's scope but referenced by the README).
202
+
203
+ ### Phase 5 — Combobox: keyboard nav + list rendering
204
+
205
+ - IR addition: `Loop`. Lowering for `.map(...)` patterns.
206
+ - More Stimulus controller surface (combobox, keyboard-nav, focus-trap).
207
+
208
+ ### Phase 6 — DX polish
209
+
210
+ - `jsx_rosetta install` CLI command and helpful "Node missing /
211
+ `@babel/parser` not installed — run `bundle exec jsx_rosetta install`"
212
+ errors.
213
+ - Long-lived Node worker (newline-delimited base64-framed JSON) when
214
+ parsing many files matters.
215
+ - README rewrite. `bin/setup` runs `npm install` in `node/`.
216
+ - Optional: structural validation of emitter output via the `herb` gem.
217
+
218
+ ## Deferred questions
219
+
220
+ 1. **Stimulus runtime packaging** — same repo, sibling gem, or JS
221
+ package? Decide before Phase 4.
222
+ 2. **CVA / tailwind-variants** — for MVP, pass `cn(...)` through as a
223
+ flagged interpolation. Build-time evaluation can come later.
224
+ 3. **Worker framing protocol** — only matters when batch parsing matters
225
+ (Phase 6).
226
+ 4. **Migrating ERB emission to Herb** — only if string-built ERB causes
227
+ real pain.
228
+
229
+ ## Non-goals
230
+
231
+ - Translating arbitrary React codebases. The MVP is component-library-shaped input.
232
+ - Translating React data-fetching (`react-query`, SWR, Suspense). Flag for manual review.
233
+ - Translating React Router. Out of scope.
234
+ - Runtime JS-to-Ruby translation of arbitrary expressions. Pass through and surface for human review.
235
+ - Round-tripping (ERB → JSX). One direction only.
236
+ - Perfect visual parity. Structural and behavioral parity is the bar; visual tweaks may be needed.