jsx_rosetta 0.4.0 → 0.6.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +342 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/ROADMAP.md +92 -0
  6. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  7. data/lib/jsx_rosetta/ast/inflector.rb +32 -0
  8. data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
  9. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  10. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
  11. data/lib/jsx_rosetta/backend/view_component.rb +261 -31
  12. data/lib/jsx_rosetta/cli.rb +175 -37
  13. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  14. data/lib/jsx_rosetta/icons.rb +44 -0
  15. data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
  16. data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
  17. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  18. data/lib/jsx_rosetta/ir/types.rb +264 -19
  19. data/lib/jsx_rosetta/ir.rb +5 -4
  20. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  21. data/lib/jsx_rosetta/version.rb +1 -1
  22. data/lib/jsx_rosetta.rb +8 -6
  23. data/plans/nextjs_pages_to_rails.md +200 -0
  24. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  25. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  26. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  27. data/plans/translator_widening_and_pages_followups.md +120 -0
  28. data/plans/translator_widening_slice_a.md +208 -0
  29. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  30. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  31. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  32. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  39. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  40. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  43. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  44. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  45. metadata +30 -1
@@ -78,9 +78,28 @@ module JsxRosetta
78
78
  name.start_with?("use") && name.length > 3 && name[3] == name[3].upcase
79
79
  end
80
80
 
81
+ # Only flag class-component shape when the class has no usable render
82
+ # method. Classes WITH `render()` lower via the
83
+ # ClassDeclaration → ViewComponent path added in v0.5.0, so the
84
+ # classifier should leave them alone and let the regular component
85
+ # finder pick them up.
81
86
  def class_component?(stmt)
82
87
  decl = stmt.of_type?(*EXPORT_TYPES) ? stmt[:declaration] : stmt
83
- AST::Node.matches?(decl, "ClassDeclaration")
88
+ return false unless AST::Node.matches?(decl, "ClassDeclaration")
89
+
90
+ !class_has_render_method?(decl)
91
+ end
92
+
93
+ def class_has_render_method?(class_decl)
94
+ body = class_decl.child(:body)
95
+ return false unless body
96
+
97
+ body[:body].any? do |member|
98
+ next false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
99
+
100
+ key = member.child(:key)
101
+ AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
102
+ end
84
103
  end
85
104
 
