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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +342 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/ROADMAP.md +92 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +32 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
- data/lib/jsx_rosetta/backend/view_component.rb +261 -31
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +264 -19
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +30 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "types"
|
|
4
4
|
require_relative "module_shape_classifier"
|
|
5
|
+
require_relative "../ast/inflector"
|
|
5
6
|
|
|
6
7
|
module JsxRosetta
|
|
7
8
|
module IR
|
|
@@ -54,12 +55,12 @@ module JsxRosetta
|
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
|
|
57
|
-
def self.lower(file, source:)
|
|
58
|
-
new(source).lower_file(file)
|
|
58
|
+
def self.lower(file, source:, keep_slot: false)
|
|
59
|
+
new(source, keep_slot: keep_slot).lower_file(file)
|
|
59
60
|
end
|
|
60
61
|
|
|
61
|
-
def self.lower_all(file, source:)
|
|
62
|
-
new(source).lower_all_components(file)
|
|
62
|
+
def self.lower_all(file, source:, keep_slot: false)
|
|
63
|
+
new(source, keep_slot: keep_slot).lower_all_components(file)
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
REACT_HOOKS = %w[
|
|
@@ -67,8 +68,53 @@ module JsxRosetta
|
|
|
67
68
|
useReducer useImperativeHandle useLayoutEffect useDebugValue
|
|
68
69
|
].freeze
|
|
69
70
|
|
|
71
|
+
# Apollo Client hooks. `useQuery` / `useLazyQuery` / `useSubscription`
|
|
72
|
+
# take a GraphQL document as the first argument; `useMutation` returns
|
|
73
|
+
# a `[mutate, { loading, ... }]` tuple. None of these have a direct
|
|
74
|
+
# translation — they encode data fetching, which in Rails lives in
|
|
75
|
+
# the controller/model. Captured here so the backend can emit a
|
|
76
|
+
# per-hook TODO with the operation name preserved when extractable.
|
|
77
|
+
APOLLO_HOOKS = %w[
|
|
78
|
+
useQuery useLazyQuery useMutation useSubscription useApolloClient
|
|
79
|
+
].freeze
|
|
80
|
+
|
|
81
|
+
# Next.js navigation hooks (App Router and Pages Router). Each has a
|
|
82
|
+
# Rails-side analog:
|
|
83
|
+
# useRouter → controller actions / redirect_to
|
|
84
|
+
# usePathname → request.path
|
|
85
|
+
# useSearchParams → params
|
|
86
|
+
# useParams → params (route params)
|
|
87
|
+
# useSelectedLayoutSegment(s) → not directly translatable; usually
|
|
88
|
+
# used to highlight nav links — the Rails view can pattern-match
|
|
89
|
+
# against request.path.
|
|
90
|
+
NEXT_HOOKS = %w[
|
|
91
|
+
useRouter usePathname useSearchParams useParams
|
|
92
|
+
useSelectedLayoutSegment useSelectedLayoutSegments
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
FRAMEWORK_HOOKS_BY_LIBRARY = {
|
|
96
|
+
react: REACT_HOOKS,
|
|
97
|
+
apollo: APOLLO_HOOKS,
|
|
98
|
+
next_js: NEXT_HOOKS
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
70
101
|
JSX_NODE_TYPES = %w[JSXElement JSXFragment JSXText JSXExpressionContainer].freeze
|
|
71
102
|
|
|
103
|
+
# Mirrors React's `isUnitlessNumber` table — CSS properties that take
|
|
104
|
+
# a bare number rather than a length. Numeric style values for any
|
|
105
|
+
# property NOT in this set get a `px` suffix appended at lowering time.
|
|
106
|
+
UNITLESS_CSS_PROPERTIES = %w[
|
|
107
|
+
animation-iteration-count aspect-ratio border-image-outset
|
|
108
|
+
border-image-slice border-image-width box-flex box-flex-group
|
|
109
|
+
box-ordinal-group column-count columns flex flex-grow flex-negative
|
|
110
|
+
flex-order flex-positive flex-shrink font-weight grid-area
|
|
111
|
+
grid-column grid-column-end grid-column-span grid-column-start
|
|
112
|
+
grid-row grid-row-end grid-row-span grid-row-start line-clamp
|
|
113
|
+
line-height opacity order orphans scale tab-size widows z-index
|
|
114
|
+
zoom fill-opacity flood-opacity stop-opacity stroke-dasharray
|
|
115
|
+
stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width
|
|
116
|
+
].to_set.freeze
|
|
117
|
+
|
|
72
118
|
# Pre-lowering AST scan: maps a node type to a callable returning the
|
|
73
119
|
# AST nodes that contribute return values. Used by body_returns_jsx?.
|
|
74
120
|
JSX_RETURN_PROBES = {
|
|
@@ -80,6 +126,14 @@ module JsxRosetta
|
|
|
80
126
|
"LogicalExpression" => ->(n) { [n[:left], n[:right]] }
|
|
81
127
|
}.freeze
|
|
82
128
|
|
|
129
|
+
# Known wrapper-call names that lowering peers through to find the
|
|
130
|
+
# inner component definition. Both bare (`memo(...)`) and React-
|
|
131
|
+
# namespaced (`React.memo(...)`) forms count. Wrappers we don't
|
|
132
|
+
# unwrap (e.g. `React.lazy` — different shape, no inline function
|
|
133
|
+
# body) stay off this list on purpose; declarations using them
|
|
134
|
+
# won't be recognized as components, same as pre-unwrap behavior.
|
|
135
|
+
HOC_WRAPPER_NAMES = %w[memo forwardRef observer connect withRouter withTranslation].to_set.freeze
|
|
136
|
+
|
|
83
137
|
SHAPE_MESSAGES = {
|
|
84
138
|
hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
|
|
85
139
|
"this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
|
|
@@ -100,8 +154,14 @@ module JsxRosetta
|
|
|
100
154
|
unknown: nil
|
|
101
155
|
}.freeze
|
|
102
156
|
|
|
103
|
-
def initialize(source)
|
|
157
|
+
def initialize(source, keep_slot: false)
|
|
104
158
|
@source = source
|
|
159
|
+
# When false (default), the shadcn `<Comp asChild>` pattern that
|
|
160
|
+
# routes through Radix's Slot.Root gets its Slot branch dropped at
|
|
161
|
+
# lowering time, leaving only the non-Slot HTML/component branch.
|
|
162
|
+
# When true, preserve the full polymorphic conditional (legacy
|
|
163
|
+
# behavior; useful if the consumer shims Components::Slot::Root).
|
|
164
|
+
@keep_slot = keep_slot
|
|
105
165
|
@prop_names = []
|
|
106
166
|
@local_jsx = {}
|
|
107
167
|
@local_bindings = []
|
|
@@ -112,6 +172,18 @@ module JsxRosetta
|
|
|
112
172
|
@stimulus_methods = []
|
|
113
173
|
@stimulus_seen_names = {}
|
|
114
174
|
@react_hooks = []
|
|
175
|
+
@render_methods = []
|
|
176
|
+
@render_method_seen = {}
|
|
177
|
+
# File-level imports; populated once at lower_file / lower_all_components
|
|
178
|
+
# entry and consulted by JSX lowering to decide whether a member-chain
|
|
179
|
+
# tag like `SeparatorPrimitive.Root` should resolve through the Radix
|
|
180
|
+
# registry into an HTML Element.
|
|
181
|
+
@module_imports = []
|
|
182
|
+
# Class-component non-render members (constructor, lifecycle hooks,
|
|
183
|
+
# custom handlers). Keyed by class name; populated by
|
|
184
|
+
# extract_class_component, drained by lower_component to surface
|
|
185
|
+
# the verbatim sources as a TODO comment block.
|
|
186
|
+
@pending_class_other_members = {}
|
|
115
187
|
end
|
|
116
188
|
|
|
117
189
|
def lower_file(file)
|
|
@@ -119,17 +191,63 @@ module JsxRosetta
|
|
|
119
191
|
raise no_component_error(file.program) if candidates.empty?
|
|
120
192
|
|
|
121
193
|
name, function = candidates.first
|
|
122
|
-
module_bindings = capture_module_bindings(file.program, candidates)
|
|
123
|
-
|
|
194
|
+
@module_bindings = capture_module_bindings(file.program, candidates)
|
|
195
|
+
@module_imports = capture_module_imports(file.program)
|
|
196
|
+
@server_data_source = capture_server_data_source(file.program)
|
|
197
|
+
attach_module_metadata(lower_component(name, function),
|
|
198
|
+
@module_bindings, @module_imports, @server_data_source)
|
|
124
199
|
end
|
|
125
200
|
|
|
126
201
|
def lower_all_components(file)
|
|
127
202
|
candidates = find_component_functions(file.program)
|
|
128
203
|
raise no_component_error(file.program) if candidates.empty?
|
|
129
204
|
|
|
130
|
-
module_bindings = capture_module_bindings(file.program, candidates)
|
|
131
|
-
|
|
132
|
-
|
|
205
|
+
@module_bindings = capture_module_bindings(file.program, candidates)
|
|
206
|
+
@module_imports = capture_module_imports(file.program)
|
|
207
|
+
@server_data_source = capture_server_data_source(file.program)
|
|
208
|
+
candidates.each_with_index.map do |(name, function), idx|
|
|
209
|
+
# Only the first sibling carries the server_data_source — a page
|
|
210
|
+
# file has at most one such export, and attaching it to every
|
|
211
|
+
# sibling would duplicate the TODO block across N files.
|
|
212
|
+
sds = idx.zero? ? @server_data_source : nil
|
|
213
|
+
attach_module_metadata(lower_component(name, function),
|
|
214
|
+
@module_bindings, @module_imports, sds)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
SERVER_DATA_HOOK_NAMES = %w[getServerSideProps getStaticProps].freeze
|
|
219
|
+
|
|
220
|
+
# Capture a top-level `export function getServerSideProps()` or
|
|
221
|
+
# `export const getServerSideProps = ...` (or getStaticProps) so the
|
|
222
|
+
# Phlex backend can surface the body as a TODO comment block. Returns
|
|
223
|
+
# nil when no such export is present (the common case for ordinary
|
|
224
|
+
# components — only Next.js pages have these).
|
|
225
|
+
def capture_server_data_source(program)
|
|
226
|
+
program.body.each do |stmt|
|
|
227
|
+
next unless stmt.of_type?("ExportNamedDeclaration")
|
|
228
|
+
|
|
229
|
+
decl = stmt[:declaration]
|
|
230
|
+
next unless decl.is_a?(AST::Node)
|
|
231
|
+
|
|
232
|
+
name = server_data_hook_name(decl)
|
|
233
|
+
return ServerDataSource.new(hook_name: name, source: source_of(stmt)) if name
|
|
234
|
+
end
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def server_data_hook_name(decl)
|
|
239
|
+
case decl.type
|
|
240
|
+
when "FunctionDeclaration"
|
|
241
|
+
id = decl[:id]
|
|
242
|
+
id&.[](:name) if id.is_a?(AST::Node) && SERVER_DATA_HOOK_NAMES.include?(id[:name])
|
|
243
|
+
when "VariableDeclaration"
|
|
244
|
+
first = decl[:declarations].first
|
|
245
|
+
return nil unless first.is_a?(AST::Node)
|
|
246
|
+
|
|
247
|
+
id = first[:id]
|
|
248
|
+
return nil unless id.is_a?(AST::Node) && id.of_type?("Identifier")
|
|
249
|
+
|
|
250
|
+
id[:name] if SERVER_DATA_HOOK_NAMES.include?(id[:name])
|
|
133
251
|
end
|
|
134
252
|
end
|
|
135
253
|
|
|
@@ -153,32 +271,300 @@ module JsxRosetta
|
|
|
153
271
|
case stmt.type
|
|
154
272
|
when "VariableDeclaration"
|
|
155
273
|
stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
|
|
156
|
-
when "
|
|
274
|
+
when "FunctionDeclaration"
|
|
275
|
+
record_module_function_binding(stmt, component_names, bindings)
|
|
276
|
+
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
157
277
|
decl = stmt[:declaration]
|
|
158
278
|
walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
|
|
159
279
|
end
|
|
160
280
|
end
|
|
161
281
|
|
|
282
|
+
# Top-level `function onError(){}` helpers — non-component, non-hook
|
|
283
|
+
# functions declared in the same file as the component. Without
|
|
284
|
+
# capture, a `<Button onClick={onError}>` use site translates to
|
|
285
|
+
# `on_click: on_error` which NameErrors at render time because
|
|
286
|
+
# nothing binds `on_error`. Recording the name here threads it into
|
|
287
|
+
# the translator's bailout set so the reference becomes `on_click: nil`
|
|
288
|
+
# plus a visible TODO.
|
|
289
|
+
def record_module_function_binding(stmt, component_names, bindings)
|
|
290
|
+
name = stmt[:id]&.[](:name)
|
|
291
|
+
return unless name
|
|
292
|
+
return if component_names.include?(name)
|
|
293
|
+
|
|
294
|
+
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
295
|
+
end
|
|
296
|
+
|
|
162
297
|
def record_module_binding(stmt, declarator, component_names, bindings)
|
|
163
298
|
init = declarator[:init]
|
|
164
299
|
return unless init.is_a?(AST::Node)
|
|
165
300
|
|
|
166
|
-
# Component declarators (`const Foo = () => ...`) are handled by
|
|
167
|
-
# the component pipeline; skip them here so the source doesn't
|
|
168
|
-
# show up twice.
|
|
169
|
-
return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
|
|
170
|
-
component_names.include?(declarator[:id]&.[](:name))
|
|
171
|
-
|
|
172
301
|
name = declarator[:id]&.[](:name)
|
|
173
302
|
return unless name
|
|
174
303
|
|
|
304
|
+
# Component declarators (`const Foo = () => ...` and the
|
|
305
|
+
# HOC-wrapped `const Foo = memo(() => ...)` form) are handled by
|
|
306
|
+
# the component pipeline; skip them here so the source doesn't
|
|
307
|
+
# surface twice (once as a TODO, once as the class).
|
|
308
|
+
return if component_names.include?(name)
|
|
309
|
+
|
|
310
|
+
# shadcn-style `const fooVariants = cva(base, { variants, ... })` gets
|
|
311
|
+
# recognized at lowering and stored as a CvaBinding — the backend
|
|
312
|
+
# turns it into real Ruby constants and the use-site call collapses
|
|
313
|
+
# to a string interpolation. Falls through to the generic LocalBinding
|
|
314
|
+
# path when the cva shape doesn't match exactly.
|
|
315
|
+
if (cva = parse_cva_binding(init, name))
|
|
316
|
+
bindings << cva
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Literal-shaped `const FOO = "x"` / `const COLUMNS = [...]` /
|
|
321
|
+
# `const TAGS = {...}` lowers to a real Ruby constant emitted above
|
|
322
|
+
# the class. Anything richer (call expressions, identifier refs,
|
|
323
|
+
# JSX) bails to the LocalBinding TODO-block fallback below.
|
|
324
|
+
if (constant = parse_module_constant(init, name))
|
|
325
|
+
bindings << constant
|
|
326
|
+
return
|
|
327
|
+
end
|
|
328
|
+
|
|
175
329
|
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
176
330
|
end
|
|
177
331
|
|
|
178
|
-
|
|
179
|
-
|
|
332
|
+
# Returns an IR::ModuleConstant when `init` reduces to a Ruby-literal-
|
|
333
|
+
# friendly value, or nil otherwise. Anything that depends on runtime
|
|
334
|
+
# state (call expressions, identifier references, function expressions,
|
|
335
|
+
# JSX) bails out so the existing LocalBinding TODO path still surfaces
|
|
336
|
+
# the original JS source.
|
|
337
|
+
def parse_module_constant(init, name)
|
|
338
|
+
value = literal_value(init)
|
|
339
|
+
return nil if value == :__not_literal__
|
|
340
|
+
|
|
341
|
+
ModuleConstant.new(
|
|
342
|
+
name: name,
|
|
343
|
+
constant_name: AST::Inflector.underscore(name).upcase,
|
|
344
|
+
value: value
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Returns the Ruby-literal-friendly value for `node`, or the sentinel
|
|
349
|
+
# `:__not_literal__` when the node isn't translatable. Sentinel rather
|
|
350
|
+
# than `nil` so a JS `null` (which legitimately maps to Ruby `nil`)
|
|
351
|
+
# is distinguishable from "couldn't parse this."
|
|
352
|
+
def literal_value(node)
|
|
353
|
+
return :__not_literal__ unless node.is_a?(AST::Node)
|
|
354
|
+
|
|
355
|
+
case node.type
|
|
356
|
+
when "StringLiteral", "NumericLiteral", "BooleanLiteral" then node[:value]
|
|
357
|
+
when "NullLiteral" then nil
|
|
358
|
+
when "TemplateLiteral" then literal_value_from_template(node)
|
|
359
|
+
when "ArrayExpression" then literal_value_from_array(node)
|
|
360
|
+
when "ObjectExpression" then literal_value_from_object(node)
|
|
361
|
+
when "UnaryExpression" then literal_value_from_unary(node)
|
|
362
|
+
when "TSAsExpression", "TSSatisfiesExpression", "TSTypeAssertion"
|
|
363
|
+
literal_value(node[:expression])
|
|
364
|
+
else :__not_literal__
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def literal_value_from_template(node)
|
|
369
|
+
return :__not_literal__ unless (node[:expressions] || []).empty?
|
|
370
|
+
|
|
371
|
+
# `quasi[:value]` is a plain Hash with String keys ("cooked" / "raw"),
|
|
372
|
+
# not an AST::Node — Babel's AST wraps Hashes only when they carry a
|
|
373
|
+
# "type" field. Use the String key directly.
|
|
374
|
+
(node[:quasis] || []).map { |q| q[:value]["cooked"] }.join
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def literal_value_from_array(node)
|
|
378
|
+
elements = node[:elements] || []
|
|
379
|
+
result = []
|
|
380
|
+
elements.each do |elem|
|
|
381
|
+
# Holes in array literals (`[1, , 3]`) come through as nil; map to
|
|
382
|
+
# Ruby `nil` to preserve length. Spread elements bail — we can't
|
|
383
|
+
# statically expand the spread target.
|
|
384
|
+
if elem.nil?
|
|
385
|
+
result << nil
|
|
386
|
+
next
|
|
387
|
+
end
|
|
388
|
+
return :__not_literal__ if elem.is_a?(AST::Node) && elem.type == "SpreadElement"
|
|
389
|
+
|
|
390
|
+
value = literal_value(elem)
|
|
391
|
+
return :__not_literal__ if value == :__not_literal__
|
|
392
|
+
|
|
393
|
+
result << value
|
|
394
|
+
end
|
|
395
|
+
result
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def literal_value_from_object(node)
|
|
399
|
+
properties = node[:properties] || []
|
|
400
|
+
result = {}
|
|
401
|
+
properties.each do |prop|
|
|
402
|
+
return :__not_literal__ unless prop.is_a?(AST::Node) && prop.type == "ObjectProperty"
|
|
403
|
+
return :__not_literal__ if prop[:computed]
|
|
404
|
+
return :__not_literal__ if prop[:shorthand] && prop[:value].is_a?(AST::Node) &&
|
|
405
|
+
prop[:value].type == "Identifier"
|
|
406
|
+
|
|
407
|
+
key = property_key(prop)
|
|
408
|
+
return :__not_literal__ unless key.is_a?(String)
|
|
409
|
+
|
|
410
|
+
value = literal_value(prop[:value])
|
|
411
|
+
return :__not_literal__ if value == :__not_literal__
|
|
412
|
+
|
|
413
|
+
result[key] = value
|
|
414
|
+
end
|
|
415
|
+
result
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def literal_value_from_unary(node)
|
|
419
|
+
return :__not_literal__ unless %w[- +].include?(node[:operator])
|
|
420
|
+
|
|
421
|
+
inner = literal_value(node[:argument])
|
|
422
|
+
return :__not_literal__ unless inner.is_a?(Numeric)
|
|
423
|
+
|
|
424
|
+
node[:operator] == "-" ? -inner : inner
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Returns a CvaBinding when `init` is a `cva(base, options)` call we
|
|
428
|
+
# know how to parse, or nil to fall through to LocalBinding.
|
|
429
|
+
def parse_cva_binding(init, name)
|
|
430
|
+
return nil unless cva_call?(init)
|
|
431
|
+
|
|
432
|
+
args = init[:arguments] || []
|
|
433
|
+
base_class = extract_cva_string(args[0])
|
|
434
|
+
return nil unless base_class
|
|
435
|
+
|
|
436
|
+
options = args[1]
|
|
437
|
+
return nil unless options.is_a?(AST::Node) && options.type == "ObjectExpression"
|
|
438
|
+
|
|
439
|
+
CvaBinding.new(
|
|
440
|
+
name: name,
|
|
441
|
+
base_class: base_class,
|
|
442
|
+
variants: extract_cva_variants(options),
|
|
443
|
+
default_variants: extract_cva_default_variants(options),
|
|
444
|
+
compound_source: extract_cva_compound_source(options)
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def cva_call?(node)
|
|
449
|
+
return false unless node.is_a?(AST::Node) && node.type == "CallExpression"
|
|
450
|
+
|
|
451
|
+
callee = node[:callee]
|
|
452
|
+
callee.is_a?(AST::Node) && callee.type == "Identifier" && callee[:name] == "cva"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def extract_cva_string(node)
|
|
456
|
+
return nil unless node.is_a?(AST::Node)
|
|
457
|
+
|
|
458
|
+
case node.type
|
|
459
|
+
when "StringLiteral"
|
|
460
|
+
node[:value]
|
|
461
|
+
when "TemplateLiteral"
|
|
462
|
+
# Only handle templates with no interpolations — they're effectively
|
|
463
|
+
# a string literal (shadcn's cva bases sometimes use a template for
|
|
464
|
+
# multi-line readability).
|
|
465
|
+
return nil unless (node[:expressions] || []).empty?
|
|
466
|
+
|
|
467
|
+
(node[:quasis] || []).map { |q| q[:value][:cooked] }.join
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def extract_cva_variants(options_node)
|
|
472
|
+
prop = find_object_property(options_node, "variants")
|
|
473
|
+
return {} unless object_expression?(prop&.[](:value))
|
|
474
|
+
|
|
475
|
+
prop[:value][:properties].each_with_object({}) do |axis, hash|
|
|
476
|
+
axis_name = property_key(axis)
|
|
477
|
+
options = extract_cva_axis_options(axis[:value])
|
|
478
|
+
hash[axis_name] = options if axis_name && !options.empty?
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def extract_cva_axis_options(axis_value_node)
|
|
483
|
+
return {} unless object_expression?(axis_value_node)
|
|
484
|
+
|
|
485
|
+
axis_value_node[:properties].each_with_object({}) do |opt, hash|
|
|
486
|
+
opt_name = property_key(opt)
|
|
487
|
+
opt_value = extract_cva_string(opt[:value])
|
|
488
|
+
hash[opt_name] = opt_value if opt_name && opt_value
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def object_expression?(node)
|
|
493
|
+
node.is_a?(AST::Node) && node.type == "ObjectExpression"
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def extract_cva_default_variants(options_node)
|
|
497
|
+
prop = find_object_property(options_node, "defaultVariants")
|
|
498
|
+
return {} unless prop && prop[:value].is_a?(AST::Node) && prop[:value].type == "ObjectExpression"
|
|
499
|
+
|
|
500
|
+
prop[:value][:properties].each_with_object({}) do |p, hash|
|
|
501
|
+
key = property_key(p)
|
|
502
|
+
val = extract_cva_string(p[:value])
|
|
503
|
+
hash[key] = val if key && val
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def extract_cva_compound_source(options_node)
|
|
508
|
+
prop = find_object_property(options_node, "compoundVariants")
|
|
509
|
+
return nil unless prop
|
|
180
510
|
|
|
181
|
-
|
|
511
|
+
source_of(prop[:value]).strip
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def find_object_property(obj_node, name)
|
|
515
|
+
(obj_node[:properties] || []).find { |p| property_key(p) == name }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def property_key(prop)
|
|
519
|
+
return nil unless prop.is_a?(AST::Node) && prop[:key].is_a?(AST::Node)
|
|
520
|
+
|
|
521
|
+
key = prop[:key]
|
|
522
|
+
case key.type
|
|
523
|
+
when "Identifier" then key[:name]
|
|
524
|
+
when "StringLiteral" then key[:value]
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def attach_module_metadata(component, module_bindings, module_imports, server_data_source = nil)
|
|
529
|
+
component.with(
|
|
530
|
+
module_bindings: module_bindings,
|
|
531
|
+
module_imports: module_imports,
|
|
532
|
+
server_data_source: server_data_source
|
|
533
|
+
)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Capture every top-level `import` declaration so the translator can
|
|
537
|
+
# recognize use-site references at expression-context. Without this,
|
|
538
|
+
# an import like `import styles from "./X.module.css"` lets every
|
|
539
|
+
# `styles.listContainer` use snake-case to a bare `styles` reference
|
|
540
|
+
# that NameErrors at render time.
|
|
541
|
+
def capture_module_imports(program)
|
|
542
|
+
imports = []
|
|
543
|
+
program.body.each do |stmt|
|
|
544
|
+
next unless stmt.is_a?(AST::Node) && stmt.type == "ImportDeclaration"
|
|
545
|
+
|
|
546
|
+
source = stmt[:source]&.[](:value).to_s
|
|
547
|
+
(stmt[:specifiers] || []).each do |spec|
|
|
548
|
+
name = spec[:local]&.[](:name)
|
|
549
|
+
next unless name
|
|
550
|
+
|
|
551
|
+
imports << ModuleImport.new(
|
|
552
|
+
name: name,
|
|
553
|
+
source: source,
|
|
554
|
+
kind: import_specifier_kind(spec),
|
|
555
|
+
imported_name: spec[:imported]&.[](:name)
|
|
556
|
+
)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
imports
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def import_specifier_kind(spec)
|
|
563
|
+
case spec.type
|
|
564
|
+
when "ImportDefaultSpecifier" then :default
|
|
565
|
+
when "ImportNamespaceSpecifier" then :namespace
|
|
566
|
+
else :named
|
|
567
|
+
end
|
|
182
568
|
end
|
|
183
569
|
|
|
184
570
|
private
|
|
@@ -210,6 +596,7 @@ module JsxRosetta
|
|
|
210
596
|
return false if name.nil? || name.empty?
|
|
211
597
|
return true if pascal_case?(name)
|
|
212
598
|
return false if hook_name?(name)
|
|
599
|
+
return true if extract_data_factory_array(function)
|
|
213
600
|
|
|
214
601
|
body_returns_jsx?(function[:body])
|
|
215
602
|
end
|
|
@@ -247,6 +634,8 @@ module JsxRosetta
|
|
|
247
634
|
[[stmt[:id]&.[](:name), stmt]]
|
|
248
635
|
when "VariableDeclaration"
|
|
249
636
|
extract_arrow_components(stmt)
|
|
637
|
+
when "ClassDeclaration"
|
|
638
|
+
extract_class_component(stmt)
|
|
250
639
|
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
251
640
|
extract_exported_components(stmt[:declaration])
|
|
252
641
|
else
|
|
@@ -260,66 +649,366 @@ module JsxRosetta
|
|
|
260
649
|
case declaration.type
|
|
261
650
|
when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
|
|
262
651
|
when "VariableDeclaration" then extract_arrow_components(declaration)
|
|
652
|
+
when "ClassDeclaration" then extract_class_component(declaration)
|
|
653
|
+
when "CallExpression" then extract_hoc_default_export(declaration)
|
|
263
654
|
else []
|
|
264
655
|
end
|
|
265
656
|
end
|
|
266
657
|
|
|
658
|
+
# `export default memo(function X() {...})` — the declaration is a
|
|
659
|
+
# CallExpression whose argument is a named FunctionExpression. Peer
|
|
660
|
+
# through to the inner function and record the wrapper. Anonymous
|
|
661
|
+
# forms (`export default memo(function () {...})`) get skipped —
|
|
662
|
+
# `lower_component` rejects anonymous functions and the pre-unwrap
|
|
663
|
+
# behavior was the same.
|
|
664
|
+
def extract_hoc_default_export(call_expression)
|
|
665
|
+
unwrapped = unwrap_hoc(call_expression)
|
|
666
|
+
return [] unless unwrapped
|
|
667
|
+
|
|
668
|
+
inner = unwrapped[:function]
|
|
669
|
+
name = inner[:id]&.[](:name) if inner.respond_to?(:[])
|
|
670
|
+
return [] unless name
|
|
671
|
+
|
|
672
|
+
record_hoc_wrappers(name, unwrapped[:wrappers])
|
|
673
|
+
[[name, inner]]
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Recognize a class component by the presence of a `render()` method.
|
|
677
|
+
# We don't require `extends React.Component` because TypeScript codebases
|
|
678
|
+
# often declare the parent via an `extends` of a typed alias. The render
|
|
679
|
+
# method's signature (no args, returns JSX) is the JSX-component signal.
|
|
680
|
+
#
|
|
681
|
+
# The render ClassMethod's `[:params]` is always `[]` and `[:body]` is a
|
|
682
|
+
# BlockStatement — same shape as a function declaration's body, so the
|
|
683
|
+
# rest of the lowering pipeline works unchanged. Other class members
|
|
684
|
+
# (constructor, lifecycle hooks, custom handlers) get stashed on
|
|
685
|
+
# `@pending_class_other_members` keyed by class name, then surfaced as
|
|
686
|
+
# a LocalBinding-style TODO block by `lower_component`.
|
|
687
|
+
def extract_class_component(class_decl)
|
|
688
|
+
name = class_decl[:id]&.[](:name)
|
|
689
|
+
return [] unless name
|
|
690
|
+
|
|
691
|
+
render_method, other_members = partition_class_members(class_decl)
|
|
692
|
+
return [] unless render_method
|
|
693
|
+
|
|
694
|
+
@pending_class_other_members[name] = other_members
|
|
695
|
+
[[name, render_method]]
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def partition_class_members(class_decl)
|
|
699
|
+
body = class_decl.child(:body)
|
|
700
|
+
return [nil, []] unless body
|
|
701
|
+
|
|
702
|
+
render_method = nil
|
|
703
|
+
others = []
|
|
704
|
+
body[:body].each do |member|
|
|
705
|
+
if class_render_method?(member)
|
|
706
|
+
render_method = member
|
|
707
|
+
else
|
|
708
|
+
others << member
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
[render_method, others]
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def class_render_method?(member)
|
|
715
|
+
return false unless AST::Node.matches?(member, "ClassMethod", "MethodDefinition")
|
|
716
|
+
|
|
717
|
+
key = member.child(:key)
|
|
718
|
+
AST::Node.matches?(key, "Identifier") && key[:name] == "render" && member[:kind] != "constructor"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Surface every non-render class member (constructor, lifecycle
|
|
722
|
+
# methods like componentDidMount / componentDidCatch / getDerivedStateFromError,
|
|
723
|
+
# custom event handlers) as a LocalBinding-shaped TODO with the
|
|
724
|
+
# verbatim JS source preserved. The user either translates each to a
|
|
725
|
+
# Ruby method by hand or moves the behavior to Stimulus / controllers.
|
|
726
|
+
def absorb_class_other_members(name)
|
|
727
|
+
members = @pending_class_other_members.delete(name) || []
|
|
728
|
+
members.each do |member|
|
|
729
|
+
source = source_of(member).strip
|
|
730
|
+
member_name = class_member_label(member)
|
|
731
|
+
@local_bindings << LocalBinding.new(name: member_name, source: source)
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def class_member_label(member)
|
|
736
|
+
key = member.child(:key)
|
|
737
|
+
return "<class member>" unless key
|
|
738
|
+
|
|
739
|
+
case key.type
|
|
740
|
+
when "Identifier" then key[:name]
|
|
741
|
+
when "StringLiteral" then key[:value]
|
|
742
|
+
else "<class member>"
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# Pre-scan the render method body for `this.props.X` member access
|
|
747
|
+
# patterns. Each unique X becomes a synthesized IR::Prop entry on the
|
|
748
|
+
# component, so the generated class emits a matching `initialize(x:)`
|
|
749
|
+
# and the translator (which sees `@x`) resolves cleanly. Without this
|
|
750
|
+
# scan, render references would land in `unresolved_identifiers` and
|
|
751
|
+
# the generated initializer would be empty.
|
|
752
|
+
def absorb_class_render_props(render_method)
|
|
753
|
+
body = render_method.child(:body)
|
|
754
|
+
return [] unless body
|
|
755
|
+
|
|
756
|
+
prop_names = []
|
|
757
|
+
scan_this_props(body, prop_names)
|
|
758
|
+
prop_names.uniq.map { |name| Prop.new(name: name, default: nil, alias_name: nil) }
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def scan_this_props(node, accumulator)
|
|
762
|
+
return unless node.is_a?(AST::Node)
|
|
763
|
+
|
|
764
|
+
if node.of_type?("MemberExpression") && this_props_access?(node)
|
|
765
|
+
accumulator << node[:property][:name]
|
|
766
|
+
elsif node.of_type?("VariableDeclarator") && this_props_destructure?(node)
|
|
767
|
+
destructured_names_of(node[:id]).each { |name| accumulator << name }
|
|
768
|
+
end
|
|
769
|
+
node.each_child { |child| scan_this_props(child, accumulator) }
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Match `this.props.X` exactly — `this.props` member access where the
|
|
773
|
+
# property side is also a MemberExpression. We don't follow deeper
|
|
774
|
+
# chains here; only the immediate `.X` after `.props` becomes a prop
|
|
775
|
+
# name. `this.props.foo.bar` still yields prop name `foo`.
|
|
776
|
+
def this_props_access?(member_expr)
|
|
777
|
+
object = member_expr.child(:object)
|
|
778
|
+
return false unless AST::Node.matches?(object, "MemberExpression")
|
|
779
|
+
return false unless AST::Node.matches?(object.child(:object), "ThisExpression")
|
|
780
|
+
|
|
781
|
+
object_prop = object.child(:property)
|
|
782
|
+
AST::Node.matches?(object_prop, "Identifier") && object_prop[:name] == "props"
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# `const { foo, bar } = this.props;` — destructure off this.props. Each
|
|
786
|
+
# destructured name becomes a prop. We don't need to walk the rest of
|
|
787
|
+
# the chain because the destructure consumes one level of `.props`.
|
|
788
|
+
def this_props_destructure?(declarator)
|
|
789
|
+
init = declarator[:init]
|
|
790
|
+
return false unless AST::Node.matches?(init, "MemberExpression")
|
|
791
|
+
return false unless AST::Node.matches?(init.child(:object), "ThisExpression")
|
|
792
|
+
return false unless destructure_pattern?(declarator[:id])
|
|
793
|
+
|
|
794
|
+
prop = init.child(:property)
|
|
795
|
+
AST::Node.matches?(prop, "Identifier") && prop[:name] == "props"
|
|
796
|
+
end
|
|
797
|
+
|
|
267
798
|
def extract_arrow_components(variable_declaration)
|
|
268
799
|
variable_declaration[:declarations].filter_map do |declarator|
|
|
269
800
|
init = declarator[:init]
|
|
270
801
|
next nil unless init.is_a?(AST::Node)
|
|
271
|
-
next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
|
|
272
802
|
|
|
273
803
|
name = declarator[:id]&.[](:name)
|
|
274
|
-
|
|
804
|
+
next nil unless name
|
|
805
|
+
|
|
806
|
+
if %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
|
|
807
|
+
[name, init]
|
|
808
|
+
elsif (unwrapped = unwrap_hoc(init))
|
|
809
|
+
record_hoc_wrappers(name, unwrapped[:wrappers])
|
|
810
|
+
[name, unwrapped[:function]]
|
|
811
|
+
end
|
|
275
812
|
end
|
|
276
813
|
end
|
|
277
814
|
|
|
815
|
+
# Peer through a CallExpression initializer to find an inline
|
|
816
|
+
# function/arrow argument that's the real component definition.
|
|
817
|
+
# Recurses through nested wrappers so `memo(forwardRef(fn))` flattens
|
|
818
|
+
# to `["memo", "forwardRef"]` + the innermost `fn`. Returns
|
|
819
|
+
# `{ function: AST, wrappers: [String] }` or nil when no unwrap
|
|
820
|
+
# applies (the initializer is a CallExpression but doesn't match
|
|
821
|
+
# a known wrapper shape).
|
|
822
|
+
def unwrap_hoc(node)
|
|
823
|
+
wrappers = []
|
|
824
|
+
current = node
|
|
825
|
+
while current.is_a?(AST::Node) && current.type == "CallExpression"
|
|
826
|
+
callee_name = hoc_callee_name(current[:callee])
|
|
827
|
+
break unless callee_name
|
|
828
|
+
|
|
829
|
+
inner = current[:arguments].first
|
|
830
|
+
break unless inner.is_a?(AST::Node)
|
|
831
|
+
|
|
832
|
+
wrappers << callee_name
|
|
833
|
+
if %w[ArrowFunctionExpression FunctionExpression].include?(inner.type)
|
|
834
|
+
return { function: inner, wrappers: wrappers }
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
current = inner
|
|
838
|
+
end
|
|
839
|
+
nil
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Extract the wrapper name from a CallExpression callee. Accepts the
|
|
843
|
+
# bare form (`memo(...)`) and the React-namespace form (`React.memo(...)`).
|
|
844
|
+
# Returns the local name ("memo") in both cases so the wrapper TODO
|
|
845
|
+
# text doesn't have to handle both spellings.
|
|
846
|
+
def hoc_callee_name(callee)
|
|
847
|
+
return nil unless callee.is_a?(AST::Node)
|
|
848
|
+
|
|
849
|
+
name =
|
|
850
|
+
case callee.type
|
|
851
|
+
when "Identifier"
|
|
852
|
+
callee[:name]
|
|
853
|
+
when "MemberExpression"
|
|
854
|
+
object = callee[:object]
|
|
855
|
+
property = callee[:property]
|
|
856
|
+
return nil unless object.is_a?(AST::Node) && object.of_type?("Identifier")
|
|
857
|
+
return nil unless object[:name] == "React"
|
|
858
|
+
return nil unless property.is_a?(AST::Node) && property.of_type?("Identifier")
|
|
859
|
+
|
|
860
|
+
property[:name]
|
|
861
|
+
end
|
|
862
|
+
name if name && HOC_WRAPPER_NAMES.include?(name)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def record_hoc_wrappers(name, wrappers)
|
|
866
|
+
@component_hoc_wrappers ||= {}
|
|
867
|
+
@component_hoc_wrappers[name] = wrappers
|
|
868
|
+
end
|
|
869
|
+
|
|
278
870
|
def lower_component(name, function)
|
|
279
871
|
if name.nil? || name.empty?
|
|
280
872
|
raise lowering_error("anonymous component functions are not supported", node: function)
|
|
281
873
|
end
|
|
282
874
|
|
|
283
|
-
|
|
875
|
+
reset_per_component_state!
|
|
876
|
+
hoc_wrappers = (@component_hoc_wrappers || {})[name] || []
|
|
877
|
+
# forwardRef's inner function is `(props, ref) => …` — the second
|
|
878
|
+
# param has no Rails analog, so drop it before lower_params sees
|
|
879
|
+
# it (otherwise it'd land as a `ref:` ivar with no use site).
|
|
880
|
+
params_for_lowering = drop_forward_ref_param(function[:params], hoc_wrappers)
|
|
881
|
+
props, rest_prop_name = lower_params(params_for_lowering)
|
|
284
882
|
@prop_names = props.map(&:name)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
body = lower_function_body(function[:body])
|
|
295
|
-
|
|
883
|
+
absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
|
|
884
|
+
|
|
885
|
+
body, mode = lower_component_body(function)
|
|
886
|
+
# Unconsumed local arrows (`const handleClick = () => ...` that didn't
|
|
887
|
+
# become a Stimulus method or a render-method) are still real bindings
|
|
888
|
+
# in the source. Add their names here so the translator treats use
|
|
889
|
+
# sites as known-unresolvable — `on_click: handle_click` (NameError)
|
|
890
|
+
# becomes `on_click: nil` (file loads, marker visible upstream).
|
|
891
|
+
unconsumed_arrow_names = @local_arrows.keys
|
|
296
892
|
Component.new(
|
|
297
893
|
name: name,
|
|
298
894
|
props: props,
|
|
299
895
|
body: body,
|
|
300
896
|
rest_prop_name: rest_prop_name,
|
|
301
897
|
local_bindings: @local_bindings,
|
|
302
|
-
local_binding_names: @local_binding_names.uniq,
|
|
898
|
+
local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
|
|
303
899
|
module_bindings: [],
|
|
900
|
+
module_imports: [],
|
|
304
901
|
stimulus_methods: @stimulus_methods,
|
|
305
|
-
react_hooks: @react_hooks
|
|
902
|
+
react_hooks: @react_hooks,
|
|
903
|
+
render_methods: @render_methods,
|
|
904
|
+
mode: mode,
|
|
905
|
+
server_data_source: nil,
|
|
906
|
+
hoc_wrappers: hoc_wrappers
|
|
306
907
|
)
|
|
307
908
|
end
|
|
308
909
|
|
|
910
|
+
def drop_forward_ref_param(params, wrappers)
|
|
911
|
+
return params unless wrappers.include?("forwardRef") && params.size > 1
|
|
912
|
+
|
|
913
|
+
params[0..-2]
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# A "data factory" function — common for AG-Grid / antd column
|
|
917
|
+
# descriptor modules — is a function whose body just returns an array
|
|
918
|
+
# of object literals (`export const createColumns = (...) => [{...},
|
|
919
|
+
# {...}]`). When we recognize this shape, we lower the body via the
|
|
920
|
+
# recursive ObjectLiteral/ArrayLiteral path (Gap H) and let the
|
|
921
|
+
# backend emit a snake_case method that returns the data, instead of
|
|
922
|
+
# a `view_template`. JSX inside object properties still extracts to
|
|
923
|
+
# private methods on the class via the IR::Lambda extraction.
|
|
924
|
+
def lower_component_body(function)
|
|
925
|
+
factory_array = extract_data_factory_array(function)
|
|
926
|
+
return [lower_value_expression(factory_array), :data_factory] if factory_array
|
|
927
|
+
|
|
928
|
+
[lower_function_body(function[:body]), :view]
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def extract_data_factory_array(function)
|
|
932
|
+
body = function[:body]
|
|
933
|
+
return nil unless body.is_a?(AST::Node)
|
|
934
|
+
|
|
935
|
+
# Implicit-return arrow: body IS the ArrayExpression.
|
|
936
|
+
return body if data_factory_candidate_array?(body)
|
|
937
|
+
|
|
938
|
+
return nil unless body.of_type?("BlockStatement")
|
|
939
|
+
|
|
940
|
+
return_stmt = body[:body].last
|
|
941
|
+
return nil unless AST::Node.matches?(return_stmt, "ReturnStatement")
|
|
942
|
+
|
|
943
|
+
arg = return_stmt[:argument]
|
|
944
|
+
data_factory_candidate_array?(arg) ? arg : nil
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def data_factory_candidate_array?(node)
|
|
948
|
+
return false unless AST::Node.matches?(node, "ArrayExpression")
|
|
949
|
+
|
|
950
|
+
# At least one element should be an object literal — otherwise
|
|
951
|
+
# this is probably a primitive list, which doesn't warrant the
|
|
952
|
+
# extra emission machinery and can stay as a regular
|
|
953
|
+
# `body_returns_jsx?` rejection.
|
|
954
|
+
node[:elements].any? { |el| AST::Node.matches?(el, "ObjectExpression") }
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def reset_per_component_state!
|
|
958
|
+
@local_bindings = []
|
|
959
|
+
@local_binding_names = []
|
|
960
|
+
@local_arrows = {}
|
|
961
|
+
@local_polymorphic_tags = {}
|
|
962
|
+
@local_destructures = {}
|
|
963
|
+
@stimulus_methods = []
|
|
964
|
+
@stimulus_seen_names = {}
|
|
965
|
+
@react_hooks = []
|
|
966
|
+
@render_methods = []
|
|
967
|
+
@render_method_seen = {}
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def absorb_class_metadata(name, render_method, props)
|
|
971
|
+
absorb_class_other_members(name)
|
|
972
|
+
props.concat(absorb_class_render_props(render_method))
|
|
973
|
+
@prop_names = props.map(&:name)
|
|
974
|
+
end
|
|
975
|
+
|
|
309
976
|
def lower_params(params)
|
|
310
977
|
return [[], nil] if params.nil? || params.empty?
|
|
311
978
|
|
|
979
|
+
# Multi-positional params — typical for data-factory functions
|
|
980
|
+
# like `createColumns(token, sortedInfo)` and lowercase JSX-helpers
|
|
981
|
+
# like `getAlertIcon(level, status, token, isSnoozed = false)`.
|
|
982
|
+
# Each becomes a Prop with no default. We support Identifier and
|
|
983
|
+
# `AssignmentPattern` (default values get dropped — translating
|
|
984
|
+
# JS defaults to Ruby isn't worth the risk here). Other shapes
|
|
985
|
+
# in a multi-param signature fall through to legacy first-param-
|
|
986
|
+
# only handling so we don't regress files that used to translate.
|
|
987
|
+
if params.size > 1 && params.all? { |p| multi_param_supported?(p) }
|
|
988
|
+
return [params.map { |p| Prop.new(name: multi_param_name(p), default: nil, alias_name: nil) }, nil]
|
|
989
|
+
end
|
|
990
|
+
|
|
312
991
|
first_param = params.first
|
|
313
992
|
case first_param.type
|
|
314
993
|
when "ObjectPattern"
|
|
315
994
|
lower_object_pattern_params(first_param)
|
|
316
995
|
when "Identifier"
|
|
317
|
-
[[Prop.new(name: first_param[:name], default: nil)], nil]
|
|
996
|
+
[[Prop.new(name: first_param[:name], default: nil, alias_name: nil)], nil]
|
|
318
997
|
else
|
|
319
998
|
raise lowering_error("unsupported parameter shape: #{first_param.type}", node: first_param)
|
|
320
999
|
end
|
|
321
1000
|
end
|
|
322
1001
|
|
|
1002
|
+
def multi_param_supported?(param)
|
|
1003
|
+
return true if AST::Node.matches?(param, "Identifier")
|
|
1004
|
+
|
|
1005
|
+
AST::Node.matches?(param, "AssignmentPattern") && AST::Node.matches?(param[:left], "Identifier")
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def multi_param_name(param)
|
|
1009
|
+
param.type == "Identifier" ? param[:name] : param[:left][:name]
|
|
1010
|
+
end
|
|
1011
|
+
|
|
323
1012
|
def lower_object_pattern_params(pattern)
|
|
324
1013
|
props = []
|
|
325
1014
|
rest_name = nil
|
|
@@ -347,7 +1036,20 @@ module JsxRosetta
|
|
|
347
1036
|
# `nil # TODO: ...`. The trailing `#` comment inside a method
|
|
348
1037
|
# parameter list swallows the closing `)` and breaks Ruby syntax.
|
|
349
1038
|
default = (lower_value_expression(value[:right]) if value.type == "AssignmentPattern")
|
|
350
|
-
|
|
1039
|
+
alias_name = destructure_alias_for(prop_name, value)
|
|
1040
|
+
Prop.new(name: prop_name, default: default, alias_name: alias_name)
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
# `{ "data-testid": dataTestId }` — extract the alias so use sites of
|
|
1044
|
+
# `dataTestId` resolve to the prop's `@data_testid` ivar instead of
|
|
1045
|
+
# leaking as a bare snake_case ref. Returns nil for the non-aliased
|
|
1046
|
+
# case (`{ loading }` — key.name == value.name).
|
|
1047
|
+
def destructure_alias_for(prop_name, value)
|
|
1048
|
+
target = value.type == "AssignmentPattern" ? value[:left] : value
|
|
1049
|
+
return nil unless AST::Node.matches?(target, "Identifier")
|
|
1050
|
+
return nil if target[:name] == prop_name
|
|
1051
|
+
|
|
1052
|
+
target[:name]
|
|
351
1053
|
end
|
|
352
1054
|
|
|
353
1055
|
def lower_function_body(body)
|
|
@@ -614,29 +1316,54 @@ module JsxRosetta
|
|
|
614
1316
|
init = declarator[:init]
|
|
615
1317
|
return unless init.is_a?(AST::Node)
|
|
616
1318
|
|
|
617
|
-
|
|
618
|
-
|
|
1319
|
+
# `const { foo, bar } = this.props` — destructured names already
|
|
1320
|
+
# got synthesized into `props:` by absorb_class_render_props at
|
|
1321
|
+
# class-component setup time. Skip the LocalBinding TODO + the
|
|
1322
|
+
# local_binding_names capture so the translator picks up the prop
|
|
1323
|
+
# form (`@foo`) instead of emitting a `nil` placeholder.
|
|
1324
|
+
return if this_props_destructure?(declarator)
|
|
619
1325
|
|
|
620
|
-
|
|
621
|
-
if
|
|
622
|
-
# Capture destructured names so the ExpressionTranslator recognizes
|
|
623
|
-
# them as known-local bindings and emits a `nil` placeholder
|
|
624
|
-
# instead of a bare unresolved reference (which NameErrors at
|
|
625
|
-
# render time). Hook destructures (`const [open, setOpen] = useState(0)`)
|
|
626
|
-
# contribute names but not a separate LocalBinding TODO — the
|
|
627
|
-
# hook's source already shows the binding to the reviewer.
|
|
628
|
-
# Also track member-expression destructures (Gap J) so
|
|
629
|
-
# `const { Content } = Layout` lets `<Content/>` resolve to
|
|
630
|
-
# `Layout::Content`.
|
|
631
|
-
record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
|
|
632
|
-
return
|
|
633
|
-
end
|
|
1326
|
+
library = hook_library_for(init)
|
|
1327
|
+
record_hook_call(stmt, init, library) if library
|
|
634
1328
|
|
|
635
|
-
|
|
1329
|
+
id_node = declarator[:id]
|
|
1330
|
+
return handle_destructure_binding(stmt, declarator, init, seen, !library.nil?) if destructure_pattern?(id_node)
|
|
1331
|
+
return handle_identifier_hook_binding(id_node) if library
|
|
636
1332
|
|
|
637
1333
|
name = id_node&.[](:name)
|
|
638
1334
|
return unless name
|
|
639
1335
|
|
|
1336
|
+
dispatch_identifier_binding(stmt, init, name, seen)
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def record_hook_call(stmt, call_expression, library)
|
|
1340
|
+
@react_hooks << ReactHookCall.new(
|
|
1341
|
+
hook: call_expression[:callee][:name],
|
|
1342
|
+
source: source_of(stmt).strip,
|
|
1343
|
+
library: library,
|
|
1344
|
+
operation: apollo_operation_name(call_expression, library)
|
|
1345
|
+
)
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
# `const [a, b] = ...` or `const { a, b } = ...`. Capture every bound
|
|
1349
|
+
# name so the translator recognizes them as known locals. Hook
|
|
1350
|
+
# destructures (`const [open, setOpen] = useState(0)`) contribute
|
|
1351
|
+
# names but not a separate LocalBinding TODO — the hook's source
|
|
1352
|
+
# already shows the binding to the reviewer.
|
|
1353
|
+
def handle_destructure_binding(stmt, declarator, init, seen, is_hook)
|
|
1354
|
+
record_destructured_names(stmt, declarator, init: init, seen: seen, is_hook: is_hook)
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
# Identifier-bound hook result (`const handleChange = useCallback(...)`).
|
|
1358
|
+
# The hook source is already in @react_hooks; just mark the binding
|
|
1359
|
+
# name as known-local so use sites translate to `nil` instead of a
|
|
1360
|
+
# bare snake_case ref that NameErrors at render time.
|
|
1361
|
+
def handle_identifier_hook_binding(id_node)
|
|
1362
|
+
name = id_node&.[](:name)
|
|
1363
|
+
@local_binding_names << name if name
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
def dispatch_identifier_binding(stmt, init, name, seen)
|
|
640
1367
|
case init.type
|
|
641
1368
|
when "JSXElement", "JSXFragment"
|
|
642
1369
|
@local_jsx[name] = init
|
|
@@ -717,16 +1444,50 @@ module JsxRosetta
|
|
|
717
1444
|
|
|
718
1445
|
def detect_bare_hook_call(stmt)
|
|
719
1446
|
expr = stmt.child(:expression)
|
|
720
|
-
return unless expr&.of_type?("CallExpression")
|
|
1447
|
+
return unless expr&.of_type?("CallExpression")
|
|
1448
|
+
|
|
1449
|
+
library = hook_library_for(expr)
|
|
1450
|
+
return unless library
|
|
721
1451
|
|
|
722
|
-
|
|
1452
|
+
record_hook_call(stmt, expr, library)
|
|
723
1453
|
end
|
|
724
1454
|
|
|
725
1455
|
def hook_call?(call_expression)
|
|
726
|
-
|
|
1456
|
+
!hook_library_for(call_expression).nil?
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
# Resolve a CallExpression to the library whose hook set its callee
|
|
1460
|
+
# belongs to (`:react`, `:apollo`, `:next_js`), or nil when it isn't
|
|
1461
|
+
# a recognized hook invocation. Lookup is by bare-Identifier callee
|
|
1462
|
+
# only — member-expression callees (`Apollo.useQuery`) aren't
|
|
1463
|
+
# recognized; we follow what production code actually writes.
|
|
1464
|
+
def hook_library_for(call_expression)
|
|
1465
|
+
return nil unless call_expression.is_a?(AST::Node) && call_expression.of_type?("CallExpression")
|
|
727
1466
|
|
|
728
1467
|
callee = call_expression.child(:callee)
|
|
729
|
-
callee&.of_type?("Identifier")
|
|
1468
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1469
|
+
|
|
1470
|
+
name = callee[:name]
|
|
1471
|
+
FRAMEWORK_HOOKS_BY_LIBRARY.each do |library, names|
|
|
1472
|
+
return library if names.include?(name)
|
|
1473
|
+
end
|
|
1474
|
+
nil
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
# For Apollo's document-first hooks (`useQuery(GET_USERS, ...)`),
|
|
1478
|
+
# extract the operation name from a bare-Identifier first argument
|
|
1479
|
+
# so the backend can echo it in the TODO. Returns nil for inline
|
|
1480
|
+
# documents (`gql\`...\``), member-expression args, or non-Apollo
|
|
1481
|
+
# hooks — the caller already has the verbatim source in `source`,
|
|
1482
|
+
# which surfaces those cases to the reviewer.
|
|
1483
|
+
def apollo_operation_name(call_expression, library)
|
|
1484
|
+
return nil unless library == :apollo
|
|
1485
|
+
|
|
1486
|
+
args = call_expression[:arguments]
|
|
1487
|
+
first_arg = args.is_a?(Array) ? args.first : nil
|
|
1488
|
+
return nil unless AST::Node.matches?(first_arg, "Identifier")
|
|
1489
|
+
|
|
1490
|
+
first_arg[:name]
|
|
730
1491
|
end
|
|
731
1492
|
|
|
732
1493
|
# Recognize the asChild-style polymorphic tag pattern:
|
|
@@ -782,14 +1543,82 @@ module JsxRosetta
|
|
|
782
1543
|
# Gap J: `const { Content } = Layout; <Content/>` should resolve
|
|
783
1544
|
# to `Layout::Content`, not a bare `ContentComponent`.
|
|
784
1545
|
ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
|
|
1546
|
+
elsif next_js_layout_yield?(tag, attributes)
|
|
1547
|
+
# `<Component {...pageProps} />` — the canonical Next.js _app
|
|
1548
|
+
# content slot. Lowering to LayoutYield lets the Phlex backend
|
|
1549
|
+
# emit `yield` (Rails layout convention) instead of trying to
|
|
1550
|
+
# render the prop verbatim.
|
|
1551
|
+
LayoutYield.new
|
|
785
1552
|
elsif html_element?(tag)
|
|
786
1553
|
Element.new(tag: tag, attributes: attributes, children: children)
|
|
1554
|
+
elsif (radix = radix_primitive_for(tag))
|
|
1555
|
+
# `<SeparatorPrimitive.Root .../>` (imported from radix-ui) lowers
|
|
1556
|
+
# to a plain `<div role="separator">` so the consumer doesn't have
|
|
1557
|
+
# to define a Components::SeparatorPrimitive::Root shim.
|
|
1558
|
+
Element.new(
|
|
1559
|
+
tag: radix[:tag],
|
|
1560
|
+
attributes: merge_radix_attrs(radix[:attrs], attributes),
|
|
1561
|
+
children: children
|
|
1562
|
+
)
|
|
787
1563
|
else
|
|
788
1564
|
ComponentInvocation.new(name: tag, props: attributes, children: children)
|
|
789
1565
|
end
|
|
790
1566
|
end
|
|
791
1567
|
|
|
1568
|
+
# Returns the Radix registry entry for `<LocalName.Member />` when:
|
|
1569
|
+
# - the tag is a two-segment member chain
|
|
1570
|
+
# - the root segment was imported from a Radix-shaped package
|
|
1571
|
+
# - the (LocalName, Member) pair is in the registry
|
|
1572
|
+
# Otherwise nil — the caller falls through to a ComponentInvocation.
|
|
1573
|
+
def radix_primitive_for(tag)
|
|
1574
|
+
segments = tag.split(".")
|
|
1575
|
+
return nil if segments.length != 2
|
|
1576
|
+
|
|
1577
|
+
local, member = segments
|
|
1578
|
+
return nil unless imported_from_radix?(local)
|
|
1579
|
+
|
|
1580
|
+
RadixRegistry.lookup(local, member)
|
|
1581
|
+
end
|
|
1582
|
+
|
|
1583
|
+
def imported_from_radix?(local_name)
|
|
1584
|
+
@module_imports.any? do |imp|
|
|
1585
|
+
imp.name == local_name && RADIX_SOURCE_PATTERN.match?(imp.source)
|
|
1586
|
+
end
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
# Combine the registry's fixed attrs (role, type, etc.) with the
|
|
1590
|
+
# consumer's own JSX attributes. Consumer attrs win on collision — the
|
|
1591
|
+
# JSX is the source of truth; the registry just supplies safe defaults.
|
|
1592
|
+
# Collision keys normalize away case + hyphens/underscores so future
|
|
1593
|
+
# registry entries like `data-state` don't slip past a consumer's
|
|
1594
|
+
# `dataState`.
|
|
1595
|
+
def merge_radix_attrs(fixed_attrs, jsx_attrs)
|
|
1596
|
+
user_keys = jsx_attrs.filter_map do |a|
|
|
1597
|
+
a.respond_to?(:name) ? normalize_attr_key(a.name) : nil
|
|
1598
|
+
end.to_set
|
|
1599
|
+
injected = fixed_attrs.filter_map do |name, value|
|
|
1600
|
+
attr_name = name.to_s
|
|
1601
|
+
next if user_keys.include?(normalize_attr_key(attr_name))
|
|
1602
|
+
|
|
1603
|
+
Attribute.new(name: attr_name, value: value.to_s)
|
|
1604
|
+
end
|
|
1605
|
+
injected + jsx_attrs
|
|
1606
|
+
end
|
|
1607
|
+
|
|
1608
|
+
def normalize_attr_key(name)
|
|
1609
|
+
name.to_s.downcase.tr("-_", "")
|
|
1610
|
+
end
|
|
1611
|
+
|
|
792
1612
|
def lower_polymorphic_tag_use(poly, attributes, children)
|
|
1613
|
+
if (chosen = drop_slot_branch(poly))
|
|
1614
|
+
# The shadcn `<Comp asChild>` pattern routes through Radix's
|
|
1615
|
+
# Slot.Root, which has no Ruby class on the Phlex side. Drop the
|
|
1616
|
+
# Slot branch and render the underlying HTML/component branch
|
|
1617
|
+
# directly. Pass `--keep-slot` to preserve the conditional if
|
|
1618
|
+
# the consumer is shimming Slot::Root themselves.
|
|
1619
|
+
return build_polymorphic_branch(chosen, attributes, children)
|
|
1620
|
+
end
|
|
1621
|
+
|
|
793
1622
|
Conditional.new(
|
|
794
1623
|
test: Interpolation.new(expression: source_of(poly[:test])),
|
|
795
1624
|
consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
|
|
@@ -806,6 +1635,41 @@ module JsxRosetta
|
|
|
806
1635
|
end
|
|
807
1636
|
end
|
|
808
1637
|
|
|
1638
|
+
# Returns the non-Slot branch when exactly one of the polymorphic
|
|
1639
|
+
# branches resolves to a Radix Slot reference (`Slot` or `Slot.Root`
|
|
1640
|
+
# rooted at a `radix-ui` import). Returns nil otherwise — including
|
|
1641
|
+
# when `--keep-slot` is in effect — so the caller emits the full
|
|
1642
|
+
# conditional unchanged.
|
|
1643
|
+
def drop_slot_branch(poly)
|
|
1644
|
+
return nil if @keep_slot
|
|
1645
|
+
|
|
1646
|
+
t = poly[:true_branch]
|
|
1647
|
+
f = poly[:false_branch]
|
|
1648
|
+
t_is_slot = radix_slot_branch?(t)
|
|
1649
|
+
f_is_slot = radix_slot_branch?(f)
|
|
1650
|
+
return f if t_is_slot && !f_is_slot
|
|
1651
|
+
return t if f_is_slot && !t_is_slot
|
|
1652
|
+
|
|
1653
|
+
nil
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1656
|
+
# True iff `branch` references a Slot import from a Radix-shaped
|
|
1657
|
+
# package. The local binding is one of {`Slot`, `SlotPrimitive`} —
|
|
1658
|
+
# both correspond to the canonical "import from radix-ui / @radix-ui/
|
|
1659
|
+
# react-slot" pattern. Anything else (e.g. a user-defined
|
|
1660
|
+
# `SlotMachine` from a random package whose path happens to contain
|
|
1661
|
+
# "radix") falls through and renders the conditional unchanged.
|
|
1662
|
+
def radix_slot_branch?(branch)
|
|
1663
|
+
return false unless branch[:kind] == :component
|
|
1664
|
+
|
|
1665
|
+
root = branch[:tag].split(".").first
|
|
1666
|
+
return false unless root && SLOT_LOCAL_NAME_PATTERN.match?(root)
|
|
1667
|
+
|
|
1668
|
+
@module_imports.any? do |imp|
|
|
1669
|
+
imp.name == root && RADIX_SOURCE_PATTERN.match?(imp.source)
|
|
1670
|
+
end
|
|
1671
|
+
end
|
|
1672
|
+
|
|
809
1673
|
def lower_jsx_fragment(fragment)
|
|
810
1674
|
Fragment.new(children: lower_children(fragment.jsx_children))
|
|
811
1675
|
end
|
|
@@ -899,7 +1763,66 @@ module JsxRosetta
|
|
|
899
1763
|
|
|
900
1764
|
def lower_call_expression(expression)
|
|
901
1765
|
loop_node = try_lower_map_loop(expression)
|
|
902
|
-
loop_node
|
|
1766
|
+
return loop_node if loop_node
|
|
1767
|
+
|
|
1768
|
+
local_call = try_lower_local_arrow_call(expression)
|
|
1769
|
+
return local_call if local_call
|
|
1770
|
+
|
|
1771
|
+
Interpolation.new(expression: source_of(expression))
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
# Recognize `{renderHeader()}` where `renderHeader` is a locally-bound
|
|
1775
|
+
# arrow whose body returns JSX. Extract the arrow as a RenderMethod
|
|
1776
|
+
# on the component and emit a LocalRenderCall at this use site so the
|
|
1777
|
+
# backend can call the generated method instead of dropping the
|
|
1778
|
+
# expression as "[untranslated: renderHeader()]". Args must be simple
|
|
1779
|
+
# identifiers (props, locals) since we don't translate arbitrary
|
|
1780
|
+
# argument expressions here — the backend's ExpressionTranslator
|
|
1781
|
+
# handles them via the Interpolation it sees.
|
|
1782
|
+
def try_lower_local_arrow_call(call_expression)
|
|
1783
|
+
match = local_arrow_call_match(call_expression)
|
|
1784
|
+
return nil unless match
|
|
1785
|
+
|
|
1786
|
+
# Consume the arrow so it doesn't ALSO get promoted to a Stimulus
|
|
1787
|
+
# method if it later appears in event-handler position.
|
|
1788
|
+
@local_arrows.delete(match[:callee_name])
|
|
1789
|
+
|
|
1790
|
+
method_name = unique_render_method_name(match[:callee_name])
|
|
1791
|
+
@render_methods << RenderMethod.new(
|
|
1792
|
+
name: method_name,
|
|
1793
|
+
params: match[:arrow][:params].map { |p| p[:name] },
|
|
1794
|
+
body: match[:body]
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
LocalRenderCall.new(
|
|
1798
|
+
method_name: method_name,
|
|
1799
|
+
args: match[:args].map { |arg| Interpolation.new(expression: source_of(arg)) }
|
|
1800
|
+
)
|
|
1801
|
+
end
|
|
1802
|
+
|
|
1803
|
+
def local_arrow_call_match(call_expression)
|
|
1804
|
+
callee = call_expression.child(:callee)
|
|
1805
|
+
return nil unless callee&.of_type?("Identifier")
|
|
1806
|
+
|
|
1807
|
+
arrow = @local_arrows[callee[:name]]
|
|
1808
|
+
return nil unless arrow
|
|
1809
|
+
return nil unless arrow[:params].all? { |p| AST::Node.matches?(p, "Identifier") }
|
|
1810
|
+
|
|
1811
|
+
args = call_expression[:arguments]
|
|
1812
|
+
return nil if args.size != arrow[:params].size
|
|
1813
|
+
return nil unless args.all? { |a| AST::Node.matches?(a, "Identifier", "MemberExpression") }
|
|
1814
|
+
|
|
1815
|
+
body = lower_lambda_body(arrow[:body])
|
|
1816
|
+
return nil unless body
|
|
1817
|
+
|
|
1818
|
+
{ callee_name: callee[:name], arrow: arrow, args: args, body: body }
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1821
|
+
def unique_render_method_name(js_name)
|
|
1822
|
+
snake = AST::Inflector.underscore(js_name)
|
|
1823
|
+
@render_method_seen[snake] ||= 0
|
|
1824
|
+
@render_method_seen[snake] += 1
|
|
1825
|
+
@render_method_seen[snake] == 1 ? snake : "#{snake}_#{@render_method_seen[snake]}"
|
|
903
1826
|
end
|
|
904
1827
|
|
|
905
1828
|
def try_lower_map_loop(call_expression)
|
|
@@ -1061,21 +1984,33 @@ module JsxRosetta
|
|
|
1061
1984
|
end
|
|
1062
1985
|
return nil if property_name.nil?
|
|
1063
1986
|
|
|
1064
|
-
value = lower_style_value(property[:value])
|
|
1987
|
+
value = lower_style_value(property[:value], property_name)
|
|
1065
1988
|
return nil if value.nil?
|
|
1066
1989
|
|
|
1067
1990
|
StyleDeclaration.new(property: property_name, value: value)
|
|
1068
1991
|
end
|
|
1069
1992
|
|
|
1070
|
-
def lower_style_value(value)
|
|
1993
|
+
def lower_style_value(value, property_name)
|
|
1071
1994
|
case value.type
|
|
1072
1995
|
when "StringLiteral" then value[:value]
|
|
1073
|
-
when "NumericLiteral" then value[:value]
|
|
1996
|
+
when "NumericLiteral" then numeric_style_value(value[:value], property_name)
|
|
1074
1997
|
when "Identifier", "MemberExpression"
|
|
1075
1998
|
Interpolation.new(expression: source_of(value))
|
|
1076
1999
|
end
|
|
1077
2000
|
end
|
|
1078
2001
|
|
|
2002
|
+
# Mirrors React's `isUnitlessNumber` table: properties that take bare
|
|
2003
|
+
# numbers (`zIndex: 5`) rather than lengths. Everything else gets a
|
|
2004
|
+
# `px` suffix appended when the JSX source provides a unitless number
|
|
2005
|
+
# — `marginBottom: 16` → `margin-bottom: 16px;`. Without this, the
|
|
2006
|
+
# browser silently ignores the declaration as invalid CSS.
|
|
2007
|
+
def numeric_style_value(number, property_name)
|
|
2008
|
+
return number.to_s if number.zero?
|
|
2009
|
+
return number.to_s if UNITLESS_CSS_PROPERTIES.include?(property_name)
|
|
2010
|
+
|
|
2011
|
+
"#{number}px"
|
|
2012
|
+
end
|
|
2013
|
+
|
|
1079
2014
|
def css_property_from_camel(name)
|
|
1080
2015
|
name.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
1081
2016
|
end
|
|
@@ -1084,10 +2019,116 @@ module JsxRosetta
|
|
|
1084
2019
|
if value.is_a?(AST::JSXExpressionContainer)
|
|
1085
2020
|
decomposed = try_lower_class_helper(value.expression)
|
|
1086
2021
|
return decomposed if decomposed
|
|
2022
|
+
|
|
2023
|
+
cva_call = try_lower_cva_call_site(value.expression)
|
|
2024
|
+
return cva_call if cva_call
|
|
1087
2025
|
end
|
|
1088
2026
|
StyleBinding.new(expression: style_binding_expression(value))
|
|
1089
2027
|
end
|
|
1090
2028
|
|
|
2029
|
+
# Recognize the cva call shape — `cn(<cvaName>({ axes }), <classArg>)`
|
|
2030
|
+
# or the bare `<cvaName>({ axes })` direct form — against a CvaBinding
|
|
2031
|
+
# captured during module-level lowering. Returns an IR::CvaCallSite,
|
|
2032
|
+
# or nil so the caller falls through to the generic StyleBinding.
|
|
2033
|
+
# AST-driven instead of regexing over verbatim source, which lets us
|
|
2034
|
+
# handle reversed-arg `cn(<classArg>, <cvaName>(...))`, the no-cn
|
|
2035
|
+
# direct form, and literal-pinned axes naturally.
|
|
2036
|
+
def try_lower_cva_call_site(expression)
|
|
2037
|
+
return nil unless expression.respond_to?(:type)
|
|
2038
|
+
return nil unless expression.type == "CallExpression"
|
|
2039
|
+
|
|
2040
|
+
callee = expression.child(:callee)
|
|
2041
|
+
return nil unless callee
|
|
2042
|
+
|
|
2043
|
+
if callee.of_type?("Identifier") && %w[cn clsx classnames].include?(callee[:name])
|
|
2044
|
+
build_cva_call_site_from_class_helper(expression[:arguments] || [])
|
|
2045
|
+
else
|
|
2046
|
+
build_cva_call_site_from_direct(expression)
|
|
2047
|
+
end
|
|
2048
|
+
end
|
|
2049
|
+
|
|
2050
|
+
# `cn(<cvaCall>, <classArg>)` or `cn(<classArg>, <cvaCall>)` — accept
|
|
2051
|
+
# the first argument that resolves to a known cva call; the remaining
|
|
2052
|
+
# argument (if any) becomes the optional `class_arg`. Anything more
|
|
2053
|
+
# complex (3+ args, nested cn, multiple cva calls) bails to nil.
|
|
2054
|
+
def build_cva_call_site_from_class_helper(args)
|
|
2055
|
+
return nil unless args.length.between?(1, 2)
|
|
2056
|
+
|
|
2057
|
+
cva_arg_index = args.find_index { |a| cva_call_against_known_binding?(a) }
|
|
2058
|
+
return nil unless cva_arg_index
|
|
2059
|
+
|
|
2060
|
+
cva_arg = args[cva_arg_index]
|
|
2061
|
+
class_arg = args.length == 2 ? args[1 - cva_arg_index] : nil
|
|
2062
|
+
build_cva_call_site_node(cva_arg, class_arg)
|
|
2063
|
+
end
|
|
2064
|
+
|
|
2065
|
+
# Bare `<cvaName>({ axes })` — same shape with no class_arg.
|
|
2066
|
+
def build_cva_call_site_from_direct(expression)
|
|
2067
|
+
return nil unless cva_call_against_known_binding?(expression)
|
|
2068
|
+
|
|
2069
|
+
build_cva_call_site_node(expression, nil)
|
|
2070
|
+
end
|
|
2071
|
+
|
|
2072
|
+
def build_cva_call_site_node(cva_call, class_arg_node)
|
|
2073
|
+
callee_name = cva_call[:callee][:name]
|
|
2074
|
+
options = cva_call[:arguments]&.first
|
|
2075
|
+
return nil unless options && options.type == "ObjectExpression"
|
|
2076
|
+
|
|
2077
|
+
axes = options[:properties].filter_map { |prop| build_cva_axis_pair(prop) }
|
|
2078
|
+
class_arg = class_arg_node && Interpolation.new(expression: source_of(class_arg_node))
|
|
2079
|
+
CvaCallSite.new(binding_name: callee_name, axes: axes, class_arg: class_arg)
|
|
2080
|
+
end
|
|
2081
|
+
|
|
2082
|
+
# Pull one axis-value pair off the cva options object. Shorthand
|
|
2083
|
+
# (`{ variant }`) and explicit (`{ variant: someExpr }`) both work;
|
|
2084
|
+
# spread (`{ ...rest }`) and computed keys bail to nil so the call
|
|
2085
|
+
# site falls through to the generic translator with a TODO.
|
|
2086
|
+
def build_cva_axis_pair(prop)
|
|
2087
|
+
return nil unless prop.type == "ObjectProperty"
|
|
2088
|
+
|
|
2089
|
+
axis = property_key_name(prop)
|
|
2090
|
+
return nil unless axis
|
|
2091
|
+
|
|
2092
|
+
value_node = prop[:value]
|
|
2093
|
+
kind, source = classify_cva_axis_value(value_node)
|
|
2094
|
+
CvaAxisPair.new(axis: axis, kind: kind, source: source)
|
|
2095
|
+
end
|
|
2096
|
+
|
|
2097
|
+
def property_key_name(prop)
|
|
2098
|
+
case prop[:key].type
|
|
2099
|
+
when "Identifier" then prop[:key][:name]
|
|
2100
|
+
when "StringLiteral" then prop[:key][:value]
|
|
2101
|
+
end
|
|
2102
|
+
end
|
|
2103
|
+
|
|
2104
|
+
def classify_cva_axis_value(node)
|
|
2105
|
+
case node.type
|
|
2106
|
+
when "StringLiteral" then [:literal_string, node[:value]]
|
|
2107
|
+
when "NumericLiteral", "BooleanLiteral" then [:literal_other, source_of(node)]
|
|
2108
|
+
when "NullLiteral" then [:literal_nil, nil]
|
|
2109
|
+
when "Identifier"
|
|
2110
|
+
# Shorthand `{ variant }` and explicit `{ variant: ident }` both
|
|
2111
|
+
# land here; the source is the identifier name itself.
|
|
2112
|
+
node[:name] == "undefined" ? [:literal_nil, nil] : [:prop_ref, node[:name]]
|
|
2113
|
+
else
|
|
2114
|
+
# Member chains, calls, etc. — pass the source through as a
|
|
2115
|
+
# raw expression. The backend re-translates it through
|
|
2116
|
+
# ExpressionTranslator like any other prop reference.
|
|
2117
|
+
[:prop_ref, source_of(node)]
|
|
2118
|
+
end
|
|
2119
|
+
end
|
|
2120
|
+
|
|
2121
|
+
def cva_call_against_known_binding?(node)
|
|
2122
|
+
return false unless node.respond_to?(:type)
|
|
2123
|
+
return false unless node.type == "CallExpression"
|
|
2124
|
+
|
|
2125
|
+
callee = node.child(:callee)
|
|
2126
|
+
return false unless callee&.of_type?("Identifier")
|
|
2127
|
+
|
|
2128
|
+
binding_name = callee[:name]
|
|
2129
|
+
@module_bindings.any? { |b| b.is_a?(CvaBinding) && b.name == binding_name }
|
|
2130
|
+
end
|
|
2131
|
+
|
|
1091
2132
|
def try_lower_class_helper(expression)
|
|
1092
2133
|
return nil unless AST::Node.matches?(expression, "CallExpression")
|
|
1093
2134
|
|
|
@@ -1168,9 +2209,18 @@ module JsxRosetta
|
|
|
1168
2209
|
def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
|
|
1169
2210
|
base = name_hint || default_stimulus_method_name(attr_name)
|
|
1170
2211
|
method_name = stimulus_method_name(base)
|
|
1171
|
-
|
|
2212
|
+
body_node = arrow_node[:body]
|
|
2213
|
+
body_source = source_of(body_node)
|
|
2214
|
+
# Preserve nil entries for non-Identifier params (ObjectPattern,
|
|
2215
|
+
# ArrayPattern, RestElement) so emit-time bails to TODO rather
|
|
2216
|
+
# than pasting a body that references undefined locals.
|
|
2217
|
+
params = Array(arrow_node[:params]).map { |p| p.type == "Identifier" ? p[:name] : nil }
|
|
1172
2218
|
@stimulus_methods << StimulusMethod.new(
|
|
1173
|
-
name: method_name,
|
|
2219
|
+
name: method_name,
|
|
2220
|
+
body_source: body_source,
|
|
2221
|
+
original_name: base,
|
|
2222
|
+
params: params,
|
|
2223
|
+
body_is_block: body_node.type == "BlockStatement"
|
|
1174
2224
|
)
|
|
1175
2225
|
@local_arrows.delete(name_hint) if name_hint
|
|
1176
2226
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
@@ -1184,7 +2234,11 @@ module JsxRosetta
|
|
|
1184
2234
|
method_name = stimulus_method_name(identifier_name)
|
|
1185
2235
|
body_source = "// originally bound to: #{identifier_name}"
|
|
1186
2236
|
@stimulus_methods << StimulusMethod.new(
|
|
1187
|
-
name: method_name,
|
|
2237
|
+
name: method_name,
|
|
2238
|
+
body_source: body_source,
|
|
2239
|
+
original_name: identifier_name,
|
|
2240
|
+
params: [],
|
|
2241
|
+
body_is_block: false
|
|
1188
2242
|
)
|
|
1189
2243
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
1190
2244
|
end
|
|
@@ -1215,18 +2269,39 @@ module JsxRosetta
|
|
|
1215
2269
|
# Recursively lower an arbitrary JS expression into structured IR
|
|
1216
2270
|
# when possible: ObjectExpression → ObjectLiteral, ArrayExpression
|
|
1217
2271
|
# → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
|
|
1218
|
-
# Lambda
|
|
1219
|
-
#
|
|
2272
|
+
# Lambda, JSXElement / JSXFragment → the same Element / Fragment /
|
|
2273
|
+
# ComponentInvocation that JSX children lower to. Everything else
|
|
2274
|
+
# falls back to the verbatim Interpolation so simpler
|
|
2275
|
+
# ExpressionTranslator paths still get a crack at it.
|
|
1220
2276
|
def lower_value_expression(expression)
|
|
1221
2277
|
case expression.type
|
|
1222
2278
|
when "ObjectExpression" then lower_object_literal(expression)
|
|
1223
2279
|
when "ArrayExpression" then lower_array_literal(expression)
|
|
1224
2280
|
when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
|
|
2281
|
+
when "JSXElement", "JSXFragment" then lower_jsx_value(expression)
|
|
1225
2282
|
else
|
|
1226
2283
|
Interpolation.new(expression: source_of(expression))
|
|
1227
2284
|
end
|
|
1228
2285
|
end
|
|
1229
2286
|
|
|
2287
|
+
# JSX appearing in a non-child position — typically as an attribute
|
|
2288
|
+
# value (`icon={<Foo/>}`, `fallback={<Loading/>}`). Lowered through
|
|
2289
|
+
# the same `lower_jsx` pipeline as children, so the resulting IR
|
|
2290
|
+
# node (Element / ComponentInvocation / Fragment) carries all the
|
|
2291
|
+
# structure the backend can use to emit a real Phlex render call
|
|
2292
|
+
# in place of the old "[untranslated: …]" drop. Single-child
|
|
2293
|
+
# Fragments (`<><Foo/></>`, often emitted by React idioms or as a
|
|
2294
|
+
# workaround for "one node required") collapse to the inner child
|
|
2295
|
+
# so the kwarg value isn't an awkwardly-wrapped Fragment.
|
|
2296
|
+
def lower_jsx_value(expression)
|
|
2297
|
+
lowered = lower_jsx(expression)
|
|
2298
|
+
if lowered.is_a?(Fragment) && lowered.children.length == 1
|
|
2299
|
+
lowered.children.first
|
|
2300
|
+
else
|
|
2301
|
+
lowered
|
|
2302
|
+
end
|
|
2303
|
+
end
|
|
2304
|
+
|
|
1230
2305
|
def lower_object_literal(object_expression)
|
|
1231
2306
|
properties = []
|
|
1232
2307
|
object_expression[:properties].each do |prop|
|
|
@@ -1268,10 +2343,16 @@ module JsxRosetta
|
|
|
1268
2343
|
return Interpolation.new(expression: source_of(arrow))
|
|
1269
2344
|
end
|
|
1270
2345
|
|
|
2346
|
+
param_names = params.map { |p| p[:name] }
|
|
1271
2347
|
body = lower_lambda_body(arrow[:body])
|
|
1272
|
-
|
|
2348
|
+
# JSX-bearing body → render lambda (translated to a Phlex render
|
|
2349
|
+
# method). Non-JSX body → opaque event handler (the body becomes
|
|
2350
|
+
# a verbatim TODO inside a stub method on the class). Without
|
|
2351
|
+
# this fork, every `onClick={() => doX()}` on a PascalCase tag
|
|
2352
|
+
# used to bail to Interpolation and drop with a TODO.
|
|
2353
|
+
return Lambda.new(params: param_names, body: body) if body
|
|
1273
2354
|
|
|
1274
|
-
|
|
2355
|
+
EventHandler.new(params: param_names, body_source: source_of(arrow[:body]))
|
|
1275
2356
|
end
|
|
1276
2357
|
|
|
1277
2358
|
def lower_lambda_body(body)
|
|
@@ -1302,6 +2383,19 @@ module JsxRosetta
|
|
|
1302
2383
|
first == first.downcase
|
|
1303
2384
|
end
|
|
1304
2385
|
|
|
2386
|
+
# `<Component {...pageProps} />` — Next.js _app content slot. Tag must
|
|
2387
|
+
# be exactly "Component" and the props must include a spread of
|
|
2388
|
+
# "pageProps". Strict shape match keeps this from false-firing on
|
|
2389
|
+
# ordinary code that happens to render a prop-named `<Component>` —
|
|
2390
|
+
# the `{...pageProps}` spread is the unambiguous Next.js signal.
|
|
2391
|
+
def next_js_layout_yield?(tag, attributes)
|
|
2392
|
+
return false unless tag == "Component"
|
|
2393
|
+
|
|
2394
|
+
attributes.any? do |attr|
|
|
2395
|
+
attr.is_a?(SpreadAttribute) && attr.expression.strip == "pageProps"
|
|
2396
|
+
end
|
|
2397
|
+
end
|
|
2398
|
+
|
|
1305
2399
|
def source_of(node)
|
|
1306
2400
|
@source[node.start_pos...node.end_pos]
|
|
1307
2401
|
end
|