jsx_rosetta 0.3.0 → 0.5.1

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: 229956b183e3760f9555874fef214d7ce66d549a603e9d431f5c28251db25bb4
4
+ data.tar.gz: 226c173f5e1010d154801740a2924bbc776ff8eede26587ec1d9d796da51c5f1
5
5
  SHA512:
6
- metadata.gz: c053e3111f0dc98f5ce3549d0c50a06f04f69bdbb13f5206296e807c62174b1e3824c9ac30d327884c20d1dd39d4fa821bec931292ff9802343b7d8c6a1da1ce
7
- data.tar.gz: d06787398b3c56482c881184f0cdcbe6ce20071c9c6a1d1cc411fad1514a5a4910d9d7ddfd78455107e14f4285fdf4dcf2299f42a48622790bccb9c7d02bfc17
6
+ metadata.gz: 4dbf8ac15094f2462a38c65dfbf539163cfb471077e4bd8874893eda3262da031df72da161793f4c65d75597d55c1d7ea09b395f991fb7fcf80b074d6a74e7ca
7
+ data.tar.gz: 94627c0471abeab22e92727047ec9e870439297bc6df6447da1bfb79914415d14abc665d3b2325d9496615e62c3f0099898db11a6c05dfd37acf739aa0af518c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,339 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1] - 2026-05-11
4
+
5
+ A correctness pass on the v0.5.0 Phlex output. A random-sample review
6
+ surfaced six classes of render-time NameError that `ruby -c` couldn't
7
+ catch (the file parses, but evaluation crashes on a bare reference).
8
+ Each one is now closed at the source — the file loads and at worst
9
+ renders empty where a TODO marker tells the reviewer what to fill in.
10
+ Stress test stays at 895/929 clean and 0/1240 syntax failures; lint
11
+ volume on the generated `.rb` files drops ~38% (5443 → 3373 offenses).
12
+
13
+ ### Fixed — runtime NameErrors at render time
14
+
15
+ - **Destructure leaks downstream of an untranslatable init.** `const
16
+ { fieldValue } = customField` is captured as a TODO, but every use
17
+ site (`fieldValue?.X`, `!fieldValue`, `fieldValue > 0`) used to leak
18
+ as a bare snake_case ref — file loaded, but `field_value&.__typename`
19
+ NameErrored on first render. The translator now bails the whole
20
+ expression when a known-local sits at a member-chain root, on the
21
+ operand side of unary, or on either side of binary. The caller emits
22
+ a `# TODO: translate condition: …` line and falls through to an
23
+ `if false` fallback so the file at least loads.
24
+ - **Inline arrow handlers on PascalCase components.** `const handleClick
25
+ = () => {}` attached to `<Button onClick={handleClick}>` no longer
26
+ leaks as bare `handle_click`. Unconsumed local-arrow names are unioned
27
+ into `Component#local_binding_names`, so use sites emit `on_click: nil`
28
+ instead of a reference to an undefined method.
29
+ - **CamelCase / snake_case rest-prop mismatch.** `**descriptionProps`
30
+ emitted `**descriptionProps` in the initializer signature but the body
31
+ read `**(@description_props || {})` — silently dropped. The rest-name
32
+ is now snake_cased on both sides. `IR::Prop` gains an `alias_name:`
33
+ field so renamed destructures (`"data-testid": dataTestId`) resolve
34
+ the alias to the prop's snake_case ivar at use sites.
35
+ - **Unitless numeric inline styles.** `style={{ marginBottom: 16 }}` used
36
+ to emit `style: 'margin-bottom: 16;'` — invalid CSS, silently ignored
37
+ by browsers. Numeric style values now get a `px` suffix for any
38
+ property not in React's `isUnitlessNumber` table (`zIndex`,
39
+ `lineHeight`, `opacity`, etc. stay bare).
40
+ - **Style declarations rooted at an unresolvable local.** `style={{
41
+ borderRadius: token.borderRadius }}` used to emit `"border-radius:
42
+ #{token.borderRadius};"` and NameError at render. The style
43
+ declaration now drops with a per-decl TODO when its value can't
44
+ translate.
45
+ - **Uppercase / SCREAMING_SNAKE imports in attribute values.** JSX
46
+ `<Foo bar={DEFAULT_PAGE_SIZE}>` used to emit `bar: default_page_size`
47
+ (NameError at the call site). Attribute-position interpolation now
48
+ drops with a TODO when any unresolved identifier starts with
49
+ uppercase — almost always an external import. Lowercase unresolved
50
+ identifiers still pass through unchanged (they may be Rails helpers
51
+ like `current_user`).
52
+
53
+ ### Changed — rubocop output cleanup (5443 → 3373 offenses across 1240 .rb)
54
+
55
+ - **`Lint/MissingSuper`** — initializers emit `super()` to satisfy
56
+ Phlex 2.x's superclass.
57
+ - **`Style/Documentation`** — one-line class docstring above every
58
+ generated class (`# X — generated by jsx_rosetta from JSX. Review
59
+ before shipping.`).
60
+ - **`Style/StringLiterals`** — new helper `AST::Inflector.ruby_string_literal`
61
+ emits single-quoted strings when safe (no embedded single quote,
62
+ backslash, or control char). Non-ASCII like emojis stays in single
63
+ quotes. The translator also re-emits JS string literals (`"hi"` →
64
+ `'hi'`) when the same constraints hold.
65
+ - **`Style/NilComparison` / `Style/NonNilCheck`** — rewrite `x == nil`
66
+ and `x != nil` as `x.nil?` / `!x.nil?` at the binary-translation
67
+ layer.
68
+ - **`Lint/BinaryOperatorWithIdenticalOperands`** — dedupe `||` / `&&`
69
+ when both sides translate to the same Ruby. `value === null ||
70
+ value === undefined` collapses to `@value.nil?` instead of
71
+ `@value.nil? || @value.nil?`.
72
+ - **`Lint/EmptyInterpolation`** — bail on the whole template literal
73
+ when any interpolation segment fails translation or resolves to
74
+ literal `nil`. No more `"prefix-#{}-suffix"`.
75
+ - **`Lint/LiteralAsCondition` / `Lint/DuplicateElsifCondition`** —
76
+ emit a file-level `# rubocop:disable` directive (with a matching
77
+ enable at the bottom of the file) only when an intentional `if false`
78
+ fallback appears. The `# TODO: translate condition:` line above the
79
+ fallback already names the issue, so the cop's report would be
80
+ redundant noise.
81
+ - **`Style/IfInsideElse`** — refactor conditional rendering to chain
82
+ `if / elsif / elsif / else / end` instead of nested
83
+ `if / else / if / else / end`. Cascades into fewer
84
+ `Metrics/BlockNesting`, `Metrics/AbcSize`, and
85
+ `Lint/DuplicateElsifCondition` offenses too.
86
+ - **`Layout/TrailingWhitespace`** — post-process emitted output to
87
+ rstrip every line.
88
+ - **`Layout/FirstHashElementIndentation`** — force-inline object/array
89
+ literals inside `initialize` default values; the column mismatch
90
+ that triggered the cop only happens when the wrap kicks in there.
91
+
92
+ ## [0.5.0] - 2026-05-11
93
+
94
+ Closes the four v0.5.0-candidate items from the v0.4.0 Phlex sample
95
+ review, plus the four larger features queued at the top of the
96
+ roadmap: Apollo + Next.js hook hint translation, class-component
97
+ support, AG-Grid column-descriptor module emission, and pretty-printing
98
+ for long object/array literals.
99
+
100
+ ### Added — class-component support
101
+
102
+ - **`ClassDeclaration` → ViewComponent path.** Classes with a `render()`
103
+ method now lower as components instead of getting flagged with the
104
+ `:class_component` rejection. The `ExpressionTranslator` recognizes
105
+ `this.props.X` and translates to `@x` (plus a snake_case member chain
106
+ for `this.props.X.y.z`); `this.state.X` translates to `nil` since
107
+ there's no Rails-side equivalent without a backing data source. Other
108
+ class members (constructor, lifecycle hooks like
109
+ `componentDidCatch`/`getDerivedStateFromError`, custom event handlers)
110
+ get captured as LocalBinding-style TODO comments at the top of the
111
+ view template so the reviewer sees the verbatim sources. Props are
112
+ synthesized from direct `this.props.X` access AND from
113
+ `const { X } = this.props` destructure patterns — the generated
114
+ `initialize(...)` matches the original class's prop set.
115
+ Stress-test impact: the 4 class-component residuals (ErrorBoundary
116
+ and cousins) now translate cleanly.
117
+
118
+ ### Added — AG-Grid column-descriptor module emission
119
+
120
+ - **Data-factory components.** `export const createColumns = (token,
121
+ sortedInfo) => [{...}, {...}]` now lowers as an `IR::Component` with
122
+ `mode: :data_factory`. Phlex emits a snake_case method that returns
123
+ the translated array — `def create_columns(token: nil, sorted_info: nil)`
124
+ — instead of `view_template`. JSX inside object properties extracts to
125
+ private methods on the class via the existing IR::Lambda path
126
+ (`render: method(:render_id_cell)`). ViewComponent backend emits a
127
+ plain Ruby class with the method and a TODO note (the .erb pair is
128
+ skipped — pure-data classes don't have a template). Multi-positional
129
+ Identifier params are now also supported by `lower_params` — previously
130
+ only single-arg React-style signatures lowered cleanly.
131
+
132
+ ### Added — pretty-printing
133
+
134
+ - **Multi-line layout for long object/array literals.** When the single-
135
+ line rendering of an `IR::ObjectLiteral` or `IR::ArrayLiteral` exceeds
136
+ `LITERAL_INLINE_BUDGET` (80 chars), or contains a nested literal that
137
+ itself wrapped, the layout switches to one entry per line indented
138
+ two spaces past the parent line's indent. Closing bracket re-aligns
139
+ to the parent indent. Short literals (typical Select options, small
140
+ config objects) stay inline so non-AG-Grid output is unchanged. Helps
141
+ readability of the column-descriptor output from the new
142
+ data-factory path.
143
+
144
+ ### Stress test outcome
145
+
146
+ - 929-file Phlex stress rerun: **895/929 clean translations**
147
+ (up from 887/929 in v0.4.0 — 8 additional files now translate via
148
+ the class-component and data-factory paths). **0/1240 emitted `.rb`
149
+ files fail `ruby -c`** (unchanged; 16 more files emitted vs v0.4.0).
150
+ 221/929 files carry an Apollo TODO block (281 GraphQL operation
151
+ names captured); 105/929 carry a Next.js navigation-hook block.
152
+ - 385 specs, all green; rubocop clean.
153
+
154
+ ### Added — framework hook hints
155
+
156
+ - **Apollo data-fetching hooks recognized.** `useQuery`, `useLazyQuery`,
157
+ `useMutation`, `useSubscription`, and `useApolloClient` are now
158
+ detected at lowering time. Each call lands in `Component#react_hooks`
159
+ tagged `library: :apollo`, with the GraphQL operation name extracted
160
+ from a bare-Identifier first argument (`useQuery(GET_USERS_QUERY, …)`
161
+ → `operation: "GET_USERS_QUERY"`). Both backends emit a dedicated
162
+ Apollo TODO block above the template that points at the Rails analog
163
+ (move the fetch to the controller; mutations become form POSTs or
164
+ Turbo Stream responses). Operation names are echoed in the comment so
165
+ the reviewer can match the call back to its GraphQL document.
166
+ Destructured names (`{ data, loading, error }` from `useQuery`,
167
+ `[mutate, { loading }]` from `useMutation`) are captured in
168
+ `local_binding_names` so use sites translate to `nil` placeholders
169
+ instead of raising NameError at render time.
170
+ - **Next.js navigation hooks recognized.** `useRouter`, `usePathname`,
171
+ `useSearchParams`, `useParams`, `useSelectedLayoutSegment`, and
172
+ `useSelectedLayoutSegments` get the same treatment — tagged
173
+ `library: :next_js`, surfaced in a dedicated TODO block listing each
174
+ hook's Rails equivalent (`useRouter` → `redirect_to`; `usePathname`
175
+ → `request.path`; `useSearchParams` / `useParams` → `params`;
176
+ `useSelectedLayoutSegment(s)` → pattern-match `request.path`).
177
+
178
+ The `ReactHookCall` IR type gains `library` and `operation` fields;
179
+ both backends group hooks by library and emit one TODO block per group.
180
+ React-only files keep the unchanged single-block output.
181
+
182
+ ### Added
183
+
184
+ - **`BinaryExpression` and `LogicalExpression` translation.**
185
+ `email.emailAttachments.length > 0` now translates to
186
+ `@email.email_attachments.length > 0` instead of bailing to `if false`.
187
+ Covers `===`/`!==`/`==`/`!=`/`<`/`>`/`<=`/`>=`/`&&`/`||`/`??`. JS-only
188
+ operators map to their Ruby equivalents (`===` → `==`, `??` → `||`).
189
+ Recursive splitting respects nested parens and string literals; outer
190
+ parens are stripped so `(a > b) && c` parses cleanly.
191
+ - **Optional chaining (`?.`) → safe navigation (`&.`).** `user?.profile?.name`
192
+ now emits `@user&.profile&.name` instead of leaving a Ruby SyntaxError
193
+ in member-chain interpolations.
194
+ - **Nested render-function locals extracted to methods.**
195
+ `const renderHeader = () => <h1/>; ... {renderHeader()}` previously
196
+ dropped to `[untranslated: renderHeader()]`. New IR types `RenderMethod`
197
+ and `LocalRenderCall` mean the arrow gets extracted to a private method
198
+ on the Phlex class (`def render_header; h1 do; ...; end`) and the use
199
+ site emits a direct call. Args translate through the same path as
200
+ attribute interpolations. The ViewComponent backend emits a method
201
+ skeleton with a TODO since ERB-method bodies don't translate cleanly
202
+ to Ruby fragments.
203
+
204
+ ### Fixed
205
+
206
+ - **`useCallback` / `useRef` / `useMemo` identifier bindings recognized.**
207
+ `const handleChange = useCallback(...)` followed by
208
+ `onChange={handleChange}` used to emit `on_change: handle_change`
209
+ referencing a nonexistent method. The binding name is now captured in
210
+ `Component#local_binding_names` so the translator emits `nil` at use
211
+ sites, matching the destructure-pattern behavior added in v0.4.0.
212
+ - **Conditional guard on a known local no longer collapses to `if nil`.**
213
+ `error && <X />` where `error` is destructured from a hook used to
214
+ translate to `if nil` (the local-binding placeholder) — Ruby-valid but
215
+ silently never-rendering. Both backends now treat a `"nil"` translation
216
+ as untranslatable, falling through to the TODO-emission path so the
217
+ reviewer sees what to fill in.
218
+
219
+ ## [0.4.0] - 2026-05-11
220
+
221
+ Closes nine translation gaps identified during a sample review of 12
222
+ random Phlex outputs from the v0.3.0 stress run. Each gap was a silent
223
+ drop, a render-time NameError, or a semantic mistranslation — the
224
+ generated Ruby parsed but didn't behave like the source JSX.
225
+
226
+ ### Added
227
+
228
+ - **Render-prop / function-as-children support.**
229
+ `<Form.List>{(fields) => <p>{fields}</p>}</Form.List>` now lowers to a
230
+ new `IR::RenderProp` and emits as a Ruby block on the parent `render`
231
+ call: `render Form::List.new do |fields| ... end`. Both Phlex and
232
+ ViewComponent backends emit the block; param names snake_case to match
233
+ Ruby convention. Previously dropped as `plain "[untranslated: ...]"`.
234
+ - **Recursive object/array/lambda translation in attribute values.**
235
+ New IR types `ObjectLiteral`, `ArrayLiteral`, and `Lambda` mean that
236
+ `<Select options={[{ value: 10, label: "10 / page" }]} />` now emits
237
+ `options: [{ value: 10, label: "10 / page" }]` (Ruby array of hashes)
238
+ instead of `options: nil` with a TODO. Identifier keys snake_case
239
+ (`dataLabel` → `data_label`); nested literals recurse. Function-valued
240
+ properties — e.g. AG-Grid `render: (v) => <Tag>{v}</Tag>` — extract to
241
+ private methods on the class (`def render_render(v); span do; ...; end`)
242
+ and the property emits as `render: method(:render_render)` so the
243
+ body has access to the Phlex tag.* helpers.
244
+ - **Module-level constants captured into `Component#module_bindings`.**
245
+ `const FOO = 400; function X() { return <p>{FOO}</p>; }` no longer
246
+ silently drops the FOO declaration — the backend emits a TODO comment
247
+ block above the class with the original source so the reviewer either
248
+ translates it to a Ruby constant or moves it to a Rails initializer.
249
+ - **Destructure-pattern names recognized by the translator.**
250
+ `const [count, setCount] = useState(0); <p>{count}</p>` previously
251
+ emitted a bare `plain count` that NameErrored at render time. The
252
+ destructured names are now captured in `Component#local_binding_names`
253
+ and the `ExpressionTranslator` emits a `nil` placeholder for them so
254
+ the file at least loads. Covers `ArrayPattern`, `ObjectPattern`
255
+ (including aliased properties, `AssignmentPattern` defaults, and
256
+ `RestElement`), and hook-tuple destructures.
257
+ - **Member-expression destructure resolution.** `const { Content } = Layout`
258
+ followed by `<Content/>` now resolves to `Layout::Content`, not a
259
+ free-floating `ContentComponent`. Multiple destructured names from the
260
+ same parent identifier all resolve correctly.
261
+
262
+ ### Fixed
263
+
264
+ - **Component-prop callbacks no longer over-promote to Stimulus.**
265
+ `<Select onChange={onChange} />` (PascalCase tag) previously emitted
266
+ `data-action="change->foo#onChange"` — a Stimulus action descriptor
267
+ that never fires because the receiving component is a Ruby class, not
268
+ a DOM element. The lowering now checks `html_element?(tag)` before
269
+ promoting `on*` attrs and treats component-prop callbacks as regular
270
+ `IR::Attribute` kwargs. Stimulus promotion still applies to lowercase
271
+ HTML tags as before. Closes a regression introduced by the v0.3.0
272
+ prop-handler Stimulus promotion.
273
+ - **Spread-of-nil no longer raises at render time.** `<div {...maybeNil}>`
274
+ used to emit `**@maybe_nil`, which raises when the prop is `nil`. Both
275
+ Phlex and ViewComponent backends now wrap as `**(@maybe_nil || {})`.
276
+ Cheap to emit unconditionally and idempotent.
277
+ - **Duplicate handler names are no longer silently renamed.** Two
278
+ `onClick={handleReset}` handlers in one component previously produced
279
+ `handleReset` / `handleReset2` with no marker. `StimulusMethod` now
280
+ carries an `original_name` field, and the generated controller JS
281
+ emits a `// NOTE: method renamed from "handleReset"` comment above the
282
+ renamed method.
283
+ - **ReactNode-typed props get a `raw` hint comment.** When an
284
+ interpolation translates to a bare `@ivar` reference (likely a prop),
285
+ the Phlex backend emits a comment hint —
286
+ `plain @extra # NOTE: use \`raw\` instead of \`plain\` if this is a
287
+ ReactNode-typed prop`. `plain` HTML-escapes its argument, which is
288
+ wrong for prebuilt-markup props but right for strings; we can't tell
289
+ at translation time, so we default to safe (`plain`) and surface the
290
+ choice.
291
+
292
+ ### Investigation
293
+
294
+ - **Sibling named exports — not a gap.** Probed four shapes of
295
+ `export default Foo; export function Loading() {}` against the gem;
296
+ all four shapes correctly emit both `foo.rb` and `loading.rb`. Closed
297
+ the suspected gap as a false positive from the random sample.
298
+
299
+ ### Refactored
300
+
301
+ - `Lowering` class shrunk by ~150 lines (v0.3.0 prep, now shipping):
302
+ pure-heuristic `ModuleShapeClassifier` lives in its own file; helper
303
+ methods `AST::Node#child`, `#of_type?`, `Node.matches?` replaced ~25
304
+ defensive `is_a?(AST::Node) && type ==` checks; class/style rendering
305
+ in the ViewComponent backend deduplicated across HTML-vs-Ruby output
306
+ formats; `tag_builder_data_action` replaced its "parse what I just
307
+ emitted" heuristic with a structured `EventDescriptor` intermediate.
308
+
309
+ ### Stress test outcome
310
+
311
+ - 929-file Phlex stress run on `reserv-web`: 887/929 clean translations
312
+ (unchanged — rejection logic untouched), **0/1224 syntax failures**
313
+ (down from 25 on v0.3.0). All emitted `.rb` files now pass `ruby -c`.
314
+ - Five residual bugs were caught during the v0.4.0 stress rerun and
315
+ fixed inline:
316
+ - Prop default expressions that translated to `nil # TODO: ...` inside
317
+ `initialize(...)` swallowed the closing `)`. Prop defaults now route
318
+ through the same recursive lowering as attribute values.
319
+ - Multi-line JSX comments only prefixed the first line with `#`. Every
320
+ line of a comment now gets a `#` prefix.
321
+ - Template literals with inner `"` or `\\` characters could break the
322
+ surrounding Ruby string. Literal segments now escape both.
323
+ - `token.blue` (where `token` was a captured local binding) translated
324
+ to `nil.blue` — `NoMethodError` at render time. Member-chain roots
325
+ that resolve to a known-local binding now fall through to the
326
+ snake_case bare reference with an unresolved marker rather than the
327
+ `nil` placeholder.
328
+ - `["a", "b"].map((x) => <li/>)` lost the array literal and emitted
329
+ `[].each` because the translator can't parse `[...]`. The lowering
330
+ now recognizes ArrayExpression iterables and routes them through
331
+ the recursive ArrayLiteral path.
332
+
333
+ ### Spec count
334
+
335
+ - Up to 343 examples (from 304), all green. `bundle exec rubocop` clean.
336
+
3
337
  ## [0.3.0] - 2026-05-10