86
105
  # Recognize `export const X = React.memo(...)` (export wrapper) or a
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsxRosetta
4
+ module IR
5
+ # When a shadcn TSX wraps a Radix primitive like
6
+ # `<SeparatorPrimitive.Root orientation="horizontal" />`, the translator
7
+ # has historically lowered it to a `ComponentInvocation` whose name is
8
+ # the member chain (`"SeparatorPrimitive.Root"`) — which emits as
9
+ # `render SeparatorPrimitive::Root.new(...)`, an undefined-constant
10
+ # NameError at render time.
11
+ #
12
+ # The shapes underneath are stable enough across Radix that we can map
13
+ # the common (LocalImportName, MemberName) pairs to plain HTML elements
14
+ # plus any always-applied attributes. The mapping is keyed on the LOCAL
15
+ # binding name shadcn uses by convention (e.g. `SeparatorPrimitive`),
16
+ # which matches the import-aliasing pattern in every shadcn fork I've
17
+ # surveyed. Unknown pairs fall through to the existing ComponentInvocation
18
+ # / TODO behavior so consumers can hand-shim primitives we don't cover.
19
+ #
20
+ # Source specifiers that count as "Radix" — both the `radix-ui` umbrella
21
+ # package and the older `@radix-ui/react-*` per-primitive packages.
22
+ RADIX_SOURCE_PATTERN = %r{\A(?:radix-ui|@radix-ui/react-[\w-]+)\z}
23
+
24
+ # Local binding names that resolve to Radix's `Slot` primitive.
25
+ # Matches `Slot` (shadcn-v4 umbrella) and `SlotPrimitive` (older
26
+ # convention). Anything looser would silently drop user-defined
27
+ # components whose names happen to start with "Slot".
28
+ SLOT_LOCAL_NAME_PATTERN = /\ASlot(?:Primitive)?\z/
29
+
30
+ module RadixRegistry
31
+ # Each entry maps [PrimitiveBaseName, MemberName] →
32
+ # { tag: <html-tag>, attrs: { <fixed kwarg name> => <value> } }
33
+ # The `attrs` are merged into the element's lowered attributes (the
34
+ # consumer's own kwargs win on conflict — see `merge_radix_attrs`).
35
+ #
36
+ # `PrimitiveBaseName` is the *canonical* Radix primitive name
37
+ # (e.g. `Separator`); the lookup strips an optional `Primitive` suffix
38
+ # from the consumer's local binding before keying in, so both shadcn-v3
39
+ # (`import { Separator as SeparatorPrimitive } from "radix-ui"`) and
40
+ # shadcn-v4 (`import { Separator } from "radix-ui"`) umbrella idioms
41
+ # resolve. Namespace imports (`import * as SeparatorPrimitive from
42
+ # "@radix-ui/react-separator"`) also strip the suffix.
43
+ MAP = {
44
+ # Separator / Label / Avatar / Switch primitives — the shapes we hit
45
+ # most often in the bulk shadcn translation pass.
46
+ %w[Separator Root] => { tag: "div", attrs: { role: "separator" } },
47
+ %w[Label Root] => { tag: "label", attrs: {} },
48
+ %w[Avatar Root] => { tag: "span", attrs: {} },
49
+ %w[Avatar Image] => { tag: "img", attrs: {} },
50
+ %w[Avatar Fallback] => { tag: "span", attrs: {} },
51
+ %w[Switch Root] => { tag: "button", attrs: { type: "button", role: "switch" } },
52
+ %w[Switch Thumb] => { tag: "span", attrs: {} },
53
+ %w[Progress Root] => { tag: "div", attrs: { role: "progressbar" } },
54
+ %w[Progress Indicator] => { tag: "div", attrs: {} },
55
+ # Aspect-ratio + scroll-area roots are presentational containers.
56
+ %w[AspectRatio Root] => { tag: "div", attrs: {} },
57
+ %w[ScrollArea Root] => { tag: "div", attrs: {} },
58
+ %w[ScrollArea Viewport] => { tag: "div", attrs: {} },
59
+ # Tabs primitives — `data-orientation` comes from the JSX attrs;
60
+ # role=tablist on List + role=tab on Trigger + role=tabpanel on Content.
61
+ %w[Tabs Root] => { tag: "div", attrs: {} },
62
+ %w[Tabs List] => { tag: "div", attrs: { role: "tablist" } },
63
+ %w[Tabs Trigger] => { tag: "button", attrs: { type: "button", role: "tab" } },
64
+ %w[Tabs Content] => { tag: "div", attrs: { role: "tabpanel" } },
65
+ # Toggle / ToggleGroup — pressable buttons.
66
+ %w[Toggle Root] => { tag: "button", attrs: { type: "button" } },
67
+ %w[ToggleGroup Root] => { tag: "div", attrs: { role: "group" } },
68
+ %w[ToggleGroup Item] => { tag: "button", attrs: { type: "button" } },
69
+ # Collapsible — presentational containers; the open/closed state is
70
+ # data-state driven by the consumer (Stimulus or otherwise).
71
+ %w[Collapsible Root] => { tag: "div", attrs: {} },
72
+ %w[Collapsible Trigger] => { tag: "button", attrs: { type: "button" } },
73
+ %w[Collapsible Content] => { tag: "div", attrs: {} }
74
+ }.freeze
75
+
76
+ # Strip an optional `Primitive` suffix from the local binding so the
77
+ # registry's canonical names match both umbrella imports (`Separator`)
78
+ # and the older convention (`SeparatorPrimitive`).
79
+ def self.lookup(local_name, member)
80
+ MAP[[local_name, member]] || MAP[[local_name.sub(/Primitive\z/, ""), member]]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -33,12 +33,19 @@ module JsxRosetta
33
33
  # inline arrows / const-bound arrows used in onX={...}.
34
34
  # When non-empty, backends should emit a sibling
35
35
  # Stimulus controller file alongside the .rb/.erb pair.
36
- # react_hooks : [ReactHookCall] — calls to React hooks (useState,
37
- # useEffect, useRef, useContext, useMemo, useCallback,
38
- # useReducer, useImperativeHandle, useLayoutEffect).
39
- # Surfaced as a distinct TODO block so the human
40
- # reviewer knows to translate behavior to Stimulus
41
- # and state to server-side rendering.
36
+ # react_hooks : [ReactHookCall] — every recognized hook invocation
37
+ # in the component body, regardless of library.
38
+ # Includes React's built-in hooks (useState, useEffect,
39
+ # useRef, useContext, useMemo, useCallback, useReducer,
40
+ # useImperativeHandle, useLayoutEffect), Apollo hooks
41
+ # (useQuery, useMutation, useLazyQuery, useSubscription,
42
+ # useApolloClient), and Next.js navigation hooks
43
+ # (useRouter, usePathname, useSearchParams, useParams,
44
+ # useSelectedLayoutSegment(s)). Each call carries a
45
+ # `library` tag so backends can group them and emit a
46
+ # library-specific TODO pointing at the right Rails
47
+ # analog (Stimulus/server-render for React; controller
48
+ # fetch for Apollo; request.path/params for Next.js).
42
49
  # module_bindings : [LocalBinding] — top-level `const`/`let` declarations
