jsx_rosetta 0.5.1 → 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 +128 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +17 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
- data/lib/jsx_rosetta/backend/view_component.rb +48 -2
- 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 +720 -31
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +187 -3
- 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 +29 -1
|
@@ -55,12 +55,12 @@ module JsxRosetta
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def self.lower(file, source:)
|
|
59
|
-
new(source).lower_file(file)
|
|
58
|
+
def self.lower(file, source:, keep_slot: false)
|
|
59
|
+
new(source, keep_slot: keep_slot).lower_file(file)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def self.lower_all(file, source:)
|
|
63
|
-
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)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
REACT_HOOKS = %w[
|
|
@@ -126,6 +126,14 @@ module JsxRosetta
|
|
|
126
126
|
"LogicalExpression" => ->(n) { [n[:left], n[:right]] }
|
|
127
127
|
}.freeze
|
|
128
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
|
+
|
|
129
137
|
SHAPE_MESSAGES = {
|
|
130
138
|
hoc_wrapped: "looks like a HOC-wrapped component (React.memo / forwardRef / lazy / observer) — " \
|
|
131
139
|
"this version doesn't peel HOC wrappers; remove the wrapper or upgrade when supported",
|
|
@@ -146,8 +154,14 @@ module JsxRosetta
|
|
|
146
154
|
unknown: nil
|
|
147
155
|
}.freeze
|
|
148
156
|
|
|
149
|
-
def initialize(source)
|
|
157
|
+
def initialize(source, keep_slot: false)
|
|
150
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
|
|
151
165
|
@prop_names = []
|
|
152
166
|
@local_jsx = {}
|
|
153
167
|
@local_bindings = []
|
|
@@ -160,6 +174,11 @@ module JsxRosetta
|
|
|
160
174
|
@react_hooks = []
|
|
161
175
|
@render_methods = []
|
|
162
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 = []
|
|
163
182
|
# Class-component non-render members (constructor, lifecycle hooks,
|
|
164
183
|
# custom handlers). Keyed by class name; populated by
|
|
165
184
|
# extract_class_component, drained by lower_component to surface
|
|
@@ -172,17 +191,63 @@ module JsxRosetta
|
|
|
172
191
|
raise no_component_error(file.program) if candidates.empty?
|
|
173
192
|
|
|
174
193
|
name, function = candidates.first
|
|
175
|
-
module_bindings = capture_module_bindings(file.program, candidates)
|
|
176
|
-
|
|
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)
|
|
177
199
|
end
|
|
178
200
|
|
|
179
201
|
def lower_all_components(file)
|
|
180
202
|
candidates = find_component_functions(file.program)
|
|
181
203
|
raise no_component_error(file.program) if candidates.empty?
|
|
182
204
|
|
|
183
|
-
module_bindings = capture_module_bindings(file.program, candidates)
|
|
184
|
-
|
|
185
|
-
|
|
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])
|
|
186
251
|
end
|
|
187
252
|
end
|
|
188
253
|
|
|
@@ -206,32 +271,300 @@ module JsxRosetta
|
|
|
206
271
|
case stmt.type
|
|
207
272
|
when "VariableDeclaration"
|
|
208
273
|
stmt[:declarations].each { |d| record_module_binding(stmt, d, component_names, bindings) }
|
|
209
|
-
when "
|
|
274
|
+
when "FunctionDeclaration"
|
|
275
|
+
record_module_function_binding(stmt, component_names, bindings)
|
|
276
|
+
when "ExportNamedDeclaration", "ExportDefaultDeclaration"
|
|
210
277
|
decl = stmt[:declaration]
|
|
211
278
|
walk_module_binding(decl, component_names, bindings) if decl.is_a?(AST::Node)
|
|
212
279
|
end
|
|
213
280
|
end
|
|
214
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
|
+
|
|
215
297
|
def record_module_binding(stmt, declarator, component_names, bindings)
|
|
216
298
|
init = declarator[:init]
|
|
217
299
|
return unless init.is_a?(AST::Node)
|
|
218
300
|
|
|
219
|
-
# Component declarators (`const Foo = () => ...`) are handled by
|
|
220
|
-
# the component pipeline; skip them here so the source doesn't
|
|
221
|
-
# show up twice.
|
|
222
|
-
return if %w[ArrowFunctionExpression FunctionExpression].include?(init.type) &&
|
|
223
|
-
component_names.include?(declarator[:id]&.[](:name))
|
|
224
|
-
|
|
225
301
|
name = declarator[:id]&.[](:name)
|
|
226
302
|
return unless name
|
|
227
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
|
+
|
|
228
329
|
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
|
|
229
330
|
end
|
|
230
331
|
|
|
231
|
-
|
|
232
|
-
|
|
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"
|
|
233
406
|
|
|
234
|
-
|
|
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
|
|
510
|
+
|
|
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
|
|
235
568
|
end
|
|
236
569
|
|
|
237
570
|
private
|
|
@@ -317,10 +650,29 @@ module JsxRosetta
|
|
|
317
650
|
when "FunctionDeclaration" then [[declaration[:id]&.[](:name), declaration]]
|
|
318
651
|
when "VariableDeclaration" then extract_arrow_components(declaration)
|
|
319
652
|
when "ClassDeclaration" then extract_class_component(declaration)
|
|
653
|
+
when "CallExpression" then extract_hoc_default_export(declaration)
|
|
320
654
|
else []
|
|
321
655
|
end
|
|
322
656
|
end
|
|
323
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
|
+
|
|
324
676
|
# Recognize a class component by the presence of a `render()` method.
|
|
325
677
|
# We don't require `extends React.Component` because TypeScript codebases
|
|
326
678
|
# often declare the parent via an `extends` of a typed alias. The render
|
|
@@ -447,20 +799,86 @@ module JsxRosetta
|
|
|
447
799
|
variable_declaration[:declarations].filter_map do |declarator|
|
|
448
800
|
init = declarator[:init]
|
|
449
801
|
next nil unless init.is_a?(AST::Node)
|
|
450
|
-
next nil unless %w[ArrowFunctionExpression FunctionExpression].include?(init.type)
|
|
451
802
|
|
|
452
803
|
name = declarator[:id]&.[](:name)
|
|
453
|
-
|
|
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
|
|
454
812
|
end
|
|
455
813
|
end
|
|
456
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
|
+
|
|
457
870
|
def lower_component(name, function)
|
|
458
871
|
if name.nil? || name.empty?
|
|
459
872
|
raise lowering_error("anonymous component functions are not supported", node: function)
|
|
460
873
|
end
|
|
461
874
|
|
|
462
875
|
reset_per_component_state!
|
|
463
|
-
|
|
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)
|
|
464
882
|
@prop_names = props.map(&:name)
|
|
465
883
|
absorb_class_metadata(name, function, props) if function.of_type?("ClassMethod", "MethodDefinition")
|
|
466
884
|
|
|
@@ -479,13 +897,22 @@ module JsxRosetta
|
|
|
479
897
|
local_bindings: @local_bindings,
|
|
480
898
|
local_binding_names: (@local_binding_names + unconsumed_arrow_names).uniq,
|
|
481
899
|
module_bindings: [],
|
|
900
|
+
module_imports: [],
|
|
482
901
|
stimulus_methods: @stimulus_methods,
|
|
483
902
|
react_hooks: @react_hooks,
|
|
484
903
|
render_methods: @render_methods,
|
|
485
|
-
mode: mode
|
|
904
|
+
mode: mode,
|
|
905
|
+
server_data_source: nil,
|
|
906
|
+
hoc_wrappers: hoc_wrappers
|
|
486
907
|
)
|
|
487
908
|
end
|
|
488
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
|
+
|
|
489
916
|
# A "data factory" function — common for AG-Grid / antd column
|
|
490
917
|
# descriptor modules — is a function whose body just returns an array
|
|
491
918
|
# of object literals (`export const createColumns = (...) => [{...},
|
|
@@ -1116,14 +1543,82 @@ module JsxRosetta
|
|
|
1116
1543
|
# Gap J: `const { Content } = Layout; <Content/>` should resolve
|
|
1117
1544
|
# to `Layout::Content`, not a bare `ContentComponent`.
|
|
1118
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
|
|
1119
1552
|
elsif html_element?(tag)
|
|
1120
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
|
+
)
|
|
1121
1563
|
else
|
|
1122
1564
|
ComponentInvocation.new(name: tag, props: attributes, children: children)
|
|
1123
1565
|
end
|
|
1124
1566
|
end
|
|
1125
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
|
+
|
|
1126
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
|
+
|
|
1127
1622
|
Conditional.new(
|
|
1128
1623
|
test: Interpolation.new(expression: source_of(poly[:test])),
|
|
1129
1624
|
consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
|
|
@@ -1140,6 +1635,41 @@ module JsxRosetta
|
|
|
1140
1635
|
end
|
|
1141
1636
|
end
|
|
1142
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
|
+
|
|
1143
1673
|
def lower_jsx_fragment(fragment)
|
|
1144
1674
|
Fragment.new(children: lower_children(fragment.jsx_children))
|
|
1145
1675
|
end
|
|
@@ -1489,10 +2019,116 @@ module JsxRosetta
|
|
|
1489
2019
|
if value.is_a?(AST::JSXExpressionContainer)
|
|
1490
2020
|
decomposed = try_lower_class_helper(value.expression)
|
|
1491
2021
|
return decomposed if decomposed
|
|
2022
|
+
|
|
2023
|
+
cva_call = try_lower_cva_call_site(value.expression)
|
|
2024
|
+
return cva_call if cva_call
|
|
1492
2025
|
end
|
|
1493
2026
|
StyleBinding.new(expression: style_binding_expression(value))
|
|
1494
2027
|
end
|
|
1495
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
|
+
|
|
1496
2132
|
def try_lower_class_helper(expression)
|
|
1497
2133
|
return nil unless AST::Node.matches?(expression, "CallExpression")
|
|
1498
2134
|
|
|
@@ -1573,9 +2209,18 @@ module JsxRosetta
|
|
|
1573
2209
|
def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
|
|
1574
2210
|
base = name_hint || default_stimulus_method_name(attr_name)
|
|
1575
2211
|
method_name = stimulus_method_name(base)
|
|
1576
|
-
|
|
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 }
|
|
1577
2218
|
@stimulus_methods << StimulusMethod.new(
|
|
1578
|
-
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"
|
|
1579
2224
|
)
|
|
1580
2225
|
@local_arrows.delete(name_hint) if name_hint
|
|
1581
2226
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
@@ -1589,7 +2234,11 @@ module JsxRosetta
|
|
|
1589
2234
|
method_name = stimulus_method_name(identifier_name)
|
|
1590
2235
|
body_source = "// originally bound to: #{identifier_name}"
|
|
1591
2236
|
@stimulus_methods << StimulusMethod.new(
|
|
1592
|
-
name: method_name,
|
|
2237
|
+
name: method_name,
|
|
2238
|
+
body_source: body_source,
|
|
2239
|
+
original_name: identifier_name,
|
|
2240
|
+
params: [],
|
|
2241
|
+
body_is_block: false
|
|
1593
2242
|
)
|
|
1594
2243
|
StimulusBinding.new(event: event, method_name: method_name)
|
|
1595
2244
|
end
|
|
@@ -1620,18 +2269,39 @@ module JsxRosetta
|
|
|
1620
2269
|
# Recursively lower an arbitrary JS expression into structured IR
|
|
1621
2270
|
# when possible: ObjectExpression → ObjectLiteral, ArrayExpression
|
|
1622
2271
|
# → ArrayLiteral, ArrowFunctionExpression / FunctionExpression →
|
|
1623
|
-
# Lambda
|
|
1624
|
-
#
|
|
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.
|
|
1625
2276
|
def lower_value_expression(expression)
|
|
1626
2277
|
case expression.type
|
|
1627
2278
|
when "ObjectExpression" then lower_object_literal(expression)
|
|
1628
2279
|
when "ArrayExpression" then lower_array_literal(expression)
|
|
1629
2280
|
when "ArrowFunctionExpression", "FunctionExpression" then lower_value_lambda(expression)
|
|
2281
|
+
when "JSXElement", "JSXFragment" then lower_jsx_value(expression)
|
|
1630
2282
|
else
|
|
1631
2283
|
Interpolation.new(expression: source_of(expression))
|
|
1632
2284
|
end
|
|
1633
2285
|
end
|
|
1634
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
|
+
|
|
1635
2305
|
def lower_object_literal(object_expression)
|
|
1636
2306
|
properties = []
|
|
1637
2307
|
object_expression[:properties].each do |prop|
|
|
@@ -1673,10 +2343,16 @@ module JsxRosetta
|
|
|
1673
2343
|
return Interpolation.new(expression: source_of(arrow))
|
|
1674
2344
|
end
|
|
1675
2345
|
|
|
2346
|
+
param_names = params.map { |p| p[:name] }
|
|
1676
2347
|
body = lower_lambda_body(arrow[:body])
|
|
1677
|
-
|
|
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
|
|
1678
2354
|
|
|
1679
|
-
|
|
2355
|
+
EventHandler.new(params: param_names, body_source: source_of(arrow[:body]))
|
|
1680
2356
|
end
|
|
1681
2357
|
|
|
1682
2358
|
def lower_lambda_body(body)
|
|
@@ -1707,6 +2383,19 @@ module JsxRosetta
|
|
|
1707
2383
|
first == first.downcase
|
|
1708
2384
|
end
|
|
1709
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
|
+
|
|
1710
2399
|
def source_of(node)
|
|
1711
2400
|
@source[node.start_pos...node.end_pos]
|
|
1712
2401
|
end
|