4
338
 
5
339
  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
data/ROADMAP.md ADDED
@@ -0,0 +1,92 @@
1
+ # Roadmap
2
+
3
+ Forward-looking list of work items. Shipped releases are documented in
4
+ [CHANGELOG.md](CHANGELOG.md); the v0.1.0 design lives in [PLAN.md](PLAN.md).
5
+
6
+ Items are tagged by source so the lineage is traceable:
7
+ - **[review]** — surfaced by a subagent sample review of the Phlex
8
+ stress run (highest signal — these are gaps seen in the wild).
9
+ - **[plan-oos]** — explicitly listed as out-of-scope in a prior release plan.
10
+ - **[stress]** — surfaced by the 929-file stress run rejection logs.
11
+
12
+ ## Next up
13
+
14
+ v0.5.0 ships every roadmap "Larger features" item that was queued. The
15
+ remaining work in this file is Stress-test residual triage and Polish.
16
+
17
+ ## v0.5+ — Larger features
18
+
19
+ _All previously-listed items shipped in v0.5.0. Drop new larger
20
+ features here as they surface._
21
+
22
+ ## Stress-test residuals (42/929 rejected)
23
+
24
+ The non-component-shape rejections each have a classifier-tagged error
25
+ message; most are intentional drops. Worth revisiting if a specific
26
+ shape becomes high-value:
27
+
28
+ - [ ] Custom-hooks modules (`useFoo.ts` returning behavior) —
29
+ classifier says "translate behavior to Stimulus." [stress]
30
+ - [ ] Side-effect-only modules — classifier says "register in a Rails
31
+ initializer." [stress]
32
+ - [ ] Types-only / constants-only modules — currently dropped; could
33
+ emit Ruby constants for the constants subset. [stress]
34
+ - [ ] Mixed-export modules — classifier asks the human to split the
35
+ file. No automated fix planned. [stress]
36
+
37
+ ## Polish / quality
38
+
39
+ - [ ] **Spec coverage gap.** Some v0.4.0 fixes (template-literal
40
+ escaping, multi-line comment prefixing) have one spec each; consider
41
+ fuzzing across a broader corpus.
42
+ - [ ] **README update.** v0.3.0 added the Phlex backend, v0.4.0 closed
43
+ many gaps — README still describes the v0.2.0 ViewComponent-only
44
+ surface. Add a Phlex section and an example of the recursive
45
+ object-literal translation.
46
+ - [ ] **CHANGELOG TLDR.** The v0.4.0 entry is dense; a one-paragraph
47
+ "what this means for users" intro would help.
48
+
49
+ ## Done — historical reference
50
+
51
+ See [CHANGELOG.md](CHANGELOG.md). Major arcs to date:
52
+ - **v0.1.0** — ViewComponent backend, three-stage pipeline.
53
+ - **v0.2.0** — Stimulus extraction, sidecar layout, helpers, RailsView,
54
+ routes, compound components, asChild polymorphism, React hooks.
55
+ - **v0.3.0** — Phlex 2.x backend (three naming strategies), 887/929
56
+ clean translations.
57
+ - **v0.4.0** — Closed nine gaps surfaced by a sample review of v0.3.0
58
+ Phlex output (A, B, D, E, F, G, H, J, K); 0/1224 syntax failures
59
+ (down from 25); 343 specs.
60
+ - **v0.5.0** — Closed all four v0.5.0 candidate items from the v0.4.0
61
+ sample review **plus** every "Larger features" item that was queued:
62
+ - useCallback / useRef / useMemo identifier-bound hook results captured
63
+ in `local_binding_names` so use sites emit `nil` instead of bare
64
+ snake_case refs to nonexistent methods.
65
+ - `BinaryExpression` / `LogicalExpression` translation in the
66
+ `ExpressionTranslator` (incl. `===`/`!==`/`??` mapping).
67
+ - Optional chaining (`?.`) → safe navigation (`&.`) in member chains.
68
+ - Nested render-function locals (`const renderHeader = () => <div/>;
69
+ ... {renderHeader()}`) extracted to private methods on the class.
70
+ - `error && <X/>` guard on a known local no longer collapses to
71
+ `if nil` — falls through to the TODO path.
72
+ - Apollo hooks (`useQuery` / `useLazyQuery` / `useMutation` /
73
+ `useSubscription` / `useApolloClient`) detected with the GraphQL
74
+ operation name extracted from a bare-Identifier first argument;
75
+ emitted as a dedicated TODO block pointing at the Rails controller
76
+ fetch. Stress-test impact: 221/929 files now carry the Apollo block
77
+ (281 operation names captured).
78
+ - Next.js navigation hooks (`useRouter` / `usePathname` /
79
+ `useSearchParams` / `useParams` / `useSelectedLayoutSegment(s)`)
80
+ detected and surfaced in a dedicated TODO block listing each
81
+ hook's Rails analog. Stress-test impact: 105/929 files now carry
82
+ the Next.js block.
83
+ - Class-component support (`render()` method extraction, `this.props.X`
84
+ → `@x` translation, non-render members captured as TODO comments).
85
+ The 4 class-component residuals now translate cleanly.
86
+ - Data-factory components (`export const createColumns = (token) =>
87
+ [{...}]`) lower with `mode: :data_factory`; Phlex emits a snake_case
88
+ method that returns the translated array, with JSX render lambdas
89
+ extracted to private methods.
90
+ - Pretty-printing for long `ObjectLiteral` / `ArrayLiteral` output:
91
+ multi-line layout when single-line exceeds 80 chars; short literals
92
+ stay inline.
@@ -19,6 +19,21 @@ module JsxRosetta
19
19
  parts = string.split("_")
20
20
  parts[0] + parts[1..].map(&:capitalize).join
21
21
  end
22
+
23
+ # Emit a Ruby string literal in the rubocop-default single-quoted
24
+ # form when safe. Falls back to `String#inspect` (double-quoted with
25
+ # escapes) when the source contains characters that prevent the
26
+ # single-quoted form: single quotes themselves, backslashes (Ruby
27
+ # single-quoted strings only escape `\\` and `\'`), or control
28
+ # characters (`\n`, `\t`, etc — single-quoted strings render those
29
+ # literally). Non-ASCII characters (emojis, unicode) are fine in
30
+ # single-quoted strings, so they don't force the fallback.
31
+ def ruby_string_literal(value)
32
+ str = value.to_s
33
+ return str.inspect if str.include?("'") || str.include?("\\") || str.match?(/[\x00-\x1f\x7f]/)
34
+
35
+ "'#{str}'"
36
+ end
22
37
  end
23
38
  end
24
39
  end
@@ -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?