43
50
  # outside the component function that aren't themselves
44
51
  # components. Captured so backends can either translate
@@ -47,10 +54,90 @@ module JsxRosetta
47
54
  # Without this capture, references to module-level
48
55
  # constants from inside the JSX silently drop and
49
56
  # produce unbacked snake_case references at render time.
57
+ # module_imports : [ModuleImport] — top-level `import` declarations.
58
+ # Backends thread the imported names into the
59
+ # ExpressionTranslator so any expression-context
60
+ # reference to an import (e.g. `styles.listContainer`
61
+ # from `import styles from "./X.module.css"`, or
62
+ # `AlertStatusEnum.Pending` from a TS enum import)
63
+ # bails out to a TODO instead of snake-casing to a
64
+ # bare identifier that NameErrors at render time.
65
+ # render_methods : [RenderMethod] — local arrow bindings that return JSX
66
+ # and are invoked from the JSX body (`const renderHeader
67
+ # = () => <div/>; ... {renderHeader()}`). Backends emit
68
+ # each as a private method on the generated class and
69
+ # reference it from a LocalRenderCall at the use site.
70
+ # mode : Symbol — `:view` for a normal Phlex/ViewComponent component
71
+ # whose body is rendered as JSX (the default); `:data_factory`
72
+ # for column-descriptor / option-list modules whose top-level
73
+ # export is a function returning an array of object literals.
74
+ # When `:data_factory`, the backend emits a snake_case method
75
+ # that returns the translated data, instead of `view_template`.
76
+ # JSX inside object properties still extracts to private
77
+ # methods on the class via the IR::Lambda path.
78
+ # server_data_source : ServerDataSource | nil — capture of a
79
+ # Next.js `getServerSideProps` / `getStaticProps`
80
+ # export from the same source file as this component.
81
+ # Body preserved verbatim so the Phlex backend can
82
+ # emit it as a TODO comment block above the class
83
+ # (the matching Rails controller action is where the
84
+ # user ports the data-loading logic). Attached only
85
+ # to the first component when a file contains
86
+ # multiple — Next.js page files have exactly one
87
+ # default-export component, so collisions are rare.
88
+ # hoc_wrappers : [String] — Higher-Order Component wrapper names
89
+ # that the lowering peeled off when finding this
90
+ # component (e.g. `memo`, `forwardRef`, `observer`,
91
+ # `connect`, `withRouter`). Recorded in
92
+ # outside-in order so a `memo(forwardRef(...))`
93
+ # emits as ["memo", "forwardRef"]. Backends surface
94
+ # these as a TODO comment block above the class
95
+ # explaining each wrapper's Rails analog (or
96
+ # lack of one). Empty for components without
97
+ # wrappers — the common case.
50
98
  Component = Data.define(:name, :props, :body, :rest_prop_name,
51
99
  :local_bindings, :local_binding_names,
52
- :module_bindings,
53
- :stimulus_methods, :react_hooks) do
100
+ :module_bindings, :module_imports,
101
+ :stimulus_methods, :react_hooks,
102
+ :render_methods, :mode, :server_data_source,
103
+ :hoc_wrappers) do
104
+ include Node
105
+ end
106
+
107
+ # A Next.js server data-loading export (`getServerSideProps` /
108
+ # `getStaticProps`). Captured at lowering time so the Phlex backend can
109
+ # surface the body as a TODO comment block pointed at the matching
110
+ # Rails controller action. Body is preserved verbatim — no JS-to-Ruby
111
+ # translation is attempted per project rules.
112
+ #
113
+ # hook_name : String — "getServerSideProps" or "getStaticProps".
114
+ # source : String — verbatim JS of the entire export statement.
115
+ ServerDataSource = Data.define(:hook_name, :source) do
116
+ include Node
117
+ end
118
+
119
+ # A top-level `import` declaration. Captured at lowering time so the
120
+ # ExpressionTranslator can recognize use-site references and bail out
121
+ # to a TODO instead of emitting a bare snake_case identifier that
122
+ # NameErrors at render time.
123
+ #
124
+ # name : String — the local binding name (the side the source uses
125
+ # to reference the imported value). For `import { foo as bar }`
126
+ # this is "bar"; for `import * as styles` this is "styles";
127
+ # for `import Default` this is "Default".
128
+ # source : String — the module specifier verbatim
129
+ # (e.g. "./styles.module.css", "@apollo/client", "react").
130
+ # Lets backends apply per-source policy later (e.g. always
131
+ # strip `*.module.css` references).
132
+ # kind : Symbol — :default | :named | :namespace.
133
+ # imported_name : String? — original exported name from the source module.
134
+ # For `import { ChevronRight as CR } from "lucide-react"`,
135
+ # `name` is `"CR"` and `imported_name` is `"ChevronRight"`.
136
+ # nil for default / namespace imports where there's no
137
+ # distinct exported name. Backends that look up vendored
138
+ # data by canonical name (icons) need the imported name;
139
+ # most callers want the local binding.
140
+ ModuleImport = Data.define(:name, :source, :kind, :imported_name) do
54
141
  include Node
