jsx_rosetta 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +334 -0
- data/README.md +69 -7
- data/ROADMAP.md +92 -0
- data/lib/jsx_rosetta/ast/inflector.rb +15 -0
- data/lib/jsx_rosetta/ast/node.rb +28 -1
- data/lib/jsx_rosetta/backend/phlex.rb +1018 -0
- data/lib/jsx_rosetta/backend/rails_view.rb +3 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +338 -25
- data/lib/jsx_rosetta/backend/view_component.rb +361 -77
- data/lib/jsx_rosetta/backend.rb +1 -0
- data/lib/jsx_rosetta/cli.rb +33 -5
- data/lib/jsx_rosetta/ir/lowering.rb +790 -213
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +167 -0
- data/lib/jsx_rosetta/ir/types.rb +154 -22
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +7 -6
- metadata +4 -1
|
@@ -28,6 +28,12 @@ module JsxRosetta
|
|
|
28
28
|
DEFAULT_SLOT_NAME = "children"
|
|
29
29
|
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
30
30
|
|
|
31
|
+
# Structured intermediate for tag_builder_data_action — avoids the
|
|
32
|
+
# fragile "parse what you just rendered" pattern. :literal is a raw
|
|
33
|
+
# action token like `"click->foo#bar"`; :ruby is a Ruby expression
|
|
34
|
+
# whose value is the action string (e.g. `event.handler.expression`).
|
|
35
|
+
EventDescriptor = Data.define(:kind, :body)
|
|
36
|
+
|
|
31
37
|
# JSX component names that have a direct Rails view-helper analog.
|
|
32
38
|
# Override per-instance via `ViewComponent.new(helpers: {...})`, or
|
|
33
39
|
# disable by passing `helpers: false`.
|
|
@@ -36,6 +42,32 @@ module JsxRosetta
|
|
|
36
42
|
"Image" => { method: :image_tag, positional: :src }.freeze
|
|
37
43
|
}.freeze
|
|
38
44
|
|
|
45
|
+
# Per-library TODO header lines surfaced above the verbatim hook
|
|
46
|
+
# source. Each library has a different Rails analog, so we don't
|
|
47
|
+
# collapse them into a single generic block. Keys must mirror the
|
|
48
|
+
# `:library` values produced by IR::Lowering. First line is rendered
|
|
49
|
+
# with the `<%#` opener; subsequent lines are indented continuation.
|
|
50
|
+
HOOK_TODO_HEADERS = {
|
|
51
|
+
react: [
|
|
52
|
+
"TODO: React hooks detected. None translate automatically. Hotwire/Stimulus",
|
|
53
|
+
"handles behavior; controllers/views handle state; turbo-frames handle async",
|
|
54
|
+
"loading. Original source:"
|
|
55
|
+
].freeze,
|
|
56
|
+
apollo: [
|
|
57
|
+
"TODO: Apollo data-fetching hooks detected. None translate automatically.",
|
|
58
|
+
"Move the fetch to the Rails controller (or a model/service); pass the",
|
|
59
|
+
"result in as a prop. For useMutation, use a form POST + redirect or a",
|
|
60
|
+
"Turbo Stream response. Original source:"
|
|
61
|
+
].freeze,
|
|
62
|
+
next_js: [
|
|
63
|
+
"TODO: Next.js navigation hooks detected. None translate automatically.",
|
|
64
|
+
"Rails analogs: useRouter -> redirect_to / form actions;",
|
|
65
|
+
"usePathname -> request.path; useSearchParams / useParams -> params;",
|
|
66
|
+
"useSelectedLayoutSegment(s) -> match against request.path in the view.",
|
|
67
|
+
"Original source:"
|
|
68
|
+
].freeze
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
39
71
|
def initialize(helpers: nil, layout: :sidecar)
|
|
40
72
|
super()
|
|
41
73
|
@helpers = case helpers
|
|
@@ -51,13 +83,17 @@ module JsxRosetta
|
|
|
51
83
|
end
|
|
52
84
|
|
|
53
85
|
def emit(component)
|
|
54
|
-
|
|
55
|
-
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
56
|
-
translator = ExpressionTranslator.new(prop_names: prop_names)
|
|
57
|
-
|
|
86
|
+
translator = build_translator(component)
|
|
58
87
|
base_name = "#{AST::Inflector.underscore(component.name)}_component"
|
|
59
88
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
60
89
|
|
|
90
|
+
# Data-factory components (column-descriptor modules) have no
|
|
91
|
+
# template — they're pure-data classes. Skip the .erb pair and
|
|
92
|
+
# emit a single .rb with the factory method. JSX render lambdas
|
|
93
|
+
# inside the data have nowhere to live in the ViewComponent
|
|
94
|
+
# ERB-template world, so we surface a TODO note in the class.
|
|
95
|
+
return emit_data_factory(component) if component.mode == :data_factory
|
|
96
|
+
|
|
61
97
|
files = [
|
|
62
98
|
File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
|
|
63
99
|
File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
|
|
@@ -71,6 +107,85 @@ module JsxRosetta
|
|
|
71
107
|
files
|
|
72
108
|
end
|
|
73
109
|
|
|
110
|
+
# For data-factory components emit a plain Ruby class (no ApplicationViewComponent
|
|
111
|
+
# base, no ERB template). The user can mix it into a ViewComponent or call
|
|
112
|
+
# the method directly — the goal is to surface the translated data array,
|
|
113
|
+
# not to render it in isolation.
|
|
114
|
+
def emit_data_factory(component)
|
|
115
|
+
method_name = AST::Inflector.underscore(component.name)
|
|
116
|
+
param_names = component.props.map(&:name)
|
|
117
|
+
translator = ExpressionTranslator.new(prop_names: [], local_binding_names: param_names)
|
|
118
|
+
signature = data_factory_signature(method_name, param_names)
|
|
119
|
+
body = translator.with_locals(param_names) do
|
|
120
|
+
inline_render_value(component.body, translator, indent: 4)
|
|
121
|
+
end
|
|
122
|
+
contents = <<~RUBY
|
|
123
|
+
# frozen_string_literal: true
|
|
124
|
+
|
|
125
|
+
# TODO: data-factory module — the translated array contains JSX render
|
|
126
|
+
# lambdas as `nil` placeholders. Wire each up to a Phlex helper or a
|
|
127
|
+
# method on the consuming ViewComponent.
|
|
128
|
+
class #{class_name_for(component)}
|
|
129
|
+
def #{signature}
|
|
130
|
+
#{body}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
RUBY
|
|
134
|
+
[File.new(path: "#{AST::Inflector.underscore(component.name)}.rb", contents: contents)]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def class_name_for(component)
|
|
138
|
+
component.name[0].upcase + component.name[1..]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def data_factory_signature(method_name, param_names)
|
|
142
|
+
return method_name if param_names.empty?
|
|
143
|
+
|
|
144
|
+
kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
|
|
145
|
+
"#{method_name}(#{kwargs.join(", ")})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Recursively render a non-JSX value (ObjectLiteral / ArrayLiteral /
|
|
149
|
+
# Lambda / Interpolation / primitives) without the kwarg-list
|
|
150
|
+
# context the Phlex backend uses. IR::Lambda and unmatched cases
|
|
151
|
+
# both fall through to `nil` — there's no Phlex class to host a
|
|
152
|
+
# rendered method body, and the class-level TODO comment above the
|
|
153
|
+
# emitted file already flags both for the reviewer.
|
|
154
|
+
def inline_render_value(value, translator, indent: 0)
|
|
155
|
+
case value
|
|
156
|
+
when IR::ObjectLiteral then render_factory_object_literal(value, translator, indent: indent)
|
|
157
|
+
when IR::ArrayLiteral then render_factory_array_literal(value, translator, indent: indent)
|
|
158
|
+
when IR::Interpolation then translator.translate(value.expression)&.ruby || "nil"
|
|
159
|
+
when String then value.inspect
|
|
160
|
+
when true then "true"
|
|
161
|
+
else "nil"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def render_factory_object_literal(obj, translator, indent:)
|
|
166
|
+
parts = obj.properties.map do |(key, value)|
|
|
167
|
+
rendered = inline_render_value(value, translator, indent: indent + 2)
|
|
168
|
+
snake = AST::Inflector.underscore(key)
|
|
169
|
+
if snake.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
170
|
+
"#{snake}: #{rendered}"
|
|
171
|
+
else
|
|
172
|
+
"#{key.inspect} => #{rendered}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
"{ #{parts.join(", ")} }"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render_factory_array_literal(arr, translator, indent:)
|
|
179
|
+
parts = arr.elements.map do |el|
|
|
180
|
+
el.nil? ? "nil" : inline_render_value(el, translator, indent: indent + 2)
|
|
181
|
+
end
|
|
182
|
+
return "[]" if parts.empty?
|
|
183
|
+
|
|
184
|
+
pad = " " * (indent + 2)
|
|
185
|
+
close_pad = " " * indent
|
|
186
|
+
"[\n#{pad}#{parts.join(",\n#{pad}")}\n#{close_pad}]"
|
|
187
|
+
end
|
|
188
|
+
|
|
74
189
|
def erb_path(base_name)
|
|
75
190
|
@layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
|
|
76
191
|
end
|
|
@@ -101,7 +216,12 @@ module JsxRosetta
|
|
|
101
216
|
def stimulus_method_lines(method)
|
|
102
217
|
body_lines = method.body_source.strip.split("\n")
|
|
103
218
|
commented = body_lines.map { |line| " // #{line}" }
|
|
104
|
-
[" // TODO: translate from the original JSX handler:"]
|
|
219
|
+
header = [" // TODO: translate from the original JSX handler:"]
|
|
220
|
+
if method.name != method.original_name
|
|
221
|
+
header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
222
|
+
"to avoid collision with an earlier handler")
|
|
223
|
+
end
|
|
224
|
+
header + commented + [
|
|
105
225
|
" #{method.name}(event) {",
|
|
106
226
|
" // ...",
|
|
107
227
|
" }"
|
|
@@ -110,6 +230,19 @@ module JsxRosetta
|
|
|
110
230
|
|
|
111
231
|
private
|
|
112
232
|
|
|
233
|
+
def build_translator(component)
|
|
234
|
+
prop_names = component.props.map(&:name)
|
|
235
|
+
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
236
|
+
prop_aliases = component.props.each_with_object({}) do |prop, hash|
|
|
237
|
+
hash[prop.alias_name] = prop.name if prop.alias_name
|
|
238
|
+
end
|
|
239
|
+
ExpressionTranslator.new(
|
|
240
|
+
prop_names: prop_names,
|
|
241
|
+
local_binding_names: component.local_binding_names,
|
|
242
|
+
prop_aliases: prop_aliases
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
113
246
|
def initializable_props(component)
|
|
114
247
|
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
115
248
|
end
|
|
@@ -118,6 +251,14 @@ module JsxRosetta
|
|
|
118
251
|
props = initializable_props(component)
|
|
119
252
|
rest_name = component.rest_prop_name
|
|
120
253
|
|
|
254
|
+
body = render_class_with_optional_props(component, props, rest_name, translator)
|
|
255
|
+
body = inject_render_method_skeletons(body, component)
|
|
256
|
+
|
|
257
|
+
prefix = render_module_bindings_prefix(component)
|
|
258
|
+
prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def render_class_with_optional_props(component, props, rest_name, translator)
|
|
121
262
|
if props.empty? && rest_name.nil?
|
|
122
263
|
<<~RUBY
|
|
123
264
|
# frozen_string_literal: true
|
|
@@ -130,15 +271,62 @@ module JsxRosetta
|
|
|
130
271
|
end
|
|
131
272
|
end
|
|
132
273
|
|
|
274
|
+
# For each RenderMethod, emit a method skeleton on the class just
|
|
275
|
+
# before the closing `end`. ERB-rendered VC bodies don't translate
|
|
276
|
+
# cleanly to Ruby methods (Phlex does — see its renderer), so the
|
|
277
|
+
# skeleton stays empty and the JSX source is preserved as a comment
|
|
278
|
+
# for the reviewer to translate by hand.
|
|
279
|
+
def inject_render_method_skeletons(body, component)
|
|
280
|
+
return body if component.render_methods.empty?
|
|
281
|
+
|
|
282
|
+
skeletons = component.render_methods.map { |rm| render_method_skeleton(rm) }
|
|
283
|
+
body.sub(/(\n)end\n\z/, "\n\n#{skeletons.join("\n\n")}\\1end\n")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def render_method_skeleton(render_method)
|
|
287
|
+
snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
|
|
288
|
+
signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
|
|
289
|
+
[
|
|
290
|
+
" # TODO: translate the JSX body for #{render_method.name} — was a",
|
|
291
|
+
" # local arrow returning JSX in the source component.",
|
|
292
|
+
" def #{signature}",
|
|
293
|
+
" \"\"",
|
|
294
|
+
" end"
|
|
295
|
+
].join("\n")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def render_module_bindings_prefix(component)
|
|
299
|
+
return "" if component.module_bindings.empty?
|
|
300
|
+
|
|
301
|
+
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
302
|
+
"or move to a Rails initializer:"]
|
|
303
|
+
component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
304
|
+
"#{lines.join("\n")}\n"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# The class body already starts with the magic comment — splice the
|
|
308
|
+
# module-bindings prefix in between so it lands above the class.
|
|
309
|
+
def insert_module_bindings_prefix(body, prefix)
|
|
310
|
+
magic = "# frozen_string_literal: true\n\n"
|
|
311
|
+
return "#{prefix}#{body}" unless body.start_with?(magic)
|
|
312
|
+
|
|
313
|
+
"#{magic}#{prefix}#{body[magic.length..]}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def comment_lines(source)
|
|
317
|
+
source.split("\n").map { |line| "# #{line}" }
|
|
318
|
+
end
|
|
319
|
+
|
|
133
320
|
def render_ruby_class_with_props(component, props, rest_name, translator)
|
|
321
|
+
rest_snake = rest_name && AST::Inflector.underscore(rest_name)
|
|
134
322
|
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
135
|
-
kwargs << "**#{
|
|
323
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
136
324
|
|
|
137
325
|
assignments = props.map do |prop|
|
|
138
326
|
snake = AST::Inflector.underscore(prop.name)
|
|
139
327
|
" @#{snake} = #{snake}"
|
|
140
328
|
end
|
|
141
|
-
assignments << " @#{
|
|
329
|
+
assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
142
330
|
|
|
143
331
|
<<~RUBY
|
|
144
332
|
# frozen_string_literal: true
|
|
@@ -160,8 +348,41 @@ module JsxRosetta
|
|
|
160
348
|
def ruby_default_for(prop, translator)
|
|
161
349
|
return "nil" if prop.default.nil?
|
|
162
350
|
|
|
163
|
-
|
|
164
|
-
|
|
351
|
+
case prop.default
|
|
352
|
+
when IR::Interpolation
|
|
353
|
+
translated = translator.translate(prop.default.expression)
|
|
354
|
+
translated ? translated.ruby : "nil"
|
|
355
|
+
when IR::ObjectLiteral then render_object_literal_default(prop.default, translator)
|
|
356
|
+
when IR::ArrayLiteral then render_array_literal_default(prop.default, translator)
|
|
357
|
+
else "nil"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def render_object_literal_default(object_literal, translator)
|
|
362
|
+
pairs = object_literal.properties.map do |(key, value)|
|
|
363
|
+
snake = AST::Inflector.underscore(key)
|
|
364
|
+
key_str = snake.match?(/\A[a-z_][a-z0-9_]*\z/i) ? "#{snake}:" : "#{key.inspect} =>"
|
|
365
|
+
"#{key_str} #{render_default_inline_value(value, translator)}"
|
|
366
|
+
end
|
|
367
|
+
"{ #{pairs.join(", ")} }"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def render_array_literal_default(array_literal, translator)
|
|
371
|
+
parts = array_literal.elements.map { |el| el.nil? ? "nil" : render_default_inline_value(el, translator) }
|
|
372
|
+
"[#{parts.join(", ")}]"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def render_default_inline_value(value, translator)
|
|
376
|
+
case value
|
|
377
|
+
when IR::ObjectLiteral then render_object_literal_default(value, translator)
|
|
378
|
+
when IR::ArrayLiteral then render_array_literal_default(value, translator)
|
|
379
|
+
when IR::Interpolation
|
|
380
|
+
translated = translator.translate(value.expression)
|
|
381
|
+
translated ? translated.ruby : "nil"
|
|
382
|
+
when String then value.inspect
|
|
383
|
+
when true then "true"
|
|
384
|
+
else "nil"
|
|
385
|
+
end
|
|
165
386
|
end
|
|
166
387
|
|
|
167
388
|
def render_erb_template(component, translator)
|
|
@@ -194,14 +415,20 @@ module JsxRosetta
|
|
|
194
415
|
def render_react_hooks_todo(hooks)
|
|
195
416
|
return "" if hooks.empty?
|
|
196
417
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
418
|
+
blocks = hooks.group_by(&:library).map { |library, calls| hook_todo_block(library, calls) }
|
|
419
|
+
"#{blocks.join("\n")}\n"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def hook_todo_block(library, calls)
|
|
423
|
+
header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
|
|
424
|
+
lines = ["<%# #{header_lines.first}"]
|
|
425
|
+
header_lines.drop(1).each { |line| lines << " #{line}" }
|
|
426
|
+
calls.each do |call|
|
|
427
|
+
lines << " operation: #{call.operation}" if call.operation
|
|
428
|
+
lines << " #{call.source}"
|
|
429
|
+
end
|
|
203
430
|
lines << "%>"
|
|
204
|
-
|
|
431
|
+
lines.join("\n")
|
|
205
432
|
end
|
|
206
433
|
|
|
207
434
|
def render_ir_node(node, translator, indent:)
|
|
@@ -211,6 +438,8 @@ module JsxRosetta
|
|
|
211
438
|
when IR::Fragment then render_fragment(node, translator, indent: indent)
|
|
212
439
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
213
440
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
441
|
+
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
442
|
+
when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
|
|
214
443
|
when IR::Slot then render_slot(node, indent: indent)
|
|
215
444
|
when IR::Text then "#{spaces(indent)}#{node.value}"
|
|
216
445
|
when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
|
|
@@ -218,8 +447,31 @@ module JsxRosetta
|
|
|
218
447
|
end
|
|
219
448
|
end
|
|
220
449
|
|
|
450
|
+
# `{renderHeader(arg)}` → `<%= render_header(arg) %>`. The matching
|
|
451
|
+
# method definition is emitted on the component class via
|
|
452
|
+
# `render_render_methods_section`. The class method returns an
|
|
453
|
+
# HTML-safe string (Rails' `content_tag` / `safe_join` is the
|
|
454
|
+
# canonical approach), and `<%= %>` interpolates it into the template.
|
|
455
|
+
def render_local_render_call(call, translator, indent:)
|
|
456
|
+
if call.args.empty?
|
|
457
|
+
"#{spaces(indent)}<%= #{call.method_name} %>"
|
|
458
|
+
else
|
|
459
|
+
arg_sources = call.args.map do |arg|
|
|
460
|
+
translated = translator.translate(arg.expression)
|
|
461
|
+
translated ? translated.ruby : arg.expression
|
|
462
|
+
end
|
|
463
|
+
"#{spaces(indent)}<%= #{call.method_name}(#{arg_sources.join(", ")}) %>"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
468
|
+
translator.with_locals(render_prop.params) do
|
|
469
|
+
render_ir_node(render_prop.body, translator, indent: indent)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
221
473
|
def render_loop(loop_node, translator, indent:)
|
|
222
|
-
iterable_ruby =
|
|
474
|
+
iterable_ruby = render_loop_iterable(loop_node.iterable, translator)
|
|
223
475
|
js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
|
|
224
476
|
ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
|
|
225
477
|
binding_str = "|#{ruby_bindings.join(", ")}|"
|
|
@@ -235,6 +487,14 @@ module JsxRosetta
|
|
|
235
487
|
].join("\n")
|
|
236
488
|
end
|
|
237
489
|
|
|
490
|
+
def render_loop_iterable(iterable, translator)
|
|
491
|
+
case iterable
|
|
492
|
+
when IR::ArrayLiteral then render_array_literal_default(iterable, translator)
|
|
493
|
+
when IR::Interpolation then render_test_expression(iterable, translator)
|
|
494
|
+
else "[]"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
238
498
|
def render_element(element, translator, indent:)
|
|
239
499
|
return render_element_with_tag_builder(element, translator, indent: indent) if needs_tag_builder?(element)
|
|
240
500
|
|
|
@@ -312,13 +572,11 @@ module JsxRosetta
|
|
|
312
572
|
|
|
313
573
|
def tag_builder_data_action(events, translator)
|
|
314
574
|
descriptors = events.map { |event| tag_builder_event_descriptor(event, translator) }
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
elsif all_literal
|
|
319
|
-
%("#{descriptors.map { |d| d[1..-2] }.join(" ")}")
|
|
575
|
+
joined = case descriptors
|
|
576
|
+
in [single]
|
|
577
|
+
render_single_event_descriptor(single)
|
|
320
578
|
else
|
|
321
|
-
%("#{descriptors.map { |d|
|
|
579
|
+
%("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
|
|
322
580
|
end
|
|
323
581
|
%("data-action" => #{joined})
|
|
324
582
|
end
|
|
@@ -327,23 +585,34 @@ module JsxRosetta
|
|
|
327
585
|
case event
|
|
328
586
|
when IR::EventBinding
|
|
329
587
|
translated = translator.translate(event.handler.expression)
|
|
330
|
-
|
|
588
|
+
if translated
|
|
589
|
+
EventDescriptor.new(:ruby, translated.ruby)
|
|
590
|
+
else
|
|
591
|
+
EventDescriptor.new(:ruby, event.handler.expression.inspect)
|
|
592
|
+
end
|
|
331
593
|
when IR::StimulusBinding
|
|
332
|
-
|
|
594
|
+
EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
|
|
333
595
|
end
|
|
334
596
|
end
|
|
335
597
|
|
|
336
|
-
def
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
598
|
+
def render_single_event_descriptor(descriptor)
|
|
599
|
+
descriptor.kind == :literal ? %("#{descriptor.body}") : descriptor.body
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Render a descriptor inline inside a Ruby string literal: literals
|
|
603
|
+
# are spliced verbatim, ruby expressions become `#{...}`.
|
|
604
|
+
def descriptor_in_string(descriptor)
|
|
605
|
+
descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
|
|
342
606
|
end
|
|
343
607
|
|
|
608
|
+
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
609
|
+
# default doesn't raise at render time. `<div {...maybeNil}>` →
|
|
610
|
+
# `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
|
|
611
|
+
# `|| {}` shortcuts on non-nil values.
|
|
344
612
|
def tag_builder_spread(expression, translator)
|
|
345
613
|
translated = translator.translate(expression)
|
|
346
|
-
translated ? translated.ruby : expression
|
|
614
|
+
ruby = translated ? translated.ruby : expression
|
|
615
|
+
"(#{ruby} || {})"
|
|
347
616
|
end
|
|
348
617
|
|
|
349
618
|
def render_component_invocation(invocation, translator, indent:)
|
|
@@ -354,7 +623,10 @@ module JsxRosetta
|
|
|
354
623
|
class_name = component_class_name(invocation.name)
|
|
355
624
|
new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
|
|
356
625
|
|
|
357
|
-
|
|
626
|
+
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
627
|
+
if render_prop
|
|
628
|
+
render_component_with_render_prop(new_call, render_prop, translator, indent)
|
|
629
|
+
elsif invocation.children.empty?
|
|
358
630
|
"#{spaces(indent)}<%= render #{new_call} %>"
|
|
359
631
|
else
|
|
360
632
|
inner = invocation.children.map { |child| render_ir_node(child, translator, indent: indent + 2) }.join("\n")
|
|
@@ -362,6 +634,15 @@ module JsxRosetta
|
|
|
362
634
|
end
|
|
363
635
|
end
|
|
364
636
|
|
|
637
|
+
def render_component_with_render_prop(new_call, render_prop, translator, indent)
|
|
638
|
+
snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
|
|
639
|
+
param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
|
|
640
|
+
inner = translator.with_locals(render_prop.params) do
|
|
641
|
+
render_ir_node(render_prop.body, translator, indent: indent + 2)
|
|
642
|
+
end
|
|
643
|
+
"#{spaces(indent)}<%= render #{new_call} do#{param_str} %>\n#{inner}\n#{spaces(indent)}<% end %>"
|
|
644
|
+
end
|
|
645
|
+
|
|
365
646
|
# JSX `<Foo.Bar>` → Ruby `Foo::BarComponent`. Plain `<Card>` stays as
|
|
366
647
|
# `CardComponent`. Each member-expression segment joins with `::`,
|
|
367
648
|
# and `Component` suffixes the leaf so the result is a constant path
|
|
@@ -424,9 +705,16 @@ module JsxRosetta
|
|
|
424
705
|
lines.join("\n")
|
|
425
706
|
end
|
|
426
707
|
|
|
708
|
+
# A translated value of `"nil"` is treated as untranslatable: the
|
|
709
|
+
# translator emits `"nil"` for known-local bindings (so the file
|
|
710
|
+
# loads as a leaf reference), but driving an `<% if %>` with `nil`
|
|
711
|
+
# silently disables the whole branch. Fall back to the verbatim
|
|
712
|
+
# expression so the human reviewer sees what needs translating.
|
|
427
713
|
def render_test_expression(test, translator)
|
|
428
714
|
translated = translator.translate(test.expression)
|
|
429
|
-
translated
|
|
715
|
+
return translated.ruby if translated && translated.ruby != "nil"
|
|
716
|
+
|
|
717
|
+
test.expression
|
|
430
718
|
end
|
|
431
719
|
|
|
432
720
|
def render_slot(slot, indent:)
|
|
@@ -455,52 +743,53 @@ module JsxRosetta
|
|
|
455
743
|
end
|
|
456
744
|
|
|
457
745
|
def render_style(style, translator)
|
|
458
|
-
rendered = style.declarations.map { |decl|
|
|
746
|
+
rendered = style.declarations.map { |decl| style_declaration(decl, translator, format: :erb) }.join(" ")
|
|
459
747
|
%(style="#{rendered}")
|
|
460
748
|
end
|
|
461
749
|
|
|
462
|
-
def
|
|
750
|
+
def render_class_list_attribute(class_list, translator)
|
|
751
|
+
parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :erb) }
|
|
752
|
+
%(class="#{parts.join(" ")}")
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def class_list_to_ruby_string(class_list, translator)
|
|
756
|
+
parts = class_list.segments.map { |seg| class_segment(seg, translator, format: :ruby_string) }
|
|
757
|
+
%("#{parts.join(" ")}")
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Render one IR::Style declaration in either ERB-template form
|
|
761
|
+
# (`color: <%= @c %>;`) or Ruby-string-interpolation form
|
|
762
|
+
# (`color: #{@c};`).
|
|
763
|
+
def style_declaration(decl, translator, format:)
|
|
463
764
|
value = case decl.value
|
|
464
765
|
when String then decl.value
|
|
465
|
-
when IR::Interpolation
|
|
466
|
-
translated = translator.translate(decl.value.expression)
|
|
467
|
-
"<%= #{translated&.ruby || decl.value.expression} %>"
|
|
766
|
+
when IR::Interpolation then interpolation_value(decl.value.expression, translator, format: format)
|
|
468
767
|
end
|
|
469
768
|
"#{decl.property}: #{value};"
|
|
470
769
|
end
|
|
471
770
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
def class_segment_for_html(segment, translator)
|
|
771
|
+
# Render one ClassList segment in either ERB-template form or Ruby
|
|
772
|
+
# string-interpolation form.
|
|
773
|
+
def class_segment(segment, translator, format:)
|
|
478
774
|
case segment
|
|
479
775
|
when String then segment
|
|
480
|
-
when IR::Interpolation
|
|
481
|
-
|
|
482
|
-
"<%= #{translated&.ruby || segment.expression} %>"
|
|
483
|
-
when IR::ConditionalSegment
|
|
484
|
-
cond_translated = translator.translate(segment.condition.expression)
|
|
485
|
-
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
486
|
-
"<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
|
|
776
|
+
when IR::Interpolation then interpolation_value(segment.expression, translator, format: format)
|
|
777
|
+
when IR::ConditionalSegment then conditional_class_segment(segment, translator, format: format)
|
|
487
778
|
end
|
|
488
779
|
end
|
|
489
780
|
|
|
490
|
-
def
|
|
491
|
-
|
|
492
|
-
|
|
781
|
+
def interpolation_value(expression, translator, format:)
|
|
782
|
+
translated = translator.translate(expression)
|
|
783
|
+
ruby = translated&.ruby || expression
|
|
784
|
+
format == :erb ? "<%= #{ruby} %>" : "\#{#{ruby}}"
|
|
493
785
|
end
|
|
494
786
|
|
|
495
|
-
def
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
when IR::ConditionalSegment
|
|
502
|
-
cond_translated = translator.translate(segment.condition.expression)
|
|
503
|
-
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
787
|
+
def conditional_class_segment(segment, translator, format:)
|
|
788
|
+
cond_translated = translator.translate(segment.condition.expression)
|
|
789
|
+
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
790
|
+
if format == :erb
|
|
791
|
+
"<%= #{cond_ruby} ? #{segment.class_name.inspect} : '' %>"
|
|
792
|
+
else
|
|
504
793
|
%(\#{#{cond_ruby} ? #{segment.class_name.inspect} : ""})
|
|
505
794
|
end
|
|
506
795
|
end
|
|
@@ -536,7 +825,7 @@ module JsxRosetta
|
|
|
536
825
|
# literal portions literally and the interpolations as ERB tags.
|
|
537
826
|
def plain_attribute_value_erb(interpolation, translator)
|
|
538
827
|
translated = translator.translate(interpolation.expression)
|
|
539
|
-
if
|
|
828
|
+
if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
|
|
540
829
|
inlined_ruby_string(translated.ruby)
|
|
541
830
|
else
|
|
542
831
|
interpolation_to_erb(interpolation, translator)
|
|
@@ -545,7 +834,7 @@ module JsxRosetta
|
|
|
545
834
|
|
|
546
835
|
def render_style_binding(binding, translator)
|
|
547
836
|
translated = translator.translate(binding.expression)
|
|
548
|
-
if
|
|
837
|
+
if quoted_ruby_string?(translated&.ruby)
|
|
549
838
|
%(class="#{inlined_ruby_string(translated.ruby)}")
|
|
550
839
|
elsif translated
|
|
551
840
|
%(class="<%= #{translated.ruby} %>")
|
|
@@ -554,8 +843,11 @@ module JsxRosetta
|
|
|
554
843
|
end
|
|
555
844
|
end
|
|
556
845
|
|
|
557
|
-
def
|
|
558
|
-
ruby.is_a?(String) &&
|
|
846
|
+
def quoted_ruby_string?(ruby)
|
|
847
|
+
ruby.is_a?(String) && (
|
|
848
|
+
(ruby.start_with?('"') && ruby.end_with?('"')) ||
|
|
849
|
+
(ruby.start_with?("'") && ruby.end_with?("'"))
|
|
850
|
+
)
|
|
559
851
|
end
|
|
560
852
|
|
|
561
853
|
# Given a Ruby double-quoted string with #{...} interpolations, emit it
|
|
@@ -598,15 +890,7 @@ module JsxRosetta
|
|
|
598
890
|
end
|
|
599
891
|
|
|
600
892
|
def style_to_ruby_string(style, translator)
|
|
601
|
-
parts = style.declarations.map
|
|
602
|
-
value = case decl.value
|
|
603
|
-
when String then decl.value
|
|
604
|
-
when IR::Interpolation
|
|
605
|
-
translated = translator.translate(decl.value.expression)
|
|
606
|
-
"\#{#{translated&.ruby || decl.value.expression}}"
|
|
607
|
-
end
|
|
608
|
-
"#{decl.property}: #{value};"
|
|
609
|
-
end
|
|
893
|
+
parts = style.declarations.map { |decl| style_declaration(decl, translator, format: :ruby_string) }
|
|
610
894
|
%("#{parts.join(" ")}")
|
|
611
895
|
end
|
|
612
896
|
|
data/lib/jsx_rosetta/backend.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "backend/base"
|
|
|
4
4
|
require_relative "backend/view_component"
|
|
5
5
|
require_relative "backend/view_component/expression_translator"
|
|
6
6
|
require_relative "backend/rails_view"
|
|
7
|
+
require_relative "backend/phlex"
|
|
7
8
|
require_relative "backend/routes_script"
|
|
8
9
|
|
|
9
10
|
module JsxRosetta
|