jsx_rosetta 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +120 -0
- data/README.md +69 -7
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +756 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +54 -12
- data/lib/jsx_rosetta/backend/view_component.rb +169 -69
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +366 -194
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +148 -0
- data/lib/jsx_rosetta/ir/types.rb +77 -6
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7b64119bbfe86413da7a779ada7e47cab06088411cb4667be2e79f8cfba3464
|
|
4
|
+
data.tar.gz: c1c1ac742642015a6ae7037538a34bf58b42b6e2fc232bedf48f8660b13a640d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44a3dc2fc89b0dbb59ca5026f2cb27cc7f22e498cf125483725e1db07a39db1e45616baff9766be82bc52e443b33bf64180b90175660536c91f4bfc89e5b1926
|
|
7
|
+
data.tar.gz: a9c999e35af23ff2aba38a02ff57fc6229bf051dc339db859c341c9c1d607f88278542abd07a742ac84840616a305dcfe48b8bad32767ffbf5fc8d08c45ef7a9
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,125 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.4.0] - 2026-05-11
|
|
6
|
+
|
|
7
|
+
Closes nine translation gaps identified during a sample review of 12
|
|
8
|
+
random Phlex outputs from the v0.3.0 stress run. Each gap was a silent
|
|
9
|
+
drop, a render-time NameError, or a semantic mistranslation — the
|
|
10
|
+
generated Ruby parsed but didn't behave like the source JSX.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Render-prop / function-as-children support.**
|
|
15
|
+
`<Form.List>{(fields) => <p>{fields}</p>}</Form.List>` now lowers to a
|
|
16
|
+
new `IR::RenderProp` and emits as a Ruby block on the parent `render`
|
|
17
|
+
call: `render Form::List.new do |fields| ... end`. Both Phlex and
|
|
18
|
+
ViewComponent backends emit the block; param names snake_case to match
|
|
19
|
+
Ruby convention. Previously dropped as `plain "[untranslated: ...]"`.
|
|
20
|
+
- **Recursive object/array/lambda translation in attribute values.**
|
|
21
|
+
New IR types `ObjectLiteral`, `ArrayLiteral`, and `Lambda` mean that
|
|
22
|
+
`<Select options={[{ value: 10, label: "10 / page" }]} />` now emits
|
|
23
|
+
`options: [{ value: 10, label: "10 / page" }]` (Ruby array of hashes)
|
|
24
|
+
instead of `options: nil` with a TODO. Identifier keys snake_case
|
|
25
|
+
(`dataLabel` → `data_label`); nested literals recurse. Function-valued
|
|
26
|
+
properties — e.g. AG-Grid `render: (v) => <Tag>{v}</Tag>` — extract to
|
|
27
|
+
private methods on the class (`def render_render(v); span do; ...; end`)
|
|
28
|
+
and the property emits as `render: method(:render_render)` so the
|
|
29
|
+
body has access to the Phlex tag.* helpers.
|
|
30
|
+
- **Module-level constants captured into `Component#module_bindings`.**
|
|
31
|
+
`const FOO = 400; function X() { return <p>{FOO}</p>; }` no longer
|
|
32
|
+
silently drops the FOO declaration — the backend emits a TODO comment
|
|
33
|
+
block above the class with the original source so the reviewer either
|
|
34
|
+
translates it to a Ruby constant or moves it to a Rails initializer.
|
|
35
|
+
- **Destructure-pattern names recognized by the translator.**
|
|
36
|
+
`const [count, setCount] = useState(0); <p>{count}</p>` previously
|
|
37
|
+
emitted a bare `plain count` that NameErrored at render time. The
|
|
38
|
+
destructured names are now captured in `Component#local_binding_names`
|
|
39
|
+
and the `ExpressionTranslator` emits a `nil` placeholder for them so
|
|
40
|
+
the file at least loads. Covers `ArrayPattern`, `ObjectPattern`
|
|
41
|
+
(including aliased properties, `AssignmentPattern` defaults, and
|
|
42
|
+
`RestElement`), and hook-tuple destructures.
|
|
43
|
+
- **Member-expression destructure resolution.** `const { Content } = Layout`
|
|
44
|
+
followed by `<Content/>` now resolves to `Layout::Content`, not a
|
|
45
|
+
free-floating `ContentComponent`. Multiple destructured names from the
|
|
46
|
+
same parent identifier all resolve correctly.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Component-prop callbacks no longer over-promote to Stimulus.**
|
|
51
|
+
`<Select onChange={onChange} />` (PascalCase tag) previously emitted
|
|
52
|
+
`data-action="change->foo#onChange"` — a Stimulus action descriptor
|
|
53
|
+
that never fires because the receiving component is a Ruby class, not
|
|
54
|
+
a DOM element. The lowering now checks `html_element?(tag)` before
|
|
55
|
+
promoting `on*` attrs and treats component-prop callbacks as regular
|
|
56
|
+
`IR::Attribute` kwargs. Stimulus promotion still applies to lowercase
|
|
57
|
+
HTML tags as before. Closes a regression introduced by the v0.3.0
|
|
58
|
+
prop-handler Stimulus promotion.
|
|
59
|
+
- **Spread-of-nil no longer raises at render time.** `<div {...maybeNil}>`
|
|
60
|
+
used to emit `**@maybe_nil`, which raises when the prop is `nil`. Both
|
|
61
|
+
Phlex and ViewComponent backends now wrap as `**(@maybe_nil || {})`.
|
|
62
|
+
Cheap to emit unconditionally and idempotent.
|
|
63
|
+
- **Duplicate handler names are no longer silently renamed.** Two
|
|
64
|
+
`onClick={handleReset}` handlers in one component previously produced
|
|
65
|
+
`handleReset` / `handleReset2` with no marker. `StimulusMethod` now
|
|
66
|
+
carries an `original_name` field, and the generated controller JS
|
|
67
|
+
emits a `// NOTE: method renamed from "handleReset"` comment above the
|
|
68
|
+
renamed method.
|
|
69
|
+
- **ReactNode-typed props get a `raw` hint comment.** When an
|
|
70
|
+
interpolation translates to a bare `@ivar` reference (likely a prop),
|
|
71
|
+
the Phlex backend emits a comment hint —
|
|
72
|
+
`plain @extra # NOTE: use \`raw\` instead of \`plain\` if this is a
|
|
73
|
+
ReactNode-typed prop`. `plain` HTML-escapes its argument, which is
|
|
74
|
+
wrong for prebuilt-markup props but right for strings; we can't tell
|
|
75
|
+
at translation time, so we default to safe (`plain`) and surface the
|
|
76
|
+
choice.
|
|
77
|
+
|
|
78
|
+
### Investigation
|
|
79
|
+
|
|
80
|
+
- **Sibling named exports — not a gap.** Probed four shapes of
|
|
81
|
+
`export default Foo; export function Loading() {}` against the gem;
|
|
82
|
+
all four shapes correctly emit both `foo.rb` and `loading.rb`. Closed
|
|
83
|
+
the suspected gap as a false positive from the random sample.
|
|
84
|
+
|
|
85
|
+
### Refactored
|
|
86
|
+
|
|
87
|
+
- `Lowering` class shrunk by ~150 lines (v0.3.0 prep, now shipping):
|
|
88
|
+
pure-heuristic `ModuleShapeClassifier` lives in its own file; helper
|
|
89
|
+
methods `AST::Node#child`, `#of_type?`, `Node.matches?` replaced ~25
|
|
90
|
+
defensive `is_a?(AST::Node) && type ==` checks; class/style rendering
|
|
91
|
+
in the ViewComponent backend deduplicated across HTML-vs-Ruby output
|
|
92
|
+
formats; `tag_builder_data_action` replaced its "parse what I just
|
|
93
|
+
emitted" heuristic with a structured `EventDescriptor` intermediate.
|
|
94
|
+
|
|
95
|
+
### Stress test outcome
|
|
96
|
+
|
|
97
|
+
- 929-file Phlex stress run on `reserv-web`: 887/929 clean translations
|
|
98
|
+
(unchanged — rejection logic untouched), **0/1224 syntax failures**
|
|
99
|
+
(down from 25 on v0.3.0). All emitted `.rb` files now pass `ruby -c`.
|
|
100
|
+
- Five residual bugs were caught during the v0.4.0 stress rerun and
|
|
101
|
+
fixed inline:
|
|
102
|
+
- Prop default expressions that translated to `nil # TODO: ...` inside
|
|
103
|
+
`initialize(...)` swallowed the closing `)`. Prop defaults now route
|
|
104
|
+
through the same recursive lowering as attribute values.
|
|
105
|
+
- Multi-line JSX comments only prefixed the first line with `#`. Every
|
|
106
|
+
line of a comment now gets a `#` prefix.
|
|
107
|
+
- Template literals with inner `"` or `\\` characters could break the
|
|
108
|
+
surrounding Ruby string. Literal segments now escape both.
|
|
109
|
+
- `token.blue` (where `token` was a captured local binding) translated
|
|
110
|
+
to `nil.blue` — `NoMethodError` at render time. Member-chain roots
|
|
111
|
+
that resolve to a known-local binding now fall through to the
|
|
112
|
+
snake_case bare reference with an unresolved marker rather than the
|
|
113
|
+
`nil` placeholder.
|
|
114
|
+
- `["a", "b"].map((x) => <li/>)` lost the array literal and emitted
|
|
115
|
+
`[].each` because the translator can't parse `[...]`. The lowering
|
|
116
|
+
now recognizes ArrayExpression iterables and routes them through
|
|
117
|
+
the recursive ArrayLiteral path.
|
|
118
|
+
|
|
119
|
+
### Spec count
|
|
120
|
+
|
|
121
|
+
- Up to 343 examples (from 304), all green. `bundle exec rubocop` clean.
|
|
122
|
+
|
|
3
123
|
## [0.3.0] - 2026-05-10
|
|
4
124
|
|
|
5
125
|
Driven by a 929-file stress run against the entire `reserv-web` codebase
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ gem itself emits.
|
|
|
13
13
|
|
|
14
14
|
```
|
|
15
15
|
JSX text ──► Babel AST ──► Ruby AST ──► IR ──► backend ──► .rb / .html.erb / _controller.js
|
|
16
|
-
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / RoutesScript)
|
|
16
|
+
(Node sidecar) (typed tree) (sema) (ViewComponent / RailsView / Phlex / RoutesScript)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
@@ -40,6 +40,17 @@ jsx_rosetta translate path/to/Button.tsx -o app/components
|
|
|
40
40
|
# app/components/button_component/button_component.html.erb
|
|
41
41
|
# app/components/button_component/button_controller.js (when inline arrow handlers)
|
|
42
42
|
|
|
43
|
+
# Translate as a single-file Phlex 2.x view class (no separate ERB)
|
|
44
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex -o app/components
|
|
45
|
+
# → app/components/button.rb
|
|
46
|
+
# app/components/button_controller.js (when inline arrow handlers)
|
|
47
|
+
# With suffix:
|
|
48
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-suffix=Component -o app/components
|
|
49
|
+
# → app/components/button_component.rb (class ButtonComponent)
|
|
50
|
+
# With namespace:
|
|
51
|
+
jsx_rosetta translate path/to/Button.tsx --as=phlex --phlex-namespace=Components -o app/components
|
|
52
|
+
# → app/components/button.rb (class Components::Button)
|
|
53
|
+
|
|
43
54
|
# Translate a route-tied page as a Rails view template (no Ruby class)
|
|
44
55
|
jsx_rosetta translate path/to/Home.tsx --as=view -o app/views/home
|
|
45
56
|
# → app/views/home/home.html.erb (rename to index.html.erb)
|
|
@@ -96,12 +107,17 @@ JsxRosetta::Backend::RoutesScript.new(source_path: "router.tsx").emit(route_tree
|
|
|
96
107
|
```
|
|
97
108
|
|
|
98
109
|
Optional kwargs to `JsxRosetta.translate`:
|
|
99
|
-
- `backend: :view_component | :rails_view` (default `:view_component`)
|
|
100
|
-
- `
|
|
101
|
-
- `
|
|
110
|
+
- `backend: :view_component | :rails_view | :phlex` (default `:view_component`)
|
|
111
|
+
- `backend_options: Hash` — per-backend options (see below)
|
|
112
|
+
- `helpers: nil | Hash | false` — JSX-name → Rails-helper mapping (see below; ViewComponent/RailsView only)
|
|
113
|
+
- `layout: :sidecar | :flat` (default `:sidecar`; ViewComponent only)
|
|
102
114
|
- `typescript: true` — force the TypeScript Babel plugin
|
|
103
115
|
- `source_filename:` — surfaces in parse errors
|
|
104
116
|
|
|
117
|
+
Phlex backend options (pass via `backend_options:`):
|
|
118
|
+
- `suffix: "Component"` — append a suffix to the class name and file name. Mutually exclusive with `namespace:`.
|
|
119
|
+
- `namespace: "Components"` — wrap the class in a `module Namespace` block. Mutually exclusive with `suffix:`.
|
|
120
|
+
|
|
105
121
|
## What translates
|
|
106
122
|
|
|
107
123
|
| JSX construct | Translation |
|
|
@@ -159,13 +175,13 @@ auto-perform. Common cases:
|
|
|
159
175
|
recreate React's runtime in Ruby.
|
|
160
176
|
- React Router's data-router form (`createBrowserRouter([...])`). Only the
|
|
161
177
|
declarative `<Routes><Route>` shape is parsed today.
|
|
162
|
-
- Backends other than ViewComponent and
|
|
178
|
+
- Backends other than ViewComponent, RailsView, and Phlex (Slim, LiveView).
|
|
163
179
|
- Suspense → Turbo Frame mapping (depends on a data-fetching translation
|
|
164
180
|
story we don't have yet).
|
|
165
181
|
|
|
166
|
-
##
|
|
182
|
+
## Emission targets
|
|
167
183
|
|
|
168
|
-
|
|
184
|
+
Three backends, picked via `--as` on the CLI or `backend:` in the API:
|
|
169
185
|
|
|
170
186
|
**`:view_component` (default)** — emits a sidecar layout matching
|
|
171
187
|
ViewComponent's `--sidecar` generator convention:
|
|
@@ -185,6 +201,52 @@ Pass `layout: :flat` to revert to the older flat layout
|
|
|
185
201
|
no Ruby class and no sidecar. Place at `app/views/<controller>/<action>.html.erb`;
|
|
186
202
|
the controller's instance variables become the template's `@x` references.
|
|
187
203
|
|
|
204
|
+
**`:phlex`** — emits a single-file [Phlex 2.x](https://www.phlex.fun/) view
|
|
205
|
+
class. The JSX template lives inside `view_template` as Ruby method calls,
|
|
206
|
+
not in a separate ERB file:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# app/components/card.rb (default mode)
|
|
210
|
+
# app/components/card_component.rb (suffix: "Component")
|
|
211
|
+
class Card < Phlex::HTML
|
|
212
|
+
def initialize(title: nil)
|
|
213
|
+
@title = title
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def view_template
|
|
217
|
+
div(class: "card") do
|
|
218
|
+
h2 { plain @title }
|
|
219
|
+
yield
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Three mutually exclusive naming strategies (configurable):
|
|
226
|
+
- **default** — `class FlashyHeader < Phlex::HTML` (collision-prone in large
|
|
227
|
+
apps where names like `Card` / `User` / `Image` overlap with models).
|
|
228
|
+
- **`suffix: "Component"`** — `class FlashyHeaderComponent < Phlex::HTML`,
|
|
229
|
+
matches the ViewComponent layout, safest for migrations.
|
|
230
|
+
- **`namespace: "Components"`** — wraps the class in a `module Components`
|
|
231
|
+
block. Cross-references inside the namespace stay bare (`render Card.new`)
|
|
232
|
+
and resolve via Ruby's constant lookup.
|
|
233
|
+
|
|
234
|
+
Stimulus handlers still emit a sibling `_controller.js` skeleton alongside
|
|
235
|
+
the `.rb` — the `data-controller`/`data-action` attrs go inline on the
|
|
236
|
+
element (emitted as `data_controller:`/`data_action:` kwargs; Phlex
|
|
237
|
+
auto-hyphenates underscores at render time), the handler body goes into
|
|
238
|
+
the JS skeleton as a TODO comment.
|
|
239
|
+
|
|
240
|
+
Attribute emission specifics:
|
|
241
|
+
- Hyphenated JSX attrs (`data-testid`, `aria-label`) emit as snake_case
|
|
242
|
+
kwargs (`data_testid:`, `aria_label:`). Phlex 2.x converts the
|
|
243
|
+
underscores back to hyphens on render.
|
|
244
|
+
- camelCase JSX attrs (`viewBox`, `preserveAspectRatio`) preserve
|
|
245
|
+
verbatim — Phlex only converts underscores, so SVG attributes stay
|
|
246
|
+
intact.
|
|
247
|
+
- Attributes with characters that aren't valid Ruby identifiers (e.g.
|
|
248
|
+
`xml:lang`) fall back to a quoted string key inside `**{ "xml:lang" => x }`.
|
|
249
|
+
|
|
188
250
|
## Helper mappings
|
|
189
251
|
|
|
190
252
|
Capitalized JSX tags that have a direct Rails helper analog skip the
|
data/lib/jsx_rosetta/ast/node.rb
CHANGED
|
@@ -66,7 +66,10 @@ module JsxRosetta
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Field access. Accepts snake_case symbols/strings (translated to
|
|
69
|
-
# camelCase) and camelCase strings (used verbatim).
|
|
69
|
+
# camelCase) and camelCase strings (used verbatim). Returns whatever
|
|
70
|
+
# the raw hash contains at that key (Node, Array of Nodes, String,
|
|
71
|
+
# Hash, nil) — caller is responsible for type-checking. For typed
|
|
72
|
+
# access, prefer `#child` (returns Node | nil).
|
|
70
73
|
def [](key)
|
|
71
74
|
raw_key = key.to_s
|
|
72
75
|
return Node.wrap(@raw[raw_key]) if @raw.key?(raw_key)
|
|
@@ -75,6 +78,30 @@ module JsxRosetta
|
|
|
75
78
|
Node.wrap(@raw[camel_key])
|
|
76
79
|
end
|
|
77
80
|
|
|
81
|
+
# Typed child access — returns the wrapped Node at `key`, or nil if
|
|
82
|
+
# absent or non-Node-shaped. Use this in lowering passes to avoid
|
|
83
|
+
# repetitive `is_a?(AST::Node)` defenses.
|
|
84
|
+
def child(key)
|
|
85
|
+
value = self[key]
|
|
86
|
+
value.is_a?(Node) ? value : nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Type predicate on a known Node. Use only when the receiver is
|
|
90
|
+
# guaranteed to be a Node — otherwise prefer `Node.matches?` (class
|
|
91
|
+
# method) which tolerates nil / non-Node values from hash lookups.
|
|
92
|
+
# Accepts multiple types: `node.of_type?("StringLiteral", "NumericLiteral")`.
|
|
93
|
+
def of_type?(*types)
|
|
94
|
+
types.include?(type)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Defensive type predicate. True iff `value` is an AST::Node whose
|
|
98
|
+
# type is one of `types`. False for nil, arrays, strings, hashes, or
|
|
99
|
+
# any non-Node — making it safe for raw hash field accesses where
|
|
100
|
+
# the contents may be anything.
|
|
101
|
+
def self.matches?(value, *types)
|
|
102
|
+
value.is_a?(Node) && types.include?(value.type)
|
|
103
|
+
end
|
|
104
|
+
|
|
78
105
|
def dig(*keys)
|
|
79
106
|
keys.reduce(self) do |current, key|
|
|
80
107
|
break nil if current.nil?
|