55
142
  end
56
143
 
@@ -64,22 +151,116 @@ module JsxRosetta
64
151
  include Node
65
152
  end
66
153
 
67
- # A React hook invocation detected in the component body (`useState`,
68
- # `useEffect`, …). Surfaced separately from local_bindings so backends
69
- # can emit a more specific TODO that points at the Stimulus / Hotwire
70
- # / server-render alternative, instead of a generic "translate this JS".
154
+ # A module-level call to `cva()` from class-variance-authority. shadcn
155
+ # components ubiquitously use this builder to attach a base class string
156
+ # plus per-axis variant maps to a JSX component. The translator
157
+ # recognizes the pattern at lowering time so backends can emit real
158
+ # Ruby constants (`FOO_BASE_CLASS`, `FOO_VARIANT_CLASSES`) instead of
159
+ # leaving the call as a TODO comment, and so the use-site
160
+ # `cn(fooVariants({ variant }), className)` translates to a proper
161
+ # Ruby string interpolation.
162
+ #
163
+ # name : String — the const binding name (e.g. "buttonVariants").
164
+ # base_class : String — the first string argument to cva().
165
+ # variants : Hash[String => Hash[String => String]]
166
+ # — { "variant" => { "default" => "...", "outline" => "..." } }
167
+ # default_variants : Hash[String => String] — per-axis default value name
168
+ # (matched against the variant axis keys).
169
+ # compound_source : String | nil — INTENTIONALLY UNPARSED verbatim JS
170
+ # source of any `compoundVariants` entry. Field name
171
+ # reads structural; it isn't — backends only print
172
+ # it as a TODO comment alongside the constants since
173
+ # compoundVariants semantics aren't supported in the
174
+ # first cut.
175
+ CvaBinding = Data.define(:name, :base_class, :variants, :default_variants, :compound_source) do
176
+ include Node
177
+ end
178
+
179
+ # A literal-shaped module-level `const` declaration that lowers to a real
180
+ # Ruby constant. Distinct from `LocalBinding` (verbatim TODO block) and
181
+ # `CvaBinding` (structured cva variants). The detector accepts initializers
182
+ # whose value reduces to a Ruby-literal-friendly object — strings, numbers,
183
+ # booleans, null, arrays/hashes of the same. Non-literal initializers
184
+ # (call expressions, identifier references, JSX) still fall through to
185
+ # the `LocalBinding` path so their verbatim source surfaces in the
186
+ # module-bindings TODO block.
187
+ #
188
+ # name : String — original JS identifier (e.g. "TAGS", "COLUMNS").
189
+ # constant_name : String — Ruby constant identifier emitted above the class
190
+ # (`AST::Inflector.underscore(name).upcase`). Stored on the
191
+ # IR so future collision-detection has somewhere to bind.
192
+ # value : Object — Ruby-literal-friendly value (String, Integer,
193
+ # Float, true, false, nil, Array of the same, Hash with
194
+ # String keys mapping to the same). Backends call `.inspect`
195
+ # to emit the literal text.
196
+ ModuleConstant = Data.define(:name, :constant_name, :value) do
197
+ include Node
198
+ end
199
+
200
+ # A className attribute value that resolves to a known cva binding's
201
+ # call shape — `className={cn(buttonVariants({ variant, size }),
202
+ # className)}` or the no-cn direct form `className={buttonVariants({
203
+ # variant })}`. The translator recognizes the AST shape at lowering
204
+ # so the backend never has to regex over verbatim JS source; this
205
+ # naturally handles literal-pinned axes, reversed arg order, and
206
+ # the cn-vs-no-cn forms.
207
+ #
208
+ # binding_name : String — referenced cva binding's const name.
209
+ # axes : [CvaAxisPair] — preserved in JSX source order.
210
+ # class_arg : Interpolation | nil — the optional trailing className
211
+ # arg from `cn(<cvaCall>, <classArg>)`. Nil for the
212
+ # single-arg `cn(<cvaCall>)` and the no-cn direct
213
+ # forms.
214
+ CvaCallSite = Data.define(:binding_name, :axes, :class_arg) do
215
+ include Node
216
+ end
217
+
218
+ # One axis-value pair inside a cva call's options object. The
219
+ # discriminator `kind` tells the backend how to render the value:
71
220
  #
