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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28709ed22159e135331acff12df2c1fadbc2b692c62cd90d40a977192b28a46d
4
- data.tar.gz: 4c8543a43d7ca8e96ba3c93d92f89fddbb548d4329332487df67683cabffa4e9
3
+ metadata.gz: d7b64119bbfe86413da7a779ada7e47cab06088411cb4667be2e79f8cfba3464
4
+ data.tar.gz: c1c1ac742642015a6ae7037538a34bf58b42b6e2fc232bedf48f8660b13a640d
5
5
  SHA512:
6
- metadata.gz: c053e3111f0dc98f5ce3549d0c50a06f04f69bdbb13f5206296e807c62174b1e3824c9ac30d327884c20d1dd39d4fa821bec931292ff9802343b7d8c6a1da1ce
7
- data.tar.gz: d06787398b3c56482c881184f0cdcbe6ce20071c9c6a1d1cc411fad1514a5a4910d9d7ddfd78455107e14f4285fdf4dcf2299f42a48622790bccb9c7d02bfc17
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
- - `helpers: nil | Hash | false` — JSX-name Rails-helper mapping (see below)
101
- - `layout: :sidecar | :flat` (default `:sidecar`, ignored for `:rails_view`)
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 RailsView (Phlex, Slim, LiveView).
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
- ## ViewComponent emission targets
182
+ ## Emission targets
167
183
 
168
- Two backends, picked via `--as` on the CLI or `backend:` in the API:
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
@@ -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?