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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../ast/inflector"
|
|
4
4
|
require_relative "../ir/types"
|
|
5
|
+
require_relative "../pages_routing"
|
|
5
6
|
require_relative "base"
|
|
6
7
|
require_relative "view_component/expression_translator"
|
|
7
8
|
|
|
@@ -69,19 +70,74 @@ module JsxRosetta
|
|
|
69
70
|
# ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
|
|
70
71
|
EventDescriptor = Data.define(:kind, :body)
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
RAILS_VIEW_BASE_CLASS = "Views::Base"
|
|
74
|
+
|
|
75
|
+
# Per-tag map of which attribute names carry a URL that the href
|
|
76
|
+
# rewriter should attempt to rewrite. Most link tags use `href`/`to`;
|
|
77
|
+
# `<form action="...">` uses `action`. Slice C1 added the `form`
|
|
78
|
+
# entry — non-GET forms are skipped at format_attributes time
|
|
79
|
+
# before they reach try_rewrite_href.
|
|
80
|
+
LINK_TAG_ATTRS = {
|
|
81
|
+
"a" => %w[href].freeze,
|
|
82
|
+
"Link" => %w[href to].freeze,
|
|
83
|
+
"NavLink" => %w[href to].freeze,
|
|
84
|
+
"RouterLink" => %w[href to].freeze,
|
|
85
|
+
"form" => %w[action].freeze
|
|
86
|
+
}.freeze
|
|
87
|
+
# Matches `router.push("…")` / `router.push('…')` / `router.push(\`…\`)`
|
|
88
|
+
# in verbatim hook source, capturing the quoted argument (including the
|
|
89
|
+
# surrounding quote/backtick). A3: only fires for sync hook bodies —
|
|
90
|
+
# event-handler bodies live in IR::EventHandler / IR::StimulusMethod
|
|
91
|
+
# sources, not in react_hooks, so they're naturally out of scope.
|
|
92
|
+
ROUTER_PUSH_PATTERN = /router\.push\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)\s*[,)]/
|
|
93
|
+
|
|
94
|
+
# Per-wrapper TODO header text. Each HOC that lowering peeled off the
|
|
95
|
+
# source (recorded on IR::Component#hoc_wrappers) gets one short line
|
|
96
|
+
# above the class explaining the Rails analog. Falls back to a
|
|
97
|
+
# generic line for wrappers not in this map.
|
|
98
|
+
HOC_WRAPPER_TODO_LINES = {
|
|
99
|
+
"memo" => "TODO: original component was wrapped in memo(...). React's memo memoizes " \
|
|
100
|
+
"by shallow prop equality. Rails analog: fragment caching (`cache @model do …`) " \
|
|
101
|
+
"when the view is expensive.",
|
|
102
|
+
"forwardRef" => "TODO: original component was wrapped in forwardRef(...). The second " \
|
|
103
|
+
"arg (`ref`) was dropped — Rails has no view-side ref-forwarding analog; " \
|
|
104
|
+
"use Stimulus targets / DOM ids if the host needs a handle.",
|
|
105
|
+
"observer" => "TODO: original component was wrapped in observer(...) (mobx). React's " \
|
|
106
|
+
"observer auto-subscribes to observable state. Rails analog: the controller " \
|
|
107
|
+
"loads data and sets @ivars; reactivity moves to Turbo Streams / Hotwire.",
|
|
108
|
+
"connect" => "TODO: original component was wrapped in connect(...)(X) (redux). Props " \
|
|
109
|
+
"injected by mapState/mapDispatch in the source — in Rails, expect those as " \
|
|
110
|
+
"controller-passed instance variables instead.",
|
|
111
|
+
"withRouter" => "TODO: original component was wrapped in withRouter(...) (React Router). " \
|
|
112
|
+
"Injected router props (location, history, match) map to Rails: request.path, " \
|
|
113
|
+
"redirect_to / form actions, and params.",
|
|
114
|
+
"withTranslation" => "TODO: original component was wrapped in withTranslation()(X) (i18n). " \
|
|
115
|
+
"Injected `t` translator function maps to Rails I18n: `I18n.t(\"…\")` " \
|
|
116
|
+
"or the `t` view helper."
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
def initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil)
|
|
73
120
|
super()
|
|
74
121
|
raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
|
|
122
|
+
if rails_view && (suffix || namespace)
|
|
123
|
+
raise ArgumentError, "Phlex backend: rails_view: cannot be combined with suffix: or namespace:"
|
|
124
|
+
end
|
|
75
125
|
|
|
76
126
|
@suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
|
|
77
127
|
@namespace = namespace
|
|
128
|
+
@rails_view = rails_view
|
|
129
|
+
@href_rewriter = route_table && PagesRouting::HrefRewriter.new(route_table)
|
|
78
130
|
end
|
|
79
131
|
|
|
80
|
-
def emit(component)
|
|
132
|
+
def emit(component, source_filename: nil)
|
|
133
|
+
@current_component = component
|
|
81
134
|
translator = build_translator(component)
|
|
82
135
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
83
136
|
@lambda_methods = []
|
|
84
137
|
@lambda_method_counts = {}
|
|
138
|
+
@event_handler_methods = []
|
|
139
|
+
@emit_module_prefix = first_emit_for_module_bindings?(component)
|
|
140
|
+
@source_filename = source_filename
|
|
85
141
|
|
|
86
142
|
files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
|
|
87
143
|
if component.stimulus_methods.any?
|
|
@@ -90,7 +146,29 @@ module JsxRosetta
|
|
|
90
146
|
contents: render_stimulus_controller_js(component)
|
|
91
147
|
)
|
|
92
148
|
end
|
|
149
|
+
files.concat(lucide_icon_files(component))
|
|
93
150
|
files
|
|
151
|
+
ensure
|
|
152
|
+
# Drop the per-emit IR reference so a long-running emitter
|
|
153
|
+
# instance doesn't pin the entire component tree until the
|
|
154
|
+
# next emit() call.
|
|
155
|
+
@current_component = nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# When a source file lowers to multiple sibling components, lower_all
|
|
159
|
+
# attaches the *same* module_bindings array to every sibling. Emitting
|
|
160
|
+
# the constants TODO block on each one duplicates 40-line GraphQL
|
|
161
|
+
# blocks across every emitted .rb file. Track the array identities
|
|
162
|
+
# we've seen and only emit the prefix the first time.
|
|
163
|
+
def first_emit_for_module_bindings?(component)
|
|
164
|
+
return true if component.module_bindings.empty?
|
|
165
|
+
|
|
166
|
+
@seen_module_bindings ||= Set.new
|
|
167
|
+
key = component.module_bindings.object_id
|
|
168
|
+
return false if @seen_module_bindings.include?(key)
|
|
169
|
+
|
|
170
|
+
@seen_module_bindings << key
|
|
171
|
+
true
|
|
94
172
|
end
|
|
95
173
|
|
|
96
174
|
def build_translator(component)
|
|
@@ -99,10 +177,15 @@ module JsxRosetta
|
|
|
99
177
|
prop_aliases = component.props.each_with_object({}) do |prop, hash|
|
|
100
178
|
hash[prop.alias_name] = prop.name if prop.alias_name
|
|
101
179
|
end
|
|
180
|
+
# `imported_names` covers both top-level `import` declarations AND
|
|
181
|
+
# top-level helper bindings (`function onError(){}`, `const FOO = …`).
|
|
182
|
+
# They behave identically at the use site — the translator bails out
|
|
183
|
+
# to `nil` rather than emitting a bare snake_case ref that NameErrors.
|
|
102
184
|
ViewComponent::ExpressionTranslator.new(
|
|
103
185
|
prop_names: prop_names,
|
|
104
186
|
local_binding_names: component.local_binding_names,
|
|
105
|
-
prop_aliases: prop_aliases
|
|
187
|
+
prop_aliases: prop_aliases,
|
|
188
|
+
imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
|
|
106
189
|
)
|
|
107
190
|
end
|
|
108
191
|
|
|
@@ -151,42 +234,234 @@ module JsxRosetta
|
|
|
151
234
|
# letter when forming the class name. Pure-PascalCase names pass
|
|
152
235
|
# through unchanged.
|
|
153
236
|
def class_name(component)
|
|
237
|
+
return rails_view_class_name if @rails_view
|
|
238
|
+
|
|
154
239
|
base = "#{component.name[0].upcase}#{component.name[1..]}"
|
|
155
|
-
|
|
240
|
+
suffix = effective_suffix_for(base, source_filename: @source_filename)
|
|
241
|
+
suffix ? "#{base}#{suffix}" : base
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def rails_view_class_name
|
|
245
|
+
return rails_layout_class_name if @rails_view.kind == :layout
|
|
246
|
+
|
|
247
|
+
namespace_parts = (@rails_view.namespace || []).map { |ns| AST::Inflector.upper_camelize(ns) }
|
|
248
|
+
controller = AST::Inflector.upper_camelize(@rails_view.controller)
|
|
249
|
+
action = AST::Inflector.upper_camelize(@rails_view.action)
|
|
250
|
+
(["Views"] + namespace_parts + [controller, action]).join("::")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Layouts land under Views::Layouts:: regardless of source path —
|
|
254
|
+
# `_app.tsx` → Views::Layouts::Application. There's only ever one
|
|
255
|
+
# "controller" for layouts (`layouts`), so the controller segment
|
|
256
|
+
# is collapsed into the namespace.
|
|
257
|
+
def rails_layout_class_name
|
|
258
|
+
action = AST::Inflector.upper_camelize(@rails_view.action)
|
|
259
|
+
"Views::Layouts::#{action}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parent_class
|
|
263
|
+
@rails_view ? RAILS_VIEW_BASE_CLASS : PHLEX_BASE_CLASS
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Pick the suffix to append to a class name, applying two rules in
|
|
267
|
+
# order:
|
|
268
|
+
#
|
|
269
|
+
# 1. **Page detection.** Source name ends in `Page` OR the source
|
|
270
|
+
# file path contains `/pages/` (Next.js / Nuxt convention) →
|
|
271
|
+
# use the literal `Page` suffix, regardless of the configured
|
|
272
|
+
# `@suffix`. Lets the gem keep `<HomePage>` / `pages/home.tsx`
|
|
273
|
+
# landing as `HomePage` / `home_page.rb` even when the user
|
|
274
|
+
# passes `--phlex-suffix=Component` for the rest of the codebase.
|
|
275
|
+
# 2. **No double-suffix.** When the name already ends with the
|
|
276
|
+
# chosen suffix (e.g. source `HomePage` with the `Page` suffix,
|
|
277
|
+
# or source `FooComponent` with the `Component` suffix), skip
|
|
278
|
+
# the append. Returns nil so callers don't concatenate.
|
|
279
|
+
#
|
|
280
|
+
# `source_filename` is the absolute or repo-relative path of the
|
|
281
|
+
# JSX source; nil when callers translate raw source strings
|
|
282
|
+
# without filename context (then only the name-based signal fires).
|
|
283
|
+
def effective_suffix_for(name, source_filename: nil)
|
|
284
|
+
suffix = page?(name, source_filename) ? "Page" : @suffix
|
|
285
|
+
return nil unless suffix
|
|
286
|
+
return nil if name.end_with?(suffix)
|
|
287
|
+
|
|
288
|
+
suffix
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def page?(name, source_filename)
|
|
292
|
+
return true if name.end_with?("Page")
|
|
293
|
+
return false unless source_filename
|
|
294
|
+
|
|
295
|
+
source_filename.include?("/pages/")
|
|
156
296
|
end
|
|
157
297
|
|
|
158
298
|
def ruby_path(component)
|
|
299
|
+
return "#{rails_view_dir}/#{@rails_view.action}.rb" if @rails_view
|
|
300
|
+
|
|
159
301
|
"#{AST::Inflector.underscore(class_name(component))}.rb"
|
|
160
302
|
end
|
|
161
303
|
|
|
162
304
|
def stimulus_path(component)
|
|
305
|
+
return "#{rails_view_dir}/#{@rails_view.action}_controller.js" if @rails_view
|
|
306
|
+
|
|
163
307
|
"#{AST::Inflector.underscore(class_name(component))}_controller.js"
|
|
164
308
|
end
|
|
165
309
|
|
|
310
|
+
def rails_view_dir
|
|
311
|
+
((@rails_view.namespace || []) + [@rails_view.controller]).join("/")
|
|
312
|
+
end
|
|
313
|
+
|
|
166
314
|
def stimulus_identifier(component)
|
|
167
315
|
AST::Inflector.underscore(component.name).tr("_", "-")
|
|
168
316
|
end
|
|
169
317
|
|
|
170
318
|
def render_ruby_class(component, translator)
|
|
171
319
|
class_body = render_class_body(component, translator)
|
|
172
|
-
prefix =
|
|
320
|
+
prefix = "#{render_hoc_wrappers_prefix(component)}" \
|
|
321
|
+
"#{render_server_data_source_prefix(component)}" \
|
|
322
|
+
"#{render_module_bindings_prefix(component)}"
|
|
173
323
|
wrap_in_namespace("#{prefix}#{class_body}")
|
|
174
324
|
end
|
|
175
325
|
|
|
326
|
+
def render_hoc_wrappers_prefix(component)
|
|
327
|
+
return "" if component.hoc_wrappers.empty?
|
|
328
|
+
|
|
329
|
+
lines = component.hoc_wrappers.map do |wrapper|
|
|
330
|
+
"# #{HOC_WRAPPER_TODO_LINES[wrapper] ||
|
|
331
|
+
"TODO: original component was wrapped in #{wrapper}(...). " \
|
|
332
|
+
"Verify the Rails-side equivalent (or absence) and adjust before shipping."}"
|
|
333
|
+
end
|
|
334
|
+
"#{lines.join("\n")}\n"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# When the source file exports `getServerSideProps` /
|
|
338
|
+
# `getStaticProps`, emit a TODO block above the class with the
|
|
339
|
+
# body verbatim. The block names the matching Rails controller
|
|
340
|
+
# action when `@rails_view` is set; otherwise it points at "the
|
|
341
|
+
# host controller" generically.
|
|
342
|
+
def render_server_data_source_prefix(component)
|
|
343
|
+
return "" unless component.server_data_source
|
|
344
|
+
|
|
345
|
+
sds = component.server_data_source
|
|
346
|
+
target = if @rails_view
|
|
347
|
+
"#{AST::Inflector.upper_camelize(@rails_view.controller)}Controller##{@rails_view.action}"
|
|
348
|
+
else
|
|
349
|
+
"the host controller action"
|
|
350
|
+
end
|
|
351
|
+
body_lines = comment_lines(sds.source)
|
|
352
|
+
lines = [
|
|
353
|
+
"# TODO: port the original Next.js `#{sds.hook_name}` to #{target}:",
|
|
354
|
+
"#",
|
|
355
|
+
*body_lines,
|
|
356
|
+
"#",
|
|
357
|
+
"# In Rails: load the data in the controller (set @ivars), and read",
|
|
358
|
+
"# them in this view via the standard props plumbing."
|
|
359
|
+
]
|
|
360
|
+
"#{lines.join("\n")}\n"
|
|
361
|
+
end
|
|
362
|
+
|
|
176
363
|
# Top-level `const`/`let` declarations outside the component
|
|
177
|
-
# function — captured at lowering time
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
# a Ruby constant or moves it to a Rails initializer.
|
|
364
|
+
# function — captured at lowering time. Cva-shaped bindings get
|
|
365
|
+
# emitted as real Ruby constants (FOO_BASE_CLASS, etc.); generic
|
|
366
|
+
# local bindings still surface as a TODO comment block.
|
|
181
367
|
def render_module_bindings_prefix(component)
|
|
182
368
|
return "" if component.module_bindings.empty?
|
|
183
369
|
|
|
370
|
+
groups = component.module_bindings.group_by { |b| binding_group(b) }
|
|
371
|
+
cva_bindings = groups[:cva] || []
|
|
372
|
+
module_constants = groups[:module_constant] || []
|
|
373
|
+
other_bindings = groups[:other] || []
|
|
374
|
+
sections = []
|
|
375
|
+
# cva constants are *referenced* by every sibling's class body via
|
|
376
|
+
# FOO_BASE_CLASS / FOO_VARIANT_CLASSES — they have to land in every
|
|
377
|
+
# sibling's file or non-first siblings NameError at render. The
|
|
378
|
+
# non-cva TODO block is informational only, so it suppresses on
|
|
379
|
+
# later siblings to avoid duplicating 40-line GraphQL blocks.
|
|
380
|
+
sections << render_cva_constants(cva_bindings) unless cva_bindings.empty?
|
|
381
|
+
sections << render_module_constants(module_constants) unless module_constants.empty?
|
|
382
|
+
emit_todo_block = @emit_module_prefix && !other_bindings.empty?
|
|
383
|
+
sections << render_module_local_bindings_todo(other_bindings) if emit_todo_block
|
|
384
|
+
sections.compact.join("\n")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def binding_group(binding)
|
|
388
|
+
case binding
|
|
389
|
+
when IR::CvaBinding then :cva
|
|
390
|
+
when IR::ModuleConstant then :module_constant
|
|
391
|
+
else :other
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Emit each literal-shaped module-level const as a real Ruby constant.
|
|
396
|
+
# Hash and array values are emitted via Ruby's `inspect` and frozen so
|
|
397
|
+
# accidental mutation surfaces immediately. Scalars (string / number /
|
|
398
|
+
# bool / nil) skip the `.freeze` since they're already immutable in
|
|
399
|
+
# Ruby — keeps the output terse for the common `const TITLE = "X"` case.
|
|
400
|
+
def render_module_constants(constants)
|
|
401
|
+
lines = constants.map do |constant|
|
|
402
|
+
literal = constant.value.inspect
|
|
403
|
+
literal += ".freeze" if constant.value.is_a?(Hash) || constant.value.is_a?(Array) ||
|
|
404
|
+
constant.value.is_a?(String)
|
|
405
|
+
"#{constant.constant_name} = #{literal}"
|
|
406
|
+
end
|
|
407
|
+
"#{lines.join("\n")}\n"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Emit one cva binding as a triplet of Ruby constants —
|
|
411
|
+
# FOO_BASE_CLASS, FOO_VARIANT_CLASSES, FOO_DEFAULT_VARIANTS — that
|
|
412
|
+
# the call-site interpolation in the class body references.
|
|
413
|
+
def render_cva_constants(cva_bindings)
|
|
414
|
+
lines = []
|
|
415
|
+
cva_bindings.each do |cva|
|
|
416
|
+
prefix = cva_constant_prefix(cva.name)
|
|
417
|
+
lines << "#{prefix}_BASE_CLASS = #{cva.base_class.inspect}"
|
|
418
|
+
lines << "#{prefix}_VARIANT_CLASSES = #{format_variants_literal(cva.variants)}.freeze"
|
|
419
|
+
unless cva.default_variants.empty?
|
|
420
|
+
lines << "#{prefix}_DEFAULT_VARIANTS = #{cva.default_variants.inspect}.freeze"
|
|
421
|
+
end
|
|
422
|
+
if cva.compound_source
|
|
423
|
+
lines << "# TODO: compoundVariants from #{cva.name} aren't translated — port by hand:"
|
|
424
|
+
lines.concat(comment_lines(cva.compound_source))
|
|
425
|
+
end
|
|
426
|
+
lines << ""
|
|
427
|
+
end
|
|
428
|
+
"#{lines.join("\n").rstrip}\n"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Non-cva module bindings — the original Gap E pre-class TODO block.
|
|
432
|
+
# Distinct from the body-level `render_local_bindings_todo` further
|
|
433
|
+
# below in this file.
|
|
434
|
+
def render_module_local_bindings_todo(bindings)
|
|
184
435
|
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
185
436
|
"or move to a Rails initializer:"]
|
|
186
|
-
|
|
437
|
+
bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
187
438
|
"#{lines.join("\n")}\n"
|
|
188
439
|
end
|
|
189
440
|
|
|
441
|
+
# buttonVariants → BUTTON, alertVariants → ALERT. The degenerate
|
|
442
|
+
# name `"Variants"` would strip to `""` (a Ruby SyntaxError when
|
|
443
|
+
# used as a `_BASE_CLASS` prefix) — fall back to the raw name in
|
|
444
|
+
# that case. Two cva bindings whose names collapse to the same
|
|
445
|
+
# prefix (`fooVariant` and `fooVariants` → `FOO`) keep both forms
|
|
446
|
+
# disambiguated by upcasing the unstripped name as the fallback.
|
|
447
|
+
def cva_constant_prefix(cva_name)
|
|
448
|
+
stripped = cva_name.sub(/Variants?\z/, "")
|
|
449
|
+
base = stripped.empty? ? cva_name : stripped
|
|
450
|
+
AST::Inflector.underscore(base).upcase
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def format_variants_literal(variants)
|
|
454
|
+
return "{}" if variants.empty?
|
|
455
|
+
|
|
456
|
+
lines = ["{"]
|
|
457
|
+
variants.each do |axis, options|
|
|
458
|
+
opts_pairs = options.map { |k, v| "#{k.inspect} => #{v.inspect}" }.join(", ")
|
|
459
|
+
lines << " #{axis.inspect} => { #{opts_pairs} },"
|
|
460
|
+
end
|
|
461
|
+
lines << "}"
|
|
462
|
+
lines.join("\n")
|
|
463
|
+
end
|
|
464
|
+
|
|
190
465
|
def wrap_in_namespace(body)
|
|
191
466
|
return "# frozen_string_literal: true\n\n#{body}" unless @namespace
|
|
192
467
|
|
|
@@ -213,7 +488,7 @@ module JsxRosetta
|
|
|
213
488
|
# body is intentionally minimal so it doesn't drift from the source
|
|
214
489
|
# over time; a richer comment belongs in the host repo's review.
|
|
215
490
|
"# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
|
|
216
|
-
"class #{cls} < #{
|
|
491
|
+
"class #{cls} < #{parent_class}\n#{sections}\nend\n"
|
|
217
492
|
end
|
|
218
493
|
|
|
219
494
|
# For data-factory components (`export const createColumns = (args)
|
|
@@ -252,12 +527,32 @@ module JsxRosetta
|
|
|
252
527
|
lambda_methods = (@lambda_methods || []).map do |entry|
|
|
253
528
|
render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
|
|
254
529
|
end
|
|
255
|
-
|
|
530
|
+
event_handlers = (@event_handler_methods || []).map do |entry|
|
|
531
|
+
render_event_handler_method_definition(entry[:method_name], entry[:handler], entry[:attr_name])
|
|
532
|
+
end
|
|
533
|
+
all = render_methods + lambda_methods + event_handlers
|
|
256
534
|
return nil if all.empty?
|
|
257
535
|
|
|
258
536
|
" private\n\n#{all.join("\n\n")}"
|
|
259
537
|
end
|
|
260
538
|
|
|
539
|
+
# Emit one EventHandler as a stub method on the class. The JS body
|
|
540
|
+
# is preserved verbatim as a TODO comment; the method itself is a
|
|
541
|
+
# no-op so the file loads and the receiving component sees a real
|
|
542
|
+
# `Method` object via `method(:name)`. Parameter names snake_case
|
|
543
|
+
# from JS conventions to Ruby identifiers.
|
|
544
|
+
def render_event_handler_method_definition(method_name, handler, attr_name)
|
|
545
|
+
snake_params = handler.params.map { |p| AST::Inflector.underscore(p) }
|
|
546
|
+
signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
|
|
547
|
+
body_lines = comment_lines(handler.body_source).map { |l| " #{l}" }
|
|
548
|
+
[
|
|
549
|
+
" def #{signature}",
|
|
550
|
+
" # TODO: translate the original JSX `#{attr_name}` handler:",
|
|
551
|
+
*body_lines,
|
|
552
|
+
" end"
|
|
553
|
+
].join("\n")
|
|
554
|
+
end
|
|
555
|
+
|
|
261
556
|
# Emit one RenderMethod as a private method on the class. Params are
|
|
262
557
|
# pushed into the translator scope so identifier references inside
|
|
263
558
|
# the body resolve to method-local arguments.
|
|
@@ -317,6 +612,15 @@ module JsxRosetta
|
|
|
317
612
|
end
|
|
318
613
|
|
|
319
614
|
def ruby_default_for(prop, translator)
|
|
615
|
+
# Use the cva defaultVariants entry as the initializer default when
|
|
616
|
+
# the prop name matches a cva axis and the JSX didn't already
|
|
617
|
+
# specify its own default. So `variant: 'default'` flows from the
|
|
618
|
+
# cva binding's defaultVariants, even though the React function
|
|
619
|
+
# signature took it as an undefaulted prop.
|
|
620
|
+
if prop.default.nil? && (cva_default = cva_axis_default_for(prop.name))
|
|
621
|
+
return cva_default.inspect
|
|
622
|
+
end
|
|
623
|
+
|
|
320
624
|
return "nil" if prop.default.nil?
|
|
321
625
|
|
|
322
626
|
case prop.default
|
|
@@ -336,41 +640,105 @@ module JsxRosetta
|
|
|
336
640
|
end
|
|
337
641
|
end
|
|
338
642
|
|
|
643
|
+
# Look up `prop_name` (e.g. "variant") across every CvaBinding on the
|
|
644
|
+
# current component. Returns the cva default value (e.g. "default") or
|
|
645
|
+
# nil when no cva binding declares that axis with a default.
|
|
646
|
+
def cva_axis_default_for(prop_name)
|
|
647
|
+
return nil unless @current_component
|
|
648
|
+
|
|
649
|
+
@current_component.module_bindings.each do |b|
|
|
650
|
+
next unless b.is_a?(IR::CvaBinding)
|
|
651
|
+
next unless b.default_variants.key?(prop_name)
|
|
652
|
+
|
|
653
|
+
return b.default_variants[prop_name]
|
|
654
|
+
end
|
|
655
|
+
nil
|
|
656
|
+
end
|
|
657
|
+
|
|
339
658
|
def render_view_template(component, translator)
|
|
340
659
|
body = render_template_body(component, translator)
|
|
341
|
-
prefix = render_template_prefix(component)
|
|
660
|
+
prefix = render_template_prefix(component, translator)
|
|
342
661
|
body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
|
|
343
662
|
" def view_template\n#{body_with_prefix}\n end"
|
|
344
663
|
end
|
|
345
664
|
|
|
346
|
-
def render_template_prefix(component)
|
|
665
|
+
def render_template_prefix(component, translator)
|
|
347
666
|
lines = []
|
|
348
|
-
lines.concat(render_react_hooks_todo(component.react_hooks))
|
|
667
|
+
lines.concat(render_react_hooks_todo(component.react_hooks, translator))
|
|
349
668
|
lines.concat(render_local_bindings_todo(component.local_bindings))
|
|
350
669
|
return "" if lines.empty?
|
|
351
670
|
|
|
352
671
|
"#{lines.map { |l| " #{l}" }.join("\n")}\n"
|
|
353
672
|
end
|
|
354
673
|
|
|
355
|
-
def render_react_hooks_todo(hooks)
|
|
674
|
+
def render_react_hooks_todo(hooks, translator)
|
|
356
675
|
return [] if hooks.empty?
|
|
357
676
|
|
|
358
677
|
# Preserve the source order of the first occurrence per library so
|
|
359
678
|
# the React block (typical) lands before Apollo/Next.js blocks when
|
|
360
679
|
# all three are present. group_by preserves first-seen order.
|
|
361
|
-
hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls) }
|
|
680
|
+
hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls, translator) }
|
|
362
681
|
end
|
|
363
682
|
|
|
364
|
-
def hook_todo_block_lines(library, calls)
|
|
683
|
+
def hook_todo_block_lines(library, calls, translator)
|
|
365
684
|
header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
|
|
366
685
|
lines = header_lines.map { |line| "# #{line}" }
|
|
367
686
|
calls.each do |call|
|
|
687
|
+
lines.concat(router_push_hint_lines(call.source, translator))
|
|
368
688
|
lines << "# operation: #{call.operation}" if call.operation
|
|
369
689
|
lines.concat(comment_lines(call.source))
|
|
370
690
|
end
|
|
371
691
|
lines
|
|
372
692
|
end
|
|
373
693
|
|
|
694
|
+
# Scan a hook's verbatim source for `router.push("…")` invocations and
|
|
695
|
+
# emit a one-line hint per match. With a route table the hint names
|
|
696
|
+
# the matching URL helper (`redirect_to user_path(@id)`); without one,
|
|
697
|
+
# it suggests adding the helper. See ROUTER_PUSH_PATTERN for the
|
|
698
|
+
# accepted shapes and why event-handler bodies are out of scope.
|
|
699
|
+
def router_push_hint_lines(source, translator)
|
|
700
|
+
source.scan(ROUTER_PUSH_PATTERN).flat_map do |(arg)|
|
|
701
|
+
hint = router_push_hint(arg, translator)
|
|
702
|
+
hint ? ["# #{hint}"] : []
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def router_push_hint(arg, translator)
|
|
707
|
+
if arg.start_with?("`")
|
|
708
|
+
template_router_push_hint(arg, translator)
|
|
709
|
+
else
|
|
710
|
+
path = arg[1..-2]
|
|
711
|
+
literal_router_push_hint(path)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def literal_router_push_hint(path)
|
|
716
|
+
helper = @href_rewriter&.rewrite_literal(path)
|
|
717
|
+
return "→ redirect_to #{helper} (translated from router.push(#{path.inspect}))" if helper
|
|
718
|
+
|
|
719
|
+
"→ consider redirect_to <helper> for router.push(#{path.inspect}) (not in route table)"
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def template_router_push_hint(template_src, translator)
|
|
723
|
+
segments = PagesRouting::HrefRewriter.parse_template_source(template_src)
|
|
724
|
+
return "→ consider redirect_to <helper> for router.push(#{template_src})" unless segments
|
|
725
|
+
|
|
726
|
+
translated = segments.map { |kind, value| kind == :hole ? translate_hole(value, translator) : [kind, value] }
|
|
727
|
+
return "→ consider redirect_to <helper> for router.push(#{template_src})" if translated.any?(&:nil?)
|
|
728
|
+
|
|
729
|
+
helper = @href_rewriter&.rewrite_template(translated)
|
|
730
|
+
return "→ redirect_to #{helper} (translated from router.push(#{template_src}))" if helper
|
|
731
|
+
|
|
732
|
+
"→ consider redirect_to <helper> for router.push(#{template_src}) (path not in route table)"
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def translate_hole(js_source, translator)
|
|
736
|
+
translated = translator.translate(js_source)
|
|
737
|
+
return nil unless translated && translated.ruby != "nil"
|
|
738
|
+
|
|
739
|
+
[:hole, translated.ruby]
|
|
740
|
+
end
|
|
741
|
+
|
|
374
742
|
def render_local_bindings_todo(bindings)
|
|
375
743
|
return [] if bindings.empty?
|
|
376
744
|
|
|
@@ -409,6 +777,7 @@ module JsxRosetta
|
|
|
409
777
|
when IR::Text then render_text(node, indent: indent)
|
|
410
778
|
when IR::Interpolation then render_interpolation(node, translator, indent: indent)
|
|
411
779
|
when IR::Comment then render_comment(node, indent: indent)
|
|
780
|
+
when IR::LayoutYield then "#{" " * indent}yield"
|
|
412
781
|
end
|
|
413
782
|
end
|
|
414
783
|
|
|
@@ -441,36 +810,81 @@ module JsxRosetta
|
|
|
441
810
|
|
|
442
811
|
def render_element(element, translator, indent:)
|
|
443
812
|
todos = []
|
|
444
|
-
attrs_source = format_attributes(element.attributes, translator,
|
|
813
|
+
attrs_source = format_attributes(element.attributes, translator,
|
|
814
|
+
context: :html, tag: element.tag, todos: todos, indent: indent)
|
|
445
815
|
method_call = "#{element.tag}#{attrs_source}"
|
|
816
|
+
body = element_body(element, method_call, translator, indent)
|
|
817
|
+
prepend_attribute_todos(todos, indent, body)
|
|
818
|
+
end
|
|
446
819
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
452
|
-
end
|
|
820
|
+
# Decide whether the HTML tag is blockless, yield-only (auto-yield for
|
|
821
|
+
# self-closing-with-spread), or full do/end (explicit children).
|
|
822
|
+
def element_body(element, method_call, translator, indent)
|
|
823
|
+
return "#{spaces(indent)}#{method_call}" if blockless_element?(element)
|
|
453
824
|
|
|
454
|
-
|
|
825
|
+
if element.children.empty?
|
|
826
|
+
# `<tag {...rest} />` — the rest-spread carries React `children`,
|
|
827
|
+
# but JSX self-closes so there are no explicit IR children. Yield
|
|
828
|
+
# to the Phlex caller's block so `Component.new { ... }` nesting
|
|
829
|
+
# actually renders; guard with `block_given?` so callers who pass
|
|
830
|
+
# no block don't blow up.
|
|
831
|
+
yield_only_block(method_call, indent)
|
|
832
|
+
else
|
|
833
|
+
inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
834
|
+
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def blockless_element?(element)
|
|
839
|
+
return true if VOID_ELEMENTS.include?(element.tag)
|
|
840
|
+
|
|
841
|
+
element.children.empty? && !spreads_children?(element)
|
|
455
842
|
end
|
|
456
843
|
|
|
457
844
|
def render_component_invocation(invocation, translator, indent:)
|
|
458
845
|
todos = []
|
|
459
|
-
kwargs = component_invocation_kwargs(invocation.props, translator,
|
|
846
|
+
kwargs = component_invocation_kwargs(invocation.props, translator,
|
|
847
|
+
todos: todos, indent: indent, tag: invocation.name)
|
|
460
848
|
class_ref = component_class_reference(invocation.name)
|
|
461
849
|
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
850
|
+
body = component_invocation_body(invocation, new_call, translator, indent)
|
|
851
|
+
prepend_attribute_todos(todos, indent, body)
|
|
852
|
+
end
|
|
462
853
|
|
|
854
|
+
def component_invocation_body(invocation, new_call, translator, indent)
|
|
463
855
|
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
464
|
-
|
|
465
|
-
render_with_render_prop(new_call, render_prop, translator, indent)
|
|
466
|
-
elsif invocation.children.empty?
|
|
467
|
-
"#{spaces(indent)}render #{new_call}"
|
|
468
|
-
else
|
|
469
|
-
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
470
|
-
"#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
471
|
-
end
|
|
856
|
+
return render_with_render_prop(new_call, render_prop, translator, indent) if render_prop
|
|
472
857
|
|
|
473
|
-
|
|
858
|
+
call = "render #{new_call}"
|
|
859
|
+
return "#{spaces(indent)}#{call}" if blockless_invocation?(invocation)
|
|
860
|
+
|
|
861
|
+
if invocation.children.empty?
|
|
862
|
+
# `<Component {...rest} />` — same idiom as element_body. The
|
|
863
|
+
# spread carries `children`; yield to the caller's block.
|
|
864
|
+
yield_only_block(call, indent)
|
|
865
|
+
else
|
|
866
|
+
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
867
|
+
"#{spaces(indent)}#{call} do\n#{inner}\n#{spaces(indent)}end"
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def blockless_invocation?(invocation)
|
|
872
|
+
invocation.children.empty? && !spreads_children?(invocation)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def yield_only_block(call, indent)
|
|
876
|
+
outer = spaces(indent)
|
|
877
|
+
inner = spaces(indent + 2)
|
|
878
|
+
"#{outer}#{call} do\n#{inner}yield if block_given?\n#{outer}end"
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Does this Element/ComponentInvocation carry a JSX rest-spread
|
|
882
|
+
# (`{...props}`) that may transport React `children` we can't see in
|
|
883
|
+
# the IR? Used to decide whether a self-closing JSX tag should still
|
|
884
|
+
# yield to the Phlex caller's block.
|
|
885
|
+
def spreads_children?(node)
|
|
886
|
+
attrs = node.respond_to?(:attributes) ? node.attributes : node.props
|
|
887
|
+
attrs.any?(IR::SpreadAttribute)
|
|
474
888
|
end
|
|
475
889
|
|
|
476
890
|
# Emit a render-prop child as a Ruby block on the parent `render` call.
|
|
@@ -497,9 +911,14 @@ module JsxRosetta
|
|
|
497
911
|
# JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
|
|
498
912
|
# `Foo` again under namespace (Ruby's constant lookup finds the
|
|
499
913
|
# peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
|
|
914
|
+
# `<HomePage>` keeps the `Page` suffix without doubling (no
|
|
915
|
+
# `HomePageComponent`) — see effective_suffix_for. The path-based
|
|
916
|
+
# page detection doesn't apply here: an invocation only carries
|
|
917
|
+
# the JSX tag name, not the target file's path.
|
|
500
918
|
def component_class_reference(jsx_tag)
|
|
501
919
|
segments = jsx_tag.split(".")
|
|
502
|
-
|
|
920
|
+
suffix = effective_suffix_for(segments.last)
|
|
921
|
+
segments[-1] = "#{segments.last}#{suffix}" if suffix
|
|
503
922
|
segments.join("::")
|
|
504
923
|
end
|
|
505
924
|
|
|
@@ -508,12 +927,80 @@ module JsxRosetta
|
|
|
508
927
|
end
|
|
509
928
|
|
|
510
929
|
def render_conditional(conditional, translator, indent:)
|
|
930
|
+
if guard_ladder?(conditional, translator)
|
|
931
|
+
return render_guard_ladder_collapse(conditional, translator, indent: indent)
|
|
932
|
+
end
|
|
933
|
+
|
|
511
934
|
lines = []
|
|
512
935
|
emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
|
|
513
936
|
lines << "#{spaces(indent)}end"
|
|
514
937
|
lines.join("\n")
|
|
515
938
|
end
|
|
516
939
|
|
|
940
|
+
# A guard ladder is a chain of `if/elsif` branches whose tests are all
|
|
941
|
+
# untranslatable AND whose consequents are all "render nothing" (the
|
|
942
|
+
# lowered form of `return null` in a guard), terminating in a real
|
|
943
|
+
# else branch. Emitted naively as `if false / elsif false / .../ else
|
|
944
|
+
# <main>`, the else *always* fires — silently inverting the source
|
|
945
|
+
# semantic ("render nothing when any guard hits") into "render main
|
|
946
|
+
# unconditionally." Collapse to a single TODO block + just the else
|
|
947
|
+
# so the reviewer sees what guards used to gate the render, and the
|
|
948
|
+
# main render is at least visible without the misleading `if false`s.
|
|
949
|
+
def guard_ladder?(conditional, translator)
|
|
950
|
+
branches, else_branch = walk_conditional_chain(conditional)
|
|
951
|
+
return false unless else_branch
|
|
952
|
+
return false if branches.empty?
|
|
953
|
+
|
|
954
|
+
branches.all? do |b|
|
|
955
|
+
test_translates_to_untranslatable?(b[:test], translator) && empty_consequent?(b[:consequent])
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def render_guard_ladder_collapse(conditional, translator, indent:)
|
|
960
|
+
branches, else_branch = walk_conditional_chain(conditional)
|
|
961
|
+
lines = ["#{spaces(indent)}# TODO: #{branches.length} render guard(s) couldn't translate; wire up Rails-side:"]
|
|
962
|
+
branches.each do |b|
|
|
963
|
+
compact = b[:test].tr("\n", " ").squeeze(" ")
|
|
964
|
+
lines << "#{spaces(indent)}# #{compact}"
|
|
965
|
+
end
|
|
966
|
+
lines << render_ir_node(else_branch, translator, indent: indent)
|
|
967
|
+
lines.join("\n")
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def walk_conditional_chain(conditional)
|
|
971
|
+
branches = []
|
|
972
|
+
node = conditional
|
|
973
|
+
while node.is_a?(IR::Conditional)
|
|
974
|
+
branches << { test: node.test.expression, consequent: node.consequent }
|
|
975
|
+
node = node.alternate
|
|
976
|
+
end
|
|
977
|
+
[branches, node]
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def test_translates_to_untranslatable?(expression, translator)
|
|
981
|
+
# Use condition-mode translation so bucket-4 hits (hook-tuple
|
|
982
|
+
# destructures, top-level imports) count as translatable. When a
|
|
983
|
+
# guard ladder's tests *do* translate under widening, the ladder
|
|
984
|
+
# collapses to nothing — render_conditional emits the real
|
|
985
|
+
# `if @ivar / elsif @ivar / else <main>` form, which correctly
|
|
986
|
+
# short-circuits to render nothing when any guard fires. Only
|
|
987
|
+
# untranslatable tests (verbatim JS we can't ivar-promote) still
|
|
988
|
+
# trip the collapse-to-TODO path.
|
|
989
|
+
translated = translator.translate_condition(expression)
|
|
990
|
+
translated.nil? || translated.ruby == "nil"
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# An empty consequent is what `return null` (the JS guard idiom) lowers
|
|
994
|
+
# to. Detected as either a literal empty Text node or a Fragment whose
|
|
995
|
+
# children are all empty.
|
|
996
|
+
def empty_consequent?(node)
|
|
997
|
+
case node
|
|
998
|
+
when IR::Text then node.value.to_s.empty?
|
|
999
|
+
when IR::Fragment then node.children.all? { |c| empty_consequent?(c) }
|
|
1000
|
+
else false
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
517
1004
|
# Walk a Conditional and its `alternate` chain, emitting `if` for the
|
|
518
1005
|
# first test, `elsif` for each alternate that is itself a Conditional,
|
|
519
1006
|
# and a final `else` for a non-Conditional alternate. Flattens the
|
|
@@ -522,8 +1009,13 @@ module JsxRosetta
|
|
|
522
1009
|
# this, deeply nested conditional chains explode the file's
|
|
523
1010
|
# indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
|
|
524
1011
|
def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
|
|
525
|
-
test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
526
|
-
|
|
1012
|
+
test_ruby, todo, promoted = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
1013
|
+
if todo
|
|
1014
|
+
lines << "#{spaces(indent)}# TODO: translate condition: #{todo}"
|
|
1015
|
+
elsif promoted && !promoted.empty?
|
|
1016
|
+
lines << "#{spaces(indent)}# TODO: render condition references binding(s) promoted to @ivar — " \
|
|
1017
|
+
"thread as controller-passed prop(s): #{promoted.join(", ")}"
|
|
1018
|
+
end
|
|
527
1019
|
lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
|
|
528
1020
|
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
529
1021
|
|
|
@@ -565,32 +1057,51 @@ module JsxRosetta
|
|
|
565
1057
|
when IR::ArrayLiteral
|
|
566
1058
|
[render_array_literal_value(iterable, translator, todos: []), nil]
|
|
567
1059
|
when IR::Interpolation
|
|
568
|
-
|
|
1060
|
+
safe_iterable_expression(iterable.expression, translator)
|
|
569
1061
|
else
|
|
570
1062
|
["[]", iterable.inspect]
|
|
571
1063
|
end
|
|
572
1064
|
end
|
|
573
1065
|
|
|
1066
|
+
# Iterable variant of safe_test_expression. Uses narrow (non-condition)
|
|
1067
|
+
# translation: a hook-tuple `items` translates to `nil` here rather than
|
|
1068
|
+
# being promoted to `@items`. Rationale: `nil.each` would crash at
|
|
1069
|
+
# render time; `[].each` renders nothing while the accompanying TODO
|
|
1070
|
+
# documents what didn't translate. Render-condition contexts (A1)
|
|
1071
|
+
# take the opposite trade-off because driving `if nil` silently
|
|
1072
|
+
# disables a load-bearing decision.
|
|
1073
|
+
def safe_iterable_expression(expression, translator)
|
|
1074
|
+
translated = translator.translate(expression)
|
|
1075
|
+
return [translated.ruby, nil] if translated && translated.ruby != "nil"
|
|
1076
|
+
|
|
1077
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
1078
|
+
["[]", compact]
|
|
1079
|
+
end
|
|
1080
|
+
|
|
574
1081
|
# Translate an expression intended to drive an `if` or `.each` call.
|
|
575
|
-
# Returns `[ruby_source, todo_text]
|
|
576
|
-
# the
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
1082
|
+
# Returns `[ruby_source, todo_text, promoted_locals]`:
|
|
1083
|
+
# - When the translator can parse the expression cleanly, `todo_text`
|
|
1084
|
+
# is nil. `promoted_locals` carries any bucket-4 names the
|
|
1085
|
+
# condition-mode translator promoted to `@ivar` — the caller
|
|
1086
|
+
# surfaces these in a TODO so a reviewer knows which bindings need
|
|
1087
|
+
# to become controller-passed props.
|
|
1088
|
+
# - When the translator can't, the caller's `fallback` (`"false"` for
|
|
1089
|
+
# conditions, `"[]"` for iterables) is returned with the verbatim
|
|
1090
|
+
# expression in `todo_text` so a TODO comment can be emitted. Without
|
|
1091
|
+
# this, JS operators like `!==`, `===`, optional chaining, and `in`
|
|
1092
|
+
# would leak into Ruby and produce SyntaxError on load.
|
|
582
1093
|
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
587
|
-
#
|
|
1094
|
+
# Condition-mode translation (`translate_condition`) is used for both
|
|
1095
|
+
# branches — it widens bucket-4 hits to `@ivar` so `if loading; …`
|
|
1096
|
+
# becomes `if @loading; …` instead of the dead `if false` fallback.
|
|
1097
|
+
# The promoted list separates that path from full-clean translation,
|
|
1098
|
+
# which has nothing to flag.
|
|
588
1099
|
def safe_test_expression(expression, translator, fallback:)
|
|
589
|
-
translated = translator.
|
|
590
|
-
return [translated.ruby, nil] if translated && translated.ruby != "nil"
|
|
1100
|
+
translated = translator.translate_condition(expression)
|
|
1101
|
+
return [translated.ruby, nil, translated.promoted_locals] if translated && translated.ruby != "nil"
|
|
591
1102
|
|
|
592
1103
|
compact = expression.tr("\n", " ").squeeze(" ")
|
|
593
|
-
[fallback, compact]
|
|
1104
|
+
[fallback, compact, []]
|
|
594
1105
|
end
|
|
595
1106
|
|
|
596
1107
|
def render_slot(slot, indent:)
|
|
@@ -656,22 +1167,31 @@ module JsxRosetta
|
|
|
656
1167
|
# of `h1()`). The `context:` param selects naming convention:
|
|
657
1168
|
# - :html (HTML element attrs — preserve camelCase for SVG)
|
|
658
1169
|
# - :component (Ruby method args — snake_case via Inflector.underscore)
|
|
659
|
-
def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
|
|
1170
|
+
def format_attributes(attributes, translator, context: :html, tag: nil, todos: [], indent: 0)
|
|
660
1171
|
events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
661
1172
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
662
1173
|
|
|
1174
|
+
# Per-element form-method check. Set before any attribute renders
|
|
1175
|
+
# so try_rewrite_href can consult it. Scoped per element via a
|
|
1176
|
+
# local backup/restore around the iteration — nested forms are
|
|
1177
|
+
# rare but legal HTML.
|
|
1178
|
+
prev_form_rewritable = @form_action_rewritable
|
|
1179
|
+
@form_action_rewritable = tag == "form" ? form_action_rewritable?(plain_attrs) : false
|
|
1180
|
+
|
|
663
1181
|
parts = { sym: [], str: [] }
|
|
664
1182
|
plain_attrs.each do |a|
|
|
665
|
-
append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
|
|
1183
|
+
append_attribute_part(a, translator, parts, context: context, tag: tag, todos: todos, indent: indent)
|
|
666
1184
|
end
|
|
667
1185
|
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
668
1186
|
|
|
669
1187
|
joined = build_attribute_list(parts, spreads, translator)
|
|
670
1188
|
joined.empty? ? "" : "(#{joined})"
|
|
1189
|
+
ensure
|
|
1190
|
+
@form_action_rewritable = prev_form_rewritable
|
|
671
1191
|
end
|
|
672
1192
|
|
|
673
|
-
def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0)
|
|
674
|
-
part = phlex_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
|
|
1193
|
+
def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0, tag: nil)
|
|
1194
|
+
part = phlex_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
|
|
675
1195
|
return unless part
|
|
676
1196
|
|
|
677
1197
|
(part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
|
|
@@ -686,23 +1206,102 @@ module JsxRosetta
|
|
|
686
1206
|
|
|
687
1207
|
# Emit one attribute as either a {string_key: false, source: "id: @x"}
|
|
688
1208
|
# (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
|
|
689
|
-
# (rare; non-identifier name — goes into a **{ ... } splat).
|
|
690
|
-
|
|
1209
|
+
# (rare; non-identifier name — goes into a **{ ... } splat). Returns
|
|
1210
|
+
# nil to signal "drop this attribute entirely" — used when every style
|
|
1211
|
+
# declaration dropped (would emit `style: ''`) or every plain-attribute
|
|
1212
|
+
# value bailed (would emit `attr: nil`); the TODO comment above the
|
|
1213
|
+
# element already describes what was lost.
|
|
1214
|
+
def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
|
|
691
1215
|
case attribute
|
|
1216
|
+
when IR::CvaCallSite then cva_call_site_attribute_part(attribute, translator)
|
|
692
1217
|
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
|
|
693
1218
|
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
|
|
694
|
-
when IR::Style then
|
|
1219
|
+
when IR::Style then style_attribute_part(attribute, translator, todos: todos)
|
|
695
1220
|
when IR::Attribute
|
|
696
|
-
plain_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
|
|
1221
|
+
plain_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
|
|
697
1222
|
end
|
|
698
1223
|
end
|
|
699
1224
|
|
|
1225
|
+
# Skip the `style:` kwarg entirely when every declaration failed to
|
|
1226
|
+
# translate — `style: ''` is invalid HTML output and pure noise; the
|
|
1227
|
+
# per-declaration TODO comments above the element preserve what was
|
|
1228
|
+
# there.
|
|
1229
|
+
def style_attribute_part(style, translator, todos:)
|
|
1230
|
+
ruby = style_to_ruby_string(style, translator, todos: todos)
|
|
1231
|
+
return nil if empty_style_ruby?(ruby)
|
|
1232
|
+
|
|
1233
|
+
{ string_key: false, source: "style: #{ruby}" }
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def empty_style_ruby?(ruby)
|
|
1237
|
+
["''", '""'].include?(ruby)
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
# A "dropped" attribute is one where translation failed AND the
|
|
1241
|
+
# fallback was the literal `nil` string. We detect this by watching
|
|
1242
|
+
# whether a TODO was appended during the value computation: a real
|
|
1243
|
+
# `attr={null}` in the source produces `nil` *without* a TODO and
|
|
1244
|
+
# should still emit (preserves intent); a failed translation
|
|
1245
|
+
# produces `nil` *with* a TODO and we drop the kwarg to keep output
|
|
1246
|
+
# clean — the TODO above the element already describes the loss.
|
|
1247
|
+
def dropped_value?(value_ruby, todos_before, todos_after)
|
|
1248
|
+
value_ruby == "nil" && todos_after.length > todos_before
|
|
1249
|
+
end
|
|
1250
|
+
|
|
700
1251
|
def class_attribute_part(expression, translator)
|
|
701
1252
|
translated = translator.translate(expression)
|
|
702
1253
|
ruby = translated ? translated.ruby : expression.inspect
|
|
703
1254
|
{ string_key: false, source: "class: #{ruby}" }
|
|
704
1255
|
end
|
|
705
1256
|
|
|
1257
|
+
# Render an IR::CvaCallSite as the `class:` kwarg. Always produces
|
|
1258
|
+
# a single Ruby string-interpolation literal that references the
|
|
1259
|
+
# backend-emitted constants for the cva binding. The detection
|
|
1260
|
+
# happened at lowering time (in `try_lower_cva_call_site`), so the
|
|
1261
|
+
# node already carries the binding name, axes, and optional
|
|
1262
|
+
# class_arg — no regex over verbatim JS source here.
|
|
1263
|
+
def cva_call_site_attribute_part(node, translator)
|
|
1264
|
+
cva = find_cva_binding(node.binding_name)
|
|
1265
|
+
return { string_key: false, source: "class: nil" } unless cva
|
|
1266
|
+
|
|
1267
|
+
parts = cva_call_site_parts(node, cva, translator)
|
|
1268
|
+
{ string_key: false, source: %(class: "#{parts.join(" ")}") }
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def find_cva_binding(binding_name)
|
|
1272
|
+
@current_component&.module_bindings&.find do |b|
|
|
1273
|
+
b.is_a?(IR::CvaBinding) && b.name == binding_name
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
def cva_call_site_parts(node, cva, translator)
|
|
1278
|
+
prefix = cva_constant_prefix(cva.name)
|
|
1279
|
+
parts = ["\#{#{prefix}_BASE_CLASS}"]
|
|
1280
|
+
node.axes.each do |pair|
|
|
1281
|
+
next unless cva.variants.key?(pair.axis)
|
|
1282
|
+
|
|
1283
|
+
parts << "\#{#{prefix}_VARIANT_CLASSES[#{pair.axis.inspect}][#{cva_axis_ruby_value(pair)}]}"
|
|
1284
|
+
end
|
|
1285
|
+
parts << "\#{#{cva_class_arg_ruby(node.class_arg, translator)}}" if node.class_arg
|
|
1286
|
+
parts
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def cva_class_arg_ruby(class_arg, translator)
|
|
1290
|
+
translated = translator.translate(class_arg.expression)
|
|
1291
|
+
return translated.ruby if translated
|
|
1292
|
+
|
|
1293
|
+
"@#{AST::Inflector.underscore(class_arg.expression)}"
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def cva_axis_ruby_value(pair)
|
|
1297
|
+
case pair.kind
|
|
1298
|
+
when :literal_string then pair.source.inspect
|
|
1299
|
+
when :literal_other then pair.source
|
|
1300
|
+
when :literal_nil then "nil"
|
|
1301
|
+
when :prop_ref then "@#{AST::Inflector.underscore(pair.source)}"
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
|
|
706
1305
|
# Map a JSX attribute name to its Ruby kwarg form. For HTML element
|
|
707
1306
|
# attrs (`context: :html`), only hyphens convert to underscores —
|
|
708
1307
|
# camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
|
|
@@ -712,8 +1311,12 @@ module JsxRosetta
|
|
|
712
1311
|
# follow snake_case convention (`defaultValue` → `default_value`).
|
|
713
1312
|
# Names that aren't valid Ruby identifiers after conversion (rare:
|
|
714
1313
|
# `xml:lang` and friends) fall back to a quoted string key.
|
|
715
|
-
def plain_attribute_part(attribute, translator, context:, todos:, indent: 0)
|
|
716
|
-
|
|
1314
|
+
def plain_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
|
|
1315
|
+
todos_before = todos.length
|
|
1316
|
+
value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator,
|
|
1317
|
+
todos: todos, indent: indent, tag: tag)
|
|
1318
|
+
return nil if dropped_value?(value_ruby, todos_before, todos)
|
|
1319
|
+
|
|
717
1320
|
ruby_name = case context
|
|
718
1321
|
when :component then AST::Inflector.underscore(attribute.name)
|
|
719
1322
|
else attribute.name.tr("-", "_")
|
|
@@ -725,7 +1328,11 @@ module JsxRosetta
|
|
|
725
1328
|
end
|
|
726
1329
|
end
|
|
727
1330
|
|
|
728
|
-
def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
|
|
1331
|
+
def attribute_value_to_ruby(name, value, translator, todos:, indent: 0, tag: nil)
|
|
1332
|
+
if (rewrite = try_rewrite_href(name, value, translator, tag: tag))
|
|
1333
|
+
return rewrite
|
|
1334
|
+
end
|
|
1335
|
+
|
|
729
1336
|
case value
|
|
730
1337
|
when true then "true"
|
|
731
1338
|
when String then AST::Inflector.ruby_string_literal(value)
|
|
@@ -733,9 +1340,89 @@ module JsxRosetta
|
|
|
733
1340
|
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
|
|
734
1341
|
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
|
|
735
1342
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
|
|
1343
|
+
when IR::EventHandler then render_event_handler_method_reference(value, attr_name: name)
|
|
1344
|
+
when IR::ComponentInvocation
|
|
1345
|
+
component_invocation_value(value, translator, todos: todos, attr_name: name)
|
|
1346
|
+
when IR::Element, IR::Fragment
|
|
1347
|
+
# An HTML tag (`title={<span>x</span>}`) or a multi-element
|
|
1348
|
+
# Fragment as an attribute value needs a Phlex execution context
|
|
1349
|
+
# the receiver might not provide. Drop with a TODO rather than
|
|
1350
|
+
# emit a broken kwarg or a speculative method reference.
|
|
1351
|
+
drop_jsx_value_with_todo(name, value, todos: todos)
|
|
736
1352
|
end
|
|
737
1353
|
end
|
|
738
1354
|
|
|
1355
|
+
# Slice 3: rewrite `href`/`to` on link-shaped tags to a Rails URL
|
|
1356
|
+
# helper call when the literal/template-literal path matches a
|
|
1357
|
+
# route in the scanned table. Returns nil to fall through to the
|
|
1358
|
+
# default emission path. Slice C1 added `<form action>` — see
|
|
1359
|
+
# form_action_rewritable? for the method-attribute check.
|
|
1360
|
+
def try_rewrite_href(name, value, translator, tag:)
|
|
1361
|
+
return nil unless @href_rewriter
|
|
1362
|
+
return nil unless tag
|
|
1363
|
+
|
|
1364
|
+
allowed_attrs = LINK_TAG_ATTRS[tag]
|
|
1365
|
+
return nil unless allowed_attrs&.include?(name)
|
|
1366
|
+
return nil if tag == "form" && !@form_action_rewritable
|
|
1367
|
+
|
|
1368
|
+
case value
|
|
1369
|
+
when String
|
|
1370
|
+
@href_rewriter.rewrite_literal(value)
|
|
1371
|
+
when IR::Interpolation
|
|
1372
|
+
rewrite_interpolation_href(value.expression, translator)
|
|
1373
|
+
end
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
# `<form action="…">` only rewrites for safe (GET) submissions —
|
|
1377
|
+
# slice-1 routes are GET-only, so POST/PUT/DELETE forms must stay
|
|
1378
|
+
# verbatim until the route table supports non-GET routes. Returns
|
|
1379
|
+
# true when no method attr is set (HTML default = GET) or when the
|
|
1380
|
+
# method is a literal GET; false otherwise (including interpolated
|
|
1381
|
+
# / TODO methods where we can't statically prove GET).
|
|
1382
|
+
def form_action_rewritable?(attributes)
|
|
1383
|
+
method_attr = attributes.find { |a| a.is_a?(IR::Attribute) && a.name == "method" }
|
|
1384
|
+
return true unless method_attr
|
|
1385
|
+
|
|
1386
|
+
case method_attr.value
|
|
1387
|
+
when nil, true then true
|
|
1388
|
+
when String then method_attr.value.casecmp("get").zero?
|
|
1389
|
+
else false
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
def rewrite_interpolation_href(js_source, translator)
|
|
1394
|
+
if (literal = string_literal_path(js_source))
|
|
1395
|
+
return @href_rewriter.rewrite_literal(literal)
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
segments = PagesRouting::HrefRewriter.parse_template_source(js_source)
|
|
1399
|
+
return nil unless segments
|
|
1400
|
+
|
|
1401
|
+
translated = segments.map do |kind, val|
|
|
1402
|
+
next [:literal, val] if kind == :literal
|
|
1403
|
+
|
|
1404
|
+
result = translator.translate(val)
|
|
1405
|
+
return nil unless result && result.ruby != "nil"
|
|
1406
|
+
|
|
1407
|
+
[:hole, result.ruby]
|
|
1408
|
+
end
|
|
1409
|
+
@href_rewriter.rewrite_template(translated)
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
def string_literal_path(js_source)
|
|
1413
|
+
return nil unless js_source.is_a?(String)
|
|
1414
|
+
return nil unless js_source.length >= 2
|
|
1415
|
+
|
|
1416
|
+
first = js_source[0]
|
|
1417
|
+
return nil unless ['"', "'"].include?(first)
|
|
1418
|
+
return nil unless js_source[-1] == first
|
|
1419
|
+
|
|
1420
|
+
body = js_source[1..-2]
|
|
1421
|
+
return nil if body.include?(first) || body.include?("\\")
|
|
1422
|
+
|
|
1423
|
+
body
|
|
1424
|
+
end
|
|
1425
|
+
|
|
739
1426
|
# Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
|
|
740
1427
|
# entries become Ruby kwargs (snake_cased to match Ruby convention);
|
|
741
1428
|
# non-identifier keys (numeric, hyphenated) fall back to string keys.
|
|
@@ -805,8 +1492,13 @@ module JsxRosetta
|
|
|
805
1492
|
when IR::ArrayLiteral
|
|
806
1493
|
render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
|
|
807
1494
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
|
|
1495
|
+
when IR::EventHandler then render_event_handler_method_reference(value, attr_name: attr_name)
|
|
808
1496
|
when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
|
|
809
1497
|
todos: todos)
|
|
1498
|
+
when IR::ComponentInvocation
|
|
1499
|
+
component_invocation_value(value, translator, todos: todos, attr_name: attr_name)
|
|
1500
|
+
when IR::Element, IR::Fragment
|
|
1501
|
+
drop_jsx_value_with_todo(attr_name, value, todos: todos)
|
|
810
1502
|
when String then AST::Inflector.ruby_string_literal(value)
|
|
811
1503
|
when true then "true"
|
|
812
1504
|
else
|
|
@@ -843,6 +1535,31 @@ module JsxRosetta
|
|
|
843
1535
|
@lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
|
|
844
1536
|
end
|
|
845
1537
|
|
|
1538
|
+
# Inline arrow event handler on a PascalCase tag — `onClick={() =>
|
|
1539
|
+
# doX()}` on `<Button>`. Extract to a stub method on the class and
|
|
1540
|
+
# reference via `method(:handle_click)` at the kwarg position so the
|
|
1541
|
+
# receiving component has a callable. The body translation is left
|
|
1542
|
+
# to the human reviewer (the JS source is preserved as a TODO
|
|
1543
|
+
# comment inside the method), but the structural wiring is intact.
|
|
1544
|
+
def render_event_handler_method_reference(handler, attr_name:)
|
|
1545
|
+
@event_handler_methods ||= []
|
|
1546
|
+
base = event_handler_method_base(attr_name)
|
|
1547
|
+
method_name = unique_lambda_method_name(base)
|
|
1548
|
+
@event_handler_methods << { handler: handler, method_name: method_name, attr_name: attr_name }
|
|
1549
|
+
"method(:#{method_name})"
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
# Map a JSX attribute name to an idiomatic Ruby handler-method name.
|
|
1553
|
+
# `onClick` → `handle_click` (mirrors React's `handleClick` convention,
|
|
1554
|
+
# snake_cased). Non-event attrs (rare — a callback prop with no `on`
|
|
1555
|
+
# prefix) fall back to `<attr>_handler`.
|
|
1556
|
+
def event_handler_method_base(attr_name)
|
|
1557
|
+
return "anonymous_handler" if attr_name.nil? || attr_name.empty?
|
|
1558
|
+
|
|
1559
|
+
snake = AST::Inflector.underscore(attr_name)
|
|
1560
|
+
snake.start_with?("on_") ? "handle_#{snake.delete_prefix("on_")}" : "#{snake}_handler"
|
|
1561
|
+
end
|
|
1562
|
+
|
|
846
1563
|
# Attribute-position interpolation. Three failure modes:
|
|
847
1564
|
# 1. Translator returns non-nil, no unresolved identifiers — emit
|
|
848
1565
|
# the Ruby reference directly. Common case.
|
|
@@ -859,9 +1576,7 @@ module JsxRosetta
|
|
|
859
1576
|
# template literals with method calls). Same TODO + nil path.
|
|
860
1577
|
def interpolated_attribute_value(name, value, translator, todos:)
|
|
861
1578
|
translated = translator.translate(value.expression)
|
|
862
|
-
if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
|
|
863
|
-
return translated.ruby
|
|
864
|
-
end
|
|
1579
|
+
return translated.ruby if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
|
|
865
1580
|
|
|
866
1581
|
compact = value.expression.tr("\n", " ").squeeze(" ")
|
|
867
1582
|
todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
|
|
@@ -872,13 +1587,84 @@ module JsxRosetta
|
|
|
872
1587
|
unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
|
|
873
1588
|
end
|
|
874
1589
|
|
|
875
|
-
|
|
1590
|
+
# JSX appearing as an attribute value — typically `icon={<Foo/>}` or
|
|
1591
|
+
# `fallback={<Loading/>}` on antd/MUI components. Emitted as a
|
|
1592
|
+
# component-instance value: `icon: FooComponent.new`. The receiving
|
|
1593
|
+
# Phlex component can render it directly via `render @icon`. Closes
|
|
1594
|
+
# the largest single category of attribute-value drops we were
|
|
1595
|
+
# silently emitting as `attr: nil` + TODO.
|
|
1596
|
+
#
|
|
1597
|
+
# Three child-handling tiers:
|
|
1598
|
+
# 1. No children — `ClassRef.new(kwargs)` on one line.
|
|
1599
|
+
# 2. Children whose rendered Phlex body fits a single line — emit
|
|
1600
|
+
# as a block: `ClassRef.new(kwargs) { plain "x" }`. The block
|
|
1601
|
+
# runs in the child component's render context, so HTML helpers
|
|
1602
|
+
# resolve correctly.
|
|
1603
|
+
# 3. Children that need multiple lines, or any IR::Element /
|
|
1604
|
+
# IR::Fragment we can't represent inline — fall back to the
|
|
1605
|
+
# existing TODO drop. Out of MVP scope; expand later if the
|
|
1606
|
+
# stress numbers warrant it.
|
|
1607
|
+
def component_invocation_value(invocation, translator, todos:, attr_name:)
|
|
1608
|
+
return drop_attribute_with_todo(attr_name, invocation, todos: todos) if invocation_has_render_prop?(invocation)
|
|
1609
|
+
|
|
1610
|
+
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: 0)
|
|
1611
|
+
class_ref = component_class_reference(invocation.name)
|
|
1612
|
+
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
1613
|
+
|
|
1614
|
+
return new_call if invocation.children.empty?
|
|
1615
|
+
|
|
1616
|
+
block_body = render_inline_children(invocation.children, translator)
|
|
1617
|
+
return drop_attribute_with_todo(attr_name, invocation, todos: todos) unless block_body
|
|
1618
|
+
|
|
1619
|
+
"#{new_call} { #{block_body} }"
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
def invocation_has_render_prop?(invocation)
|
|
1623
|
+
invocation.children.any?(IR::RenderProp)
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
# Render children to a single-line Phlex block body. Returns nil when
|
|
1627
|
+
# any child needs multiple lines or isn't representable inline — the
|
|
1628
|
+
# caller falls back to the TODO drop.
|
|
1629
|
+
def render_inline_children(children, translator)
|
|
1630
|
+
rendered = children.map { |c| render_ir_node(c, translator, indent: 0) }
|
|
1631
|
+
return nil if rendered.any? { |line| line.include?("\n") }
|
|
1632
|
+
|
|
1633
|
+
joined = rendered.join("; ")
|
|
1634
|
+
joined.length <= LITERAL_INLINE_BUDGET ? joined : nil
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
def drop_attribute_with_todo(attr_name, invocation, todos:)
|
|
1638
|
+
label = attr_name || "<element>"
|
|
1639
|
+
compact = invocation_source_summary(invocation)
|
|
1640
|
+
todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{compact}"
|
|
1641
|
+
"nil"
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
def invocation_source_summary(invocation)
|
|
1645
|
+
tag = invocation.name
|
|
1646
|
+
suffix = invocation.children.empty? ? "/" : "...>"
|
|
1647
|
+
"<#{tag}#{suffix}"
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
def drop_jsx_value_with_todo(attr_name, value, todos:)
|
|
1651
|
+
label = attr_name || "<element>"
|
|
1652
|
+
summary = case value
|
|
1653
|
+
when IR::Element then "<#{value.tag}...>"
|
|
1654
|
+
when IR::Fragment then "<>...</>"
|
|
1655
|
+
else "<JSX>"
|
|
1656
|
+
end
|
|
1657
|
+
todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{summary}"
|
|
1658
|
+
"nil"
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
def component_invocation_kwargs(props, translator, todos: [], indent: 0, tag: nil)
|
|
876
1662
|
events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
877
1663
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
878
1664
|
|
|
879
1665
|
parts = { sym: [], str: [] }
|
|
880
1666
|
plain_attrs.each do |a|
|
|
881
|
-
append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
|
|
1667
|
+
append_attribute_part(a, translator, parts, context: :component, tag: tag, todos: todos, indent: indent)
|
|
882
1668
|
end
|
|
883
1669
|
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
884
1670
|
|
|
@@ -995,15 +1781,77 @@ module JsxRosetta
|
|
|
995
1781
|
"#{lines.join("\n")}\n"
|
|
996
1782
|
end
|
|
997
1783
|
|
|
1784
|
+
# Stimulus method emission. JSX inline arrow bodies are valid JS already;
|
|
1785
|
+
# for DOM-driven handlers (the common shadcn shape) we just paste the body
|
|
1786
|
+
# verbatim into the method, naming the JS parameter to match the original
|
|
1787
|
+
# arrow's parameter so identifier references in the body still resolve.
|
|
1788
|
+
#
|
|
1789
|
+
# When the body references React-state setters or hooks we can't run in
|
|
1790
|
+
# the browser, fall back to the previous TODO-comment behavior so the
|
|
1791
|
+
# human reviewer ports it by hand.
|
|
998
1792
|
def stimulus_method_lines(method)
|
|
999
|
-
|
|
1000
|
-
commented = body_lines.map { |line| " // #{line}" }
|
|
1001
|
-
header = [" // TODO: translate from the original JSX handler:"]
|
|
1793
|
+
lines = []
|
|
1002
1794
|
if method.name != method.original_name
|
|
1003
|
-
|
|
1004
|
-
|
|
1795
|
+
lines << " // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
1796
|
+
"to avoid collision with an earlier handler"
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
if safe_to_paste_handler?(method)
|
|
1800
|
+
lines.concat(pasted_handler_lines(method))
|
|
1801
|
+
else
|
|
1802
|
+
lines.concat(todo_handler_lines(method))
|
|
1005
1803
|
end
|
|
1006
|
-
|
|
1804
|
+
|
|
1805
|
+
lines
|
|
1806
|
+
end
|
|
1807
|
+
|
|
1808
|
+
# Heuristic for "this JS body is safe to drop into a Stimulus method
|
|
1809
|
+
# verbatim." Bails out when:
|
|
1810
|
+
# - any arrow param wasn't a plain Identifier (destructured / rest) —
|
|
1811
|
+
# pasting would reference an undefined local at runtime;
|
|
1812
|
+
# - the body is the identifier-bound pseudo-comment we synthesize
|
|
1813
|
+
# when an `onClick={onChange}` reference resolved to no arrow;
|
|
1814
|
+
# - the body calls a top-level React state setter (`setX(`) or hook
|
|
1815
|
+
# (`useX(`) — the negative lookbehind on `[.\w]` makes sure DOM
|
|
1816
|
+
# methods like `e.setAttribute(` / `el.setPointerCapture(` don't
|
|
1817
|
+
# trip the guard.
|
|
1818
|
+
def safe_to_paste_handler?(method)
|
|
1819
|
+
return false unless method.params.all?
|
|
1820
|
+
|
|
1821
|
+
body = method.body_source
|
|
1822
|
+
return false if body.lstrip.start_with?("//")
|
|
1823
|
+
return false if body =~ /(?<![.\w])set[A-Z]\w*\(/
|
|
1824
|
+
return false if body =~ /(?<![.\w])use[A-Z]\w*\(/
|
|
1825
|
+
|
|
1826
|
+
true
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
# Paste the JS body into the method, using the original arrow's first
|
|
1830
|
+
# parameter name (so the body's references still resolve). Strip the
|
|
1831
|
+
# outer `{ … }` wrapper only when the body was an arrow BlockStatement
|
|
1832
|
+
# (`(e) => { … }`), not an expression-form body like `(e) => ({ x: 1 })`
|
|
1833
|
+
# which Babel hands back as `{ x: 1 }` already — stripping would yield
|
|
1834
|
+
# `x: 1`, a JS label statement (no-op).
|
|
1835
|
+
def pasted_handler_lines(method)
|
|
1836
|
+
param = method.params.first || "event"
|
|
1837
|
+
body = method.body_source.strip
|
|
1838
|
+
body = body[1..-2].strip if method.body_is_block
|
|
1839
|
+
inner_lines = body.split("\n").map { |l| " #{l.lstrip}" }
|
|
1840
|
+
[
|
|
1841
|
+
" #{method.name}(#{param}) {",
|
|
1842
|
+
*inner_lines,
|
|
1843
|
+
" }"
|
|
1844
|
+
]
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
# Fallback for handlers that aren't safe to paste verbatim — preserve
|
|
1848
|
+
# the original body as a comment and emit an empty method body.
|
|
1849
|
+
def todo_handler_lines(method)
|
|
1850
|
+
body_lines = method.body_source.strip.split("\n")
|
|
1851
|
+
commented = body_lines.map { |line| " // #{line}" }
|
|
1852
|
+
[
|
|
1853
|
+
" // TODO: translate from the original JSX handler:",
|
|
1854
|
+
*commented,
|
|
1007
1855
|
" #{method.name}(event) {",
|
|
1008
1856
|
" // ...",
|
|
1009
1857
|
" }"
|
|
@@ -1013,6 +1861,159 @@ module JsxRosetta
|
|
|
1013
1861
|
def spaces(count)
|
|
1014
1862
|
" " * count
|
|
1015
1863
|
end
|
|
1864
|
+
|
|
1865
|
+
# ----------------------------------------------------------------------
|
|
1866
|
+
# Lucide icon sidecars
|
|
1867
|
+
# ----------------------------------------------------------------------
|
|
1868
|
+
#
|
|
1869
|
+
# When a translated component references `<ChevronRight />` after
|
|
1870
|
+
# `import { ChevronRight } from "lucide-react"`, the consumer ends up
|
|
1871
|
+
# with a `render ChevronRight.new(...)` call against a Ruby class that
|
|
1872
|
+
# doesn't exist. To close that NameError, we emit one Phlex class per
|
|
1873
|
+
# referenced icon as a sidecar file (`chevron_right.rb` etc.) plus a
|
|
1874
|
+
# shared `lucide_icon.rb` base. Each file follows Zeitwerk's
|
|
1875
|
+
# one-constant-per-file convention so it drops straight into
|
|
1876
|
+
# `app/components/` without further configuration.
|
|
1877
|
+
|
|
1878
|
+
def lucide_icon_files(component)
|
|
1879
|
+
usages = referenced_lucide_icons(component)
|
|
1880
|
+
return [] if usages.empty?
|
|
1881
|
+
|
|
1882
|
+
@seen_lucide_icons ||= Set.new
|
|
1883
|
+
files = []
|
|
1884
|
+
unless @seen_lucide_icons.include?(:base)
|
|
1885
|
+
files << File.new(path: "lucide_icon.rb", contents: render_lucide_icon_base_rb)
|
|
1886
|
+
@seen_lucide_icons << :base
|
|
1887
|
+
end
|
|
1888
|
+
|
|
1889
|
+
usages.sort_by { |u| u[:local_name] }.each do |usage|
|
|
1890
|
+
next if @seen_lucide_icons.include?(usage[:local_name])
|
|
1891
|
+
|
|
1892
|
+
path = "#{AST::Inflector.underscore(usage[:local_name])}.rb"
|
|
1893
|
+
files << File.new(
|
|
1894
|
+
path: path,
|
|
1895
|
+
contents: render_lucide_icon_class_rb(usage[:local_name], canonical: usage[:canonical_name])
|
|
1896
|
+
)
|
|
1897
|
+
@seen_lucide_icons << usage[:local_name]
|
|
1898
|
+
end
|
|
1899
|
+
files
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
# Lucide imports used as JSX tags, each carrying both the local
|
|
1903
|
+
# binding (what the emitted file/class is named after) and the
|
|
1904
|
+
# canonical export (used to look up the vendored SVG path data).
|
|
1905
|
+
# The two diverge under aliased imports — `import { ChevronRight as
|
|
1906
|
+
# CR }` should still resolve `ChevronRight`'s SVG while emitting a
|
|
1907
|
+
# `cr.rb` defining `class CR < LucideIcon`. Names not in the
|
|
1908
|
+
# vendored data still emit, but the class falls back to a TODO
|
|
1909
|
+
# `inner_svg` instead of NameError-ing at render.
|
|
1910
|
+
def referenced_lucide_icons(component)
|
|
1911
|
+
lucide_by_local = component.module_imports
|
|
1912
|
+
.select { |i| Icons.lucide_source?(i.source) }
|
|
1913
|
+
.to_h { |i| [i.name, i.imported_name || i.name] }
|
|
1914
|
+
return [] if lucide_by_local.empty?
|
|
1915
|
+
|
|
1916
|
+
invocations = Set.new
|
|
1917
|
+
collect_component_invocations(component.body, invocations)
|
|
1918
|
+
invocations.intersection(lucide_by_local.keys).map do |local_name|
|
|
1919
|
+
{ local_name: local_name, canonical_name: lucide_by_local.fetch(local_name) }
|
|
1920
|
+
end
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
# Walk an IR subtree and collect every ComponentInvocation tag name we
|
|
1924
|
+
# see. Recurses through any field that holds an IR node or array of
|
|
1925
|
+
# nodes. Conservative — visits every container; cost is proportional
|
|
1926
|
+
# to IR size.
|
|
1927
|
+
def collect_component_invocations(node, acc)
|
|
1928
|
+
return if node.nil?
|
|
1929
|
+
|
|
1930
|
+
acc << node.name if node.is_a?(IR::ComponentInvocation)
|
|
1931
|
+
return unless node.respond_to?(:members)
|
|
1932
|
+
|
|
1933
|
+
node.members.each do |field|
|
|
1934
|
+
value = node.public_send(field)
|
|
1935
|
+
if value.is_a?(Array)
|
|
1936
|
+
value.each { |child| collect_component_invocations(child, acc) }
|
|
1937
|
+
elsif value.respond_to?(:members)
|
|
1938
|
+
collect_component_invocations(value, acc)
|
|
1939
|
+
end
|
|
1940
|
+
end
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
def render_lucide_icon_base_rb
|
|
1944
|
+
mod_open, mod_close, indent = lucide_module_wrap
|
|
1945
|
+
<<~RUBY
|
|
1946
|
+
# frozen_string_literal: true
|
|
1947
|
+
|
|
1948
|
+
# Generated by jsx_rosetta. Don't edit by hand — re-translate the source
|
|
1949
|
+
# to refresh. Shared SVG-wrapper base for Lucide icon shims; one subclass
|
|
1950
|
+
# per icon (e.g. ChevronRight, Search) sits alongside this file.
|
|
1951
|
+
#{mod_open}#{indent}class LucideIcon < Phlex::HTML
|
|
1952
|
+
#{indent} BASE_ATTRS = %{xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"}.freeze
|
|
1953
|
+
|
|
1954
|
+
#{indent} def initialize(class_name: nil, **)
|
|
1955
|
+
#{indent} super()
|
|
1956
|
+
#{indent} @class_name = class_name
|
|
1957
|
+
#{indent} end
|
|
1958
|
+
|
|
1959
|
+
#{indent} def view_template
|
|
1960
|
+
#{indent} cls = @class_name.to_s.empty? ? "" : %{ class="\#{@class_name}"}
|
|
1961
|
+
#{indent} raw safe("<svg \#{BASE_ATTRS}\#{cls}>\#{inner_svg}</svg>")
|
|
1962
|
+
#{indent} end
|
|
1963
|
+
|
|
1964
|
+
#{indent} def inner_svg
|
|
1965
|
+
#{indent} raise NotImplementedError
|
|
1966
|
+
#{indent} end
|
|
1967
|
+
#{indent}end
|
|
1968
|
+
#{mod_close}
|
|
1969
|
+
RUBY
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
# `name` is the local binding (the Ruby class name we emit), `canonical`
|
|
1973
|
+
# is the original Lucide export — they diverge under aliased imports.
|
|
1974
|
+
# SVG lookup keys on `canonical`; the class definition keys on `name`.
|
|
1975
|
+
def render_lucide_icon_class_rb(name, canonical: name)
|
|
1976
|
+
inner = Icons.lucide_for(canonical)
|
|
1977
|
+
mod_open, mod_close, indent = lucide_module_wrap
|
|
1978
|
+
body = if inner
|
|
1979
|
+
"#{indent} def inner_svg = #{format_svg_string(inner)}"
|
|
1980
|
+
else
|
|
1981
|
+
"#{indent} # TODO: #{canonical.inspect} isn't in jsx_rosetta's vendored lucide.json.\n" \
|
|
1982
|
+
"#{indent} # Fill in inner_svg with the SVG path data from lucide.dev, or refresh\n" \
|
|
1983
|
+
"#{indent} # `lib/jsx_rosetta/icons/lucide.json`.\n" \
|
|
1984
|
+
"#{indent} def inner_svg = \"\""
|
|
1985
|
+
end
|
|
1986
|
+
<<~RUBY
|
|
1987
|
+
# frozen_string_literal: true
|
|
1988
|
+
|
|
1989
|
+
# Generated by jsx_rosetta from a "lucide-react" import. Refresh with
|
|
1990
|
+
# a re-translate; don't edit by hand.
|
|
1991
|
+
#{mod_open}#{indent}class #{name} < LucideIcon
|
|
1992
|
+
#{body}
|
|
1993
|
+
#{indent}end
|
|
1994
|
+
#{mod_close}
|
|
1995
|
+
RUBY
|
|
1996
|
+
end
|
|
1997
|
+
|
|
1998
|
+
# Match the namespace wrapping we use for the main component class.
|
|
1999
|
+
# Returns ["module Foo\n", "end\n", " "] when a namespace is set,
|
|
2000
|
+
# or ["", "", ""] for the top-level case.
|
|
2001
|
+
def lucide_module_wrap
|
|
2002
|
+
return ["", "", ""] unless @namespace
|
|
2003
|
+
|
|
2004
|
+
["module #{@namespace}\n", "end\n", " "]
|
|
2005
|
+
end
|
|
2006
|
+
|
|
2007
|
+
# Wrap an SVG inner-markup snippet in a Ruby string literal that
|
|
2008
|
+
# preserves its embedded double quotes. Single-quoted when the markup
|
|
2009
|
+
# contains no single quotes (most cases), otherwise %q delimited.
|
|
2010
|
+
def format_svg_string(svg)
|
|
2011
|
+
if svg.include?("'")
|
|
2012
|
+
%(%q{#{svg}})
|
|
2013
|
+
else
|
|
2014
|
+
"'#{svg}'"
|
|
2015
|
+
end
|
|
2016
|
+
end
|
|
1016
2017
|
end
|
|
1017
2018
|
end
|
|
1018
2019
|
end
|