72
- # hook : String hook function name (`"useState"`, `"useEffect"`, …)
73
- # source : String verbatim JS of the entire statement.
74
- ReactHookCall = Data.define(:hook, :source) do
221
+ # :prop_refJS identifier referencing a prop (`{ variant }` or
222
+ # `{ variant: someProp }`). Backends render as
223
+ # `@snake_case` against the receiving Phlex component.
224
+ # :literal_string — `{ variant: "default" }` — the literal value
225
+ # is the variant-table key.
226
+ # :literal_other — `{ size: 42 }` / `{ active: true }` — Ruby
227
+ # literal passed through to the bracket key.
228
+ # :literal_nil — `{ variant: null }` or `undefined`.
229
+ CvaAxisPair = Data.define(:axis, :kind, :source) do
230
+ include Node
231
+ end
232
+
233
+ # A hook invocation detected in the component body. Covers React's
234
+ # built-in hooks plus framework hooks we recognize (Apollo's `useQuery`/
235
+ # `useMutation`/etc., Next.js's `useRouter`/`usePathname`/etc.).
236
+ # Surfaced separately from local_bindings so backends can emit a more
237
+ # specific TODO pointing at the Rails equivalent for each library,
238
+ # instead of a generic "translate this JS".
239
+ #
240
+ # hook : String — hook function name (`"useState"`, `"useQuery"`, …)
241
+ # source : String — verbatim JS of the entire statement.
242
+ # library : Symbol — `:react`, `:apollo`, or `:next_js`. Backends
243
+ # group hooks by library and emit one TODO block per group,
244
+ # since each library maps to a different Rails analog.
245
+ # operation : String | nil — for Apollo hooks called with a bare-Identifier
246
+ # first argument (`useQuery(GET_USERS_QUERY, …)`), the
247
+ # captured operation name. nil when the first argument is
248
+ # not a simple Identifier, or when the hook isn't Apollo.
249
+ # Backends echo it in the TODO so the reviewer can match
250
+ # the operation back to its GraphQL document and to the
251
+ # Rails controller / model fetch it should become.
252
+ ReactHookCall = Data.define(:hook, :source, :library, :operation) do
75
253
  include Node
76
254
  end
77
255
 
78
256
  # A component prop, possibly with a default value.
79
257
  #
80
- # name : String
81
- # default : Interpolation | nil
82
- Prop = Data.define(:name, :default) do
258
+ # name : String — the prop name on the parent (e.g. "data-testid").
259
+ # default : Interpolation | nil
260
+ # alias_name : String | nil — the local binding name inside the body when
261
+ # the destructure renames it (`"data-testid": dataTestId`).
262
+ # Use sites of the alias resolve to the prop's ivar.
263
+ Prop = Data.define(:name, :default, :alias_name) do
83
264
  include Node
84
265
  end
85
266
 
@@ -279,7 +460,17 @@ module JsxRosetta
279
460
  # `name != original_name`, backends emit a collision
280
461
  # marker comment in the generated controller JS so the
281
462
  # reviewer can see the silent rename.
282
- StimulusMethod = Data.define(:name, :body_source, :original_name) do
463
+ # params : [String | nil] — original arrow/function parameter
464
+ # names (e.g. `["e"]`, `["event"]`, or `[]` for
465
+ # `() => …`). A `nil` entry signals a non-identifier
466
+ # param (destructured `({target}) =>`, rest `(...args) =>`)
467
+ # that the pasted body can't safely reference — backends
468
+ # bail to the TODO form when any entry is nil.
469
+ # body_is_block : Boolean — true when the arrow body was a BlockStatement
470
+ # (`(e) => { … }`), false for an expression-form body
471
+ # (`(e) => doX(e)`). Backends use this to decide whether
472
+ # to strip outer braces when pasting verbatim.
473
+ StimulusMethod = Data.define(:name, :body_source, :original_name, :params, :body_is_block) do
283
474
  include Node
284
475
  end
285
476
 
@@ -333,6 +524,25 @@ module JsxRosetta
333
524
  include Node
334
525
  end
335
526
 
527
+ # An arrow/function expression whose body isn't JSX — typically a
528
+ # JSX event handler on a PascalCase component (`onClick={() => doX()}`)
529
+ # or a non-JSX-returning callback prop. We can't translate arbitrary
530
+ # JS bodies to Ruby procedurally, so backends emit a stub method on
531
+ # the class with the verbatim JS body preserved as a TODO comment;
532
+ # the kwarg at the use site becomes `method(:method_name)` so the
533
+ # receiving component has a callable reference and the structural
534
+ # attachment is preserved end-to-end.
535
+ #
536
+ # params : [String]
537
+ # body_source : String — verbatim JS of the arrow/function body
538
+ # (the part after `=>` for arrows, or the full block
539
+ # body for FunctionExpressions). Preserved as a
540
+ # comment in the emitted method so the reviewer can
541
+ # translate the behavior to Ruby.
542
+ EventHandler = Data.define(:params, :body_source) do
543
+ include Node
544
+ end
545
+
336
546
  # A render-prop child: `<Form.List>{(fields) => <div>{fields}</div>}</Form.List>`.
337
547
  # Backends emit this as a Ruby block on the render call, with the params
338
548
  # bound as block arguments. Distinct from Loop (which iterates an
@@ -343,5 +553,40 @@ module JsxRosetta
343
553
  RenderProp = Data.define(:params, :body) do
344
554
  include Node
345
555
  end
556
+
557
+ # The Next.js `_app.tsx` content slot — what JSX writes as
558
+ # `<Component {...pageProps} />`. Lowered to a distinguished node so
559
+ # the Phlex backend can emit `yield` (the Rails layout convention) in
560
+ # its place when the host file is a layout. Outside the layout
561
+ # rendering path the node still emits a yield, since the only way to
562
+ # produce one in our IR is to hit this exact source shape.
563
+ LayoutYield = Data.define do
564
+ include Node
565
+ end
566
+
567
+ # A locally-declared JSX-returning arrow that's invoked inside the
568
+ # render body: `const renderHeader = (count) => <h1>{count}</h1>;
569
+ # ... {renderHeader(headerCount)}`. Backends emit one private method
570
+ # per RenderMethod on the generated class and reference it via a
571
+ # LocalRenderCall at each use site.
572
+ #
573
+ # name : String — snake_case method name on the class.
574
+ # params : [String] — arrow param names (camelCase preserved; backends
575
+ # snake_case to form Ruby parameter names).
576
+ # body : Node — the lowered IR node produced by the arrow's body.
577
+ RenderMethod = Data.define(:name, :params, :body) do
578
+ include Node
579
+ end
580
+
581
+ # A call to a locally-declared JSX-returning arrow at its use site.
582
+ # Pairs with a sibling RenderMethod on Component#render_methods.
583
+ #
584
+ # method_name : String — snake_case method name (matches RenderMethod#name).
585
+ # args : [Interpolation] — argument expressions captured verbatim
586
+ # (each Interpolation's expression is translated by the
587
+ # backend's ExpressionTranslator at emission time).
588
+ LocalRenderCall = Data.define(:method_name, :args) do
589
+ include Node
590
+ end
346
591
  end
347
592
  end
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ir/types"
4
+ require_relative "ir/radix_registry"
4
5
  require_relative "ir/lowering"
5
6
 
6
7
  module JsxRosetta
7
8
  module IR
8
- def self.lower(ast_file, source:)
9
- Lowering.lower(ast_file, source: source)
9
+ def self.lower(ast_file, source:, keep_slot: false)
10
+ Lowering.lower(ast_file, source: source, keep_slot: keep_slot)
10
11
  end
11
12
 
12
- def self.lower_all(ast_file, source:)
13
- Lowering.lower_all(ast_file, source: source)
13
+ def self.lower_all(ast_file, source:, keep_slot: false)
14
+ Lowering.lower_all(ast_file, source: source, keep_slot: keep_slot)
14
15
  end
15
16
  end
16
17
  end