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 +7 -0
- data/CHANGELOG.md +149 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +236 -0
- data/README.md +328 -0
- data/Rakefile +12 -0
- data/exe/jsx_rosetta +6 -0
- data/lib/jsx_rosetta/ast/inflector.rb +23 -0
- data/lib/jsx_rosetta/ast/node.rb +151 -0
- data/lib/jsx_rosetta/ast/types.rb +224 -0
- data/lib/jsx_rosetta/ast/visitor.rb +47 -0
- data/lib/jsx_rosetta/ast.rb +15 -0
- data/lib/jsx_rosetta/backend/base.rb +21 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +41 -0
- data/lib/jsx_rosetta/backend/routes_script.rb +191 -0
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +120 -0
- data/lib/jsx_rosetta/backend/view_component.rb +638 -0
- data/lib/jsx_rosetta/backend.rb +12 -0
- data/lib/jsx_rosetta/cli.rb +182 -0
- data/lib/jsx_rosetta/ir/lowering.rb +727 -0
- data/lib/jsx_rosetta/ir/types.rb +276 -0
- data/lib/jsx_rosetta/ir.rb +16 -0
- data/lib/jsx_rosetta/node_bridge.rb +56 -0
- data/lib/jsx_rosetta/parse_error.rb +19 -0
- data/lib/jsx_rosetta/parser.rb +30 -0
- data/lib/jsx_rosetta/routes.rb +72 -0
- data/lib/jsx_rosetta/version.rb +5 -0
- data/lib/jsx_rosetta.rb +41 -0
- data/node/.gitignore +1 -0
- data/node/package-lock.json +64 -0
- data/node/package.json +16 -0
- data/node/parse.js +77 -0
- metadata +84 -0
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.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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.
|