jsx_rosetta 0.4.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 +215 -1
- data/ROADMAP.md +92 -0
- data/lib/jsx_rosetta/ast/inflector.rb +15 -0
- data/lib/jsx_rosetta/backend/phlex.rb +372 -110
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +297 -26
- data/lib/jsx_rosetta/backend/view_component.rb +214 -30
- data/lib/jsx_rosetta/ir/lowering.rb +445 -40
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/types.rb +78 -17
- data/lib/jsx_rosetta/version.rb +1 -1
- metadata +2 -1
|
@@ -42,6 +42,32 @@ module JsxRosetta
|
|
|
42
42
|
"Image" => { method: :image_tag, positional: :src }.freeze
|
|
43
43
|
}.freeze
|
|
44
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
|
+
|
|
45
71
|
def initialize(helpers: nil, layout: :sidecar)
|
|
46
72
|
super()
|
|
47
73
|
@helpers = case helpers
|
|
@@ -57,15 +83,17 @@ module JsxRosetta
|
|
|
57
83
|
end
|
|
58
84
|
|
|
59
85
|
def emit(component)
|
|
60
|
-
|
|
61
|
-
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
62
|
-
translator = ExpressionTranslator.new(
|
|
63
|
-
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
64
|
-
)
|
|
65
|
-
|
|
86
|
+
translator = build_translator(component)
|
|
66
87
|
base_name = "#{AST::Inflector.underscore(component.name)}_component"
|
|
67
88
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
68
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
|
+
|
|
69
97
|
files = [
|
|
70
98
|
File.new(path: "#{base_name}.rb", contents: render_ruby_class(component, translator)),
|
|
71
99
|
File.new(path: erb_path(base_name), contents: render_erb_template(component, translator))
|
|
@@ -79,6 +107,85 @@ module JsxRosetta
|
|
|
79
107
|
files
|
|
80
108
|
end
|
|
81
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
|
+
|
|
82
189
|
def erb_path(base_name)
|
|
83
190
|
@layout == :sidecar ? "#{base_name}/#{base_name}.html.erb" : "#{base_name}.html.erb"
|
|
84
191
|
end
|
|
@@ -123,6 +230,19 @@ module JsxRosetta
|
|
|
123
230
|
|
|
124
231
|
private
|
|
125
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
|
+
|
|
126
246
|
def initializable_props(component)
|
|
127
247
|
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
128
248
|
end
|
|
@@ -131,21 +251,50 @@ module JsxRosetta
|
|
|
131
251
|
props = initializable_props(component)
|
|
132
252
|
rest_name = component.rest_prop_name
|
|
133
253
|
|
|
134
|
-
body =
|
|
135
|
-
|
|
136
|
-
# frozen_string_literal: true
|
|
137
|
-
|
|
138
|
-
class #{component.name}Component < ::ViewComponent::Base
|
|
139
|
-
end
|
|
140
|
-
RUBY
|
|
141
|
-
else
|
|
142
|
-
render_ruby_class_with_props(component, props, rest_name, translator)
|
|
143
|
-
end
|
|
254
|
+
body = render_class_with_optional_props(component, props, rest_name, translator)
|
|
255
|
+
body = inject_render_method_skeletons(body, component)
|
|
144
256
|
|
|
145
257
|
prefix = render_module_bindings_prefix(component)
|
|
146
258
|
prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
|
|
147
259
|
end
|
|
148
260
|
|
|
261
|
+
def render_class_with_optional_props(component, props, rest_name, translator)
|
|
262
|
+
if props.empty? && rest_name.nil?
|
|
263
|
+
<<~RUBY
|
|
264
|
+
# frozen_string_literal: true
|
|
265
|
+
|
|
266
|
+
class #{component.name}Component < ::ViewComponent::Base
|
|
267
|
+
end
|
|
268
|
+
RUBY
|
|
269
|
+
else
|
|
270
|
+
render_ruby_class_with_props(component, props, rest_name, translator)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
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
|
+
|
|
149
298
|
def render_module_bindings_prefix(component)
|
|
150
299
|
return "" if component.module_bindings.empty?
|
|
151
300
|
|
|
@@ -169,14 +318,15 @@ module JsxRosetta
|
|
|
169
318
|
end
|
|
170
319
|
|
|
171
320
|
def render_ruby_class_with_props(component, props, rest_name, translator)
|
|
321
|
+
rest_snake = rest_name && AST::Inflector.underscore(rest_name)
|
|
172
322
|
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
173
|
-
kwargs << "**#{
|
|
323
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
174
324
|
|
|
175
325
|
assignments = props.map do |prop|
|
|
176
326
|
snake = AST::Inflector.underscore(prop.name)
|
|
177
327
|
" @#{snake} = #{snake}"
|
|
178
328
|
end
|
|
179
|
-
assignments << " @#{
|
|
329
|
+
assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
180
330
|
|
|
181
331
|
<<~RUBY
|
|
182
332
|
# frozen_string_literal: true
|
|
@@ -265,14 +415,20 @@ module JsxRosetta
|
|
|
265
415
|
def render_react_hooks_todo(hooks)
|
|
266
416
|
return "" if hooks.empty?
|
|
267
417
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
274
430
|
lines << "%>"
|
|
275
|
-
|
|
431
|
+
lines.join("\n")
|
|
276
432
|
end
|
|
277
433
|
|
|
278
434
|
def render_ir_node(node, translator, indent:)
|
|
@@ -283,6 +439,7 @@ module JsxRosetta
|
|
|
283
439
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
284
440
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
285
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)
|
|
286
443
|
when IR::Slot then render_slot(node, indent: indent)
|
|
287
444
|
when IR::Text then "#{spaces(indent)}#{node.value}"
|
|
288
445
|
when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
|
|
@@ -290,6 +447,23 @@ module JsxRosetta
|
|
|
290
447
|
end
|
|
291
448
|
end
|
|
292
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
|
+
|
|
293
467
|
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
294
468
|
translator.with_locals(render_prop.params) do
|
|
295
469
|
render_ir_node(render_prop.body, translator, indent: indent)
|
|
@@ -531,9 +705,16 @@ module JsxRosetta
|
|
|
531
705
|
lines.join("\n")
|
|
532
706
|
end
|
|
533
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.
|
|
534
713
|
def render_test_expression(test, translator)
|
|
535
714
|
translated = translator.translate(test.expression)
|
|
536
|
-
translated
|
|
715
|
+
return translated.ruby if translated && translated.ruby != "nil"
|
|
716
|
+
|
|
717
|
+
test.expression
|
|
537
718
|
end
|
|
538
719
|
|
|
539
720
|
def render_slot(slot, indent:)
|
|
@@ -644,7 +825,7 @@ module JsxRosetta
|
|
|
644
825
|
# literal portions literally and the interpolations as ERB tags.
|
|
645
826
|
def plain_attribute_value_erb(interpolation, translator)
|
|
646
827
|
translated = translator.translate(interpolation.expression)
|
|
647
|
-
if
|
|
828
|
+
if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
|
|
648
829
|
inlined_ruby_string(translated.ruby)
|
|
649
830
|
else
|
|
650
831
|
interpolation_to_erb(interpolation, translator)
|
|
@@ -653,7 +834,7 @@ module JsxRosetta
|
|
|
653
834
|
|
|
654
835
|
def render_style_binding(binding, translator)
|
|
655
836
|
translated = translator.translate(binding.expression)
|
|
656
|
-
if
|
|
837
|
+
if quoted_ruby_string?(translated&.ruby)
|
|
657
838
|
%(class="#{inlined_ruby_string(translated.ruby)}")
|
|
658
839
|
elsif translated
|
|
659
840
|
%(class="<%= #{translated.ruby} %>")
|
|
@@ -662,8 +843,11 @@ module JsxRosetta
|
|
|
662
843
|
end
|
|
663
844
|
end
|
|
664
845
|
|
|
665
|
-
def
|
|
666
|
-
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
|
+
)
|
|
667
851
|
end
|
|
668
852
|
|
|
669
853
|
# Given a Ruby double-quoted string with #{...} interpolations, emit it
|