jsx_rosetta 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +342 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/ROADMAP.md +92 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +32 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
- data/lib/jsx_rosetta/backend/view_component.rb +261 -31
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +264 -19
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +30 -1
|
@@ -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
|
|
@@ -56,16 +82,18 @@ module JsxRosetta
|
|
|
56
82
|
@layout = layout
|
|
57
83
|
end
|
|
58
84
|
|
|
59
|
-
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
|
-
|
|
85
|
+
def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
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,20 @@ 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
|
+
imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
126
247
|
def initializable_props(component)
|
|
127
248
|
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
128
249
|
end
|
|
@@ -131,21 +252,50 @@ module JsxRosetta
|
|
|
131
252
|
props = initializable_props(component)
|
|
132
253
|
rest_name = component.rest_prop_name
|
|
133
254
|
|
|
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
|
|
255
|
+
body = render_class_with_optional_props(component, props, rest_name, translator)
|
|
256
|
+
body = inject_render_method_skeletons(body, component)
|
|
144
257
|
|
|
145
258
|
prefix = render_module_bindings_prefix(component)
|
|
146
259
|
prefix.empty? ? body : insert_module_bindings_prefix(body, prefix)
|
|
147
260
|
end
|
|
148
261
|
|
|
262
|
+
def render_class_with_optional_props(component, props, rest_name, translator)
|
|
263
|
+
if props.empty? && rest_name.nil?
|
|
264
|
+
<<~RUBY
|
|
265
|
+
# frozen_string_literal: true
|
|
266
|
+
|
|
267
|
+
class #{component.name}Component < ::ViewComponent::Base
|
|
268
|
+
end
|
|
269
|
+
RUBY
|
|
270
|
+
else
|
|
271
|
+
render_ruby_class_with_props(component, props, rest_name, translator)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# For each RenderMethod, emit a method skeleton on the class just
|
|
276
|
+
# before the closing `end`. ERB-rendered VC bodies don't translate
|
|
277
|
+
# cleanly to Ruby methods (Phlex does — see its renderer), so the
|
|
278
|
+
# skeleton stays empty and the JSX source is preserved as a comment
|
|
279
|
+
# for the reviewer to translate by hand.
|
|
280
|
+
def inject_render_method_skeletons(body, component)
|
|
281
|
+
return body if component.render_methods.empty?
|
|
282
|
+
|
|
283
|
+
skeletons = component.render_methods.map { |rm| render_method_skeleton(rm) }
|
|
284
|
+
body.sub(/(\n)end\n\z/, "\n\n#{skeletons.join("\n\n")}\\1end\n")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def render_method_skeleton(render_method)
|
|
288
|
+
snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
|
|
289
|
+
signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
|
|
290
|
+
[
|
|
291
|
+
" # TODO: translate the JSX body for #{render_method.name} — was a",
|
|
292
|
+
" # local arrow returning JSX in the source component.",
|
|
293
|
+
" def #{signature}",
|
|
294
|
+
" \"\"",
|
|
295
|
+
" end"
|
|
296
|
+
].join("\n")
|
|
297
|
+
end
|
|
298
|
+
|
|
149
299
|
def render_module_bindings_prefix(component)
|
|
150
300
|
return "" if component.module_bindings.empty?
|
|
151
301
|
|
|
@@ -169,14 +319,15 @@ module JsxRosetta
|
|
|
169
319
|
end
|
|
170
320
|
|
|
171
321
|
def render_ruby_class_with_props(component, props, rest_name, translator)
|
|
322
|
+
rest_snake = rest_name && AST::Inflector.underscore(rest_name)
|
|
172
323
|
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
173
|
-
kwargs << "**#{
|
|
324
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
174
325
|
|
|
175
326
|
assignments = props.map do |prop|
|
|
176
327
|
snake = AST::Inflector.underscore(prop.name)
|
|
177
328
|
" @#{snake} = #{snake}"
|
|
178
329
|
end
|
|
179
|
-
assignments << " @#{
|
|
330
|
+
assignments << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
180
331
|
|
|
181
332
|
<<~RUBY
|
|
182
333
|
# frozen_string_literal: true
|
|
@@ -265,14 +416,20 @@ module JsxRosetta
|
|
|
265
416
|
def render_react_hooks_todo(hooks)
|
|
266
417
|
return "" if hooks.empty?
|
|
267
418
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
419
|
+
blocks = hooks.group_by(&:library).map { |library, calls| hook_todo_block(library, calls) }
|
|
420
|
+
"#{blocks.join("\n")}\n"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def hook_todo_block(library, calls)
|
|
424
|
+
header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
|
|
425
|
+
lines = ["<%# #{header_lines.first}"]
|
|
426
|
+
header_lines.drop(1).each { |line| lines << " #{line}" }
|
|
427
|
+
calls.each do |call|
|
|
428
|
+
lines << " operation: #{call.operation}" if call.operation
|
|
429
|
+
lines << " #{call.source}"
|
|
430
|
+
end
|
|
274
431
|
lines << "%>"
|
|
275
|
-
|
|
432
|
+
lines.join("\n")
|
|
276
433
|
end
|
|
277
434
|
|
|
278
435
|
def render_ir_node(node, translator, indent:)
|
|
@@ -283,6 +440,7 @@ module JsxRosetta
|
|
|
283
440
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
284
441
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
285
442
|
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
443
|
+
when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
|
|
286
444
|
when IR::Slot then render_slot(node, indent: indent)
|
|
287
445
|
when IR::Text then "#{spaces(indent)}#{node.value}"
|
|
288
446
|
when IR::Interpolation then "#{spaces(indent)}#{interpolation_to_erb(node, translator)}"
|
|
@@ -290,6 +448,23 @@ module JsxRosetta
|
|
|
290
448
|
end
|
|
291
449
|
end
|
|
292
450
|
|
|
451
|
+
# `{renderHeader(arg)}` → `<%= render_header(arg) %>`. The matching
|
|
452
|
+
# method definition is emitted on the component class via
|
|
453
|
+
# `render_render_methods_section`. The class method returns an
|
|
454
|
+
# HTML-safe string (Rails' `content_tag` / `safe_join` is the
|
|
455
|
+
# canonical approach), and `<%= %>` interpolates it into the template.
|
|
456
|
+
def render_local_render_call(call, translator, indent:)
|
|
457
|
+
if call.args.empty?
|
|
458
|
+
"#{spaces(indent)}<%= #{call.method_name} %>"
|
|
459
|
+
else
|
|
460
|
+
arg_sources = call.args.map do |arg|
|
|
461
|
+
translated = translator.translate(arg.expression)
|
|
462
|
+
translated ? translated.ruby : arg.expression
|
|
463
|
+
end
|
|
464
|
+
"#{spaces(indent)}<%= #{call.method_name}(#{arg_sources.join(", ")}) %>"
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
293
468
|
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
294
469
|
translator.with_locals(render_prop.params) do
|
|
295
470
|
render_ir_node(render_prop.body, translator, indent: indent)
|
|
@@ -531,9 +706,16 @@ module JsxRosetta
|
|
|
531
706
|
lines.join("\n")
|
|
532
707
|
end
|
|
533
708
|
|
|
709
|
+
# A translated value of `"nil"` is treated as untranslatable: the
|
|
710
|
+
# translator emits `"nil"` for known-local bindings (so the file
|
|
711
|
+
# loads as a leaf reference), but driving an `<% if %>` with `nil`
|
|
712
|
+
# silently disables the whole branch. Fall back to the verbatim
|
|
713
|
+
# expression so the human reviewer sees what needs translating.
|
|
534
714
|
def render_test_expression(test, translator)
|
|
535
715
|
translated = translator.translate(test.expression)
|
|
536
|
-
translated
|
|
716
|
+
return translated.ruby if translated && translated.ruby != "nil"
|
|
717
|
+
|
|
718
|
+
test.expression
|
|
537
719
|
end
|
|
538
720
|
|
|
539
721
|
def render_slot(slot, indent:)
|
|
@@ -644,7 +826,7 @@ module JsxRosetta
|
|
|
644
826
|
# literal portions literally and the interpolations as ERB tags.
|
|
645
827
|
def plain_attribute_value_erb(interpolation, translator)
|
|
646
828
|
translated = translator.translate(interpolation.expression)
|
|
647
|
-
if
|
|
829
|
+
if quoted_ruby_string?(translated&.ruby) && translated.unresolved_identifiers.empty?
|
|
648
830
|
inlined_ruby_string(translated.ruby)
|
|
649
831
|
else
|
|
650
832
|
interpolation_to_erb(interpolation, translator)
|
|
@@ -653,7 +835,7 @@ module JsxRosetta
|
|
|
653
835
|
|
|
654
836
|
def render_style_binding(binding, translator)
|
|
655
837
|
translated = translator.translate(binding.expression)
|
|
656
|
-
if
|
|
838
|
+
if quoted_ruby_string?(translated&.ruby)
|
|
657
839
|
%(class="#{inlined_ruby_string(translated.ruby)}")
|
|
658
840
|
elsif translated
|
|
659
841
|
%(class="<%= #{translated.ruby} %>")
|
|
@@ -662,8 +844,11 @@ module JsxRosetta
|
|
|
662
844
|
end
|
|
663
845
|
end
|
|
664
846
|
|
|
665
|
-
def
|
|
666
|
-
ruby.is_a?(String) &&
|
|
847
|
+
def quoted_ruby_string?(ruby)
|
|
848
|
+
ruby.is_a?(String) && (
|
|
849
|
+
(ruby.start_with?('"') && ruby.end_with?('"')) ||
|
|
850
|
+
(ruby.start_with?("'") && ruby.end_with?("'"))
|
|
851
|
+
)
|
|
667
852
|
end
|
|
668
853
|
|
|
669
854
|
# Given a Ruby double-quoted string with #{...} interpolations, emit it
|
|
@@ -727,6 +912,51 @@ module JsxRosetta
|
|
|
727
912
|
when IR::Interpolation
|
|
728
913
|
translated = translator.translate(value.expression)
|
|
729
914
|
translated ? translated.ruby : "nil # TODO: translate #{value.expression.inspect}"
|
|
915
|
+
when IR::ComponentInvocation
|
|
916
|
+
component_invocation_inline_value(value, translator)
|
|
917
|
+
when IR::Element, IR::Fragment
|
|
918
|
+
"nil # TODO: couldn't inline JSX value: #{jsx_value_summary(value)}"
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# JSX appearing as an attribute value — `icon={<Foo/>}` etc. — lowered
|
|
923
|
+
# in v0.5.x. Emit a component-instance value (`icon: FooComponent.new`)
|
|
924
|
+
# so the receiving ViewComponent can `render @icon`. With children we
|
|
925
|
+
# use Ruby block syntax (`FooComponent.new { ... }`) when the body
|
|
926
|
+
# fits one line; otherwise we drop with a TODO so the kwarg stays
|
|
927
|
+
# valid Ruby. Cases requiring a Phlex execution context (IR::Element,
|
|
928
|
+
# multi-element IR::Fragment) drop with a TODO too — out of MVP scope.
|
|
929
|
+
def component_invocation_inline_value(invocation, translator)
|
|
930
|
+
return inline_render_prop_todo if invocation.children.any?(IR::RenderProp)
|
|
931
|
+
|
|
932
|
+
kwargs = component_invocation_kwargs(invocation.props, translator)
|
|
933
|
+
class_name = component_class_name(invocation.name)
|
|
934
|
+
new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
|
|
935
|
+
return new_call if invocation.children.empty?
|
|
936
|
+
|
|
937
|
+
block_body = inline_children_body(invocation.children, translator)
|
|
938
|
+
return "nil # TODO: couldn't inline JSX value: <#{invocation.name}...>" unless block_body
|
|
939
|
+
|
|
940
|
+
"#{new_call} { #{block_body} }"
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def inline_render_prop_todo
|
|
944
|
+
"nil # TODO: couldn't inline render-prop value"
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def inline_children_body(children, translator)
|
|
948
|
+
rendered = children.map { |c| render_ir_node(c, translator, indent: 0).strip }
|
|
949
|
+
return nil if rendered.any? { |line| line.include?("\n") }
|
|
950
|
+
|
|
951
|
+
joined = rendered.join("; ")
|
|
952
|
+
joined.empty? || joined.length > 100 ? nil : joined
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def jsx_value_summary(value)
|
|
956
|
+
case value
|
|
957
|
+
when IR::Element then "<#{value.tag}...>"
|
|
958
|
+
when IR::Fragment then "<>...</>"
|
|
959
|
+
else "<JSX>"
|
|
730
960
|
end
|
|
731
961
|
end
|
|
732
962
|
|
data/lib/jsx_rosetta/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
|
+
require "pathname"
|
|
6
7
|
|
|
7
8
|
require_relative "node_bridge"
|
|
8
9
|
|
|
@@ -15,6 +16,8 @@ module JsxRosetta
|
|
|
15
16
|
# (default: current directory). TSX is detected
|
|
16
17
|
# via the .tsx extension or --tsx.
|
|
17
18
|
# parse FILE Print the parsed Babel AST as pretty JSON.
|
|
19
|
+
# pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory and emit a
|
|
20
|
+
# Rails config/routes.rb skeleton.
|
|
18
21
|
# version Print the gem version.
|
|
19
22
|
# help Show usage.
|
|
20
23
|
class CLI
|
|
@@ -22,6 +25,43 @@ module JsxRosetta
|
|
|
22
25
|
EXIT_USAGE = 64
|
|
23
26
|
EXIT_FAILURE = 1
|
|
24
27
|
|
|
28
|
+
USAGE_TEXT = <<~USAGE
|
|
29
|
+
Usage: jsx_rosetta <command> [args]
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
install Install the gem's Node sidecar dependencies (runs `npm install`).
|
|
33
|
+
translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
|
|
34
|
+
Pass --tsx to force TypeScript parsing if the input is .jsx.
|
|
35
|
+
Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
|
|
36
|
+
instead of a ViewComponent class + sidecar template — appropriate
|
|
37
|
+
for pages tied to a route.
|
|
38
|
+
Pass --as=phlex to emit a single-file Phlex 2.x view class
|
|
39
|
+
(`<snake>.rb`) instead of a ViewComponent. Configure the class
|
|
40
|
+
name with --phlex-suffix=Component or --phlex-namespace=Components
|
|
41
|
+
(mutually exclusive; default is bare class name).
|
|
42
|
+
Pass --rails-routes DIR (with --as=phlex) to place the output
|
|
43
|
+
at <controller>/<action>.rb with class
|
|
44
|
+
Views::<Controller>::<Action> < Views::Base, derived from a
|
|
45
|
+
route table scanned out of DIR (a Next.js pages directory).
|
|
46
|
+
routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
|
|
47
|
+
and emit a reviewable Ruby script that calls `rails generate
|
|
48
|
+
controller` and prints suggested config/routes.rb additions.
|
|
49
|
+
pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory tree and emit a
|
|
50
|
+
Rails config/routes.rb skeleton derived from the file
|
|
51
|
+
layout. Use --ext .tsx,.jsx,.ts,.js to override the
|
|
52
|
+
default `.tsx,.jsx` filter, and --allow-any-dir to
|
|
53
|
+
skip the `basename == 'pages'` safety check.
|
|
54
|
+
Pass --controllers DIR to also emit one
|
|
55
|
+
`<controller>_controller.rb` per controller in DIR
|
|
56
|
+
(existing files are not overwritten).
|
|
57
|
+
parse FILE Parse the input and print the Babel AST as JSON.
|
|
58
|
+
version Print the gem version.
|
|
59
|
+
help Show this help.
|
|
60
|
+
|
|
61
|
+
Environment:
|
|
62
|
+
JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
|
|
63
|
+
USAGE
|
|
64
|
+
|
|
25
65
|
def initialize(argv = ARGV.dup, stdout: $stdout, stderr: $stderr)
|
|
26
66
|
@argv = argv
|
|
27
67
|
@stdout = stdout
|
|
@@ -34,6 +74,7 @@ module JsxRosetta
|
|
|
34
74
|
when "install" then run_install
|
|
35
75
|
when "translate" then run_translate
|
|
36
76
|
when "routes" then run_routes
|
|
77
|
+
when "pages-routes" then run_pages_routes
|
|
37
78
|
when "parse" then run_parse
|
|
38
79
|
when "version", "-v", "--version" then run_version
|
|
39
80
|
when nil, "help", "-h", "--help" then print_help(EXIT_OK)
|
|
@@ -61,6 +102,8 @@ module JsxRosetta
|
|
|
61
102
|
input_path = positional.first
|
|
62
103
|
return missing_argument("translate FILE", "translate") unless input_path
|
|
63
104
|
|
|
105
|
+
resolve_rails_view_route!(options, input_path)
|
|
106
|
+
|
|
64
107
|
out_dir = options[:out] || "."
|
|
65
108
|
typescript = options[:tsx] || input_path.end_with?(".tsx")
|
|
66
109
|
backend = backend_for_as(options[:as])
|
|
@@ -72,7 +115,8 @@ module JsxRosetta
|
|
|
72
115
|
backend: backend,
|
|
73
116
|
backend_options: backend_options,
|
|
74
117
|
typescript: typescript,
|
|
75
|
-
source_filename: input_path
|
|
118
|
+
source_filename: input_path,
|
|
119
|
+
keep_slot: options[:keep_slot] || false
|
|
76
120
|
)
|
|
77
121
|
|
|
78
122
|
write_emitted_files(files, out_dir)
|
|
@@ -93,7 +137,40 @@ module JsxRosetta
|
|
|
93
137
|
def backend_options_for(backend, options)
|
|
94
138
|
return {} unless backend == :phlex
|
|
95
139
|
|
|
96
|
-
{ suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
|
|
140
|
+
base = { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
|
|
141
|
+
base[:rails_view] = options[:rails_view_route] if options[:rails_view_route]
|
|
142
|
+
base[:route_table] = options[:route_table] if options[:route_table]
|
|
143
|
+
base
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_rails_view_route!(options, input_path)
|
|
147
|
+
pages_dir = options[:rails_routes]
|
|
148
|
+
return unless pages_dir
|
|
149
|
+
|
|
150
|
+
raise ArgumentError, "--rails-routes requires --as=phlex" unless options[:as] == "phlex"
|
|
151
|
+
if options[:phlex_suffix] || options[:phlex_namespace]
|
|
152
|
+
raise ArgumentError, "--rails-routes cannot be combined with --phlex-suffix or --phlex-namespace"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
ensure_pages_dir!(pages_dir, allow_any: options[:allow_any_dir])
|
|
156
|
+
rel = relative_path_under(input_path, pages_dir)
|
|
157
|
+
raise ArgumentError, "#{input_path} is not under #{pages_dir}" unless rel
|
|
158
|
+
|
|
159
|
+
routes, _skipped = PagesRouting.scan(pages_dir, extensions: options[:ext] || PagesRouting::DEFAULT_EXTENSIONS)
|
|
160
|
+
route = routes.find { |r| r.source_path == rel }
|
|
161
|
+
raise ArgumentError, "#{rel} has no route in #{pages_dir} (skipped or non-page file?)" unless route
|
|
162
|
+
|
|
163
|
+
options[:rails_view_route] = route
|
|
164
|
+
options[:route_table] = routes
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def relative_path_under(file_path, dir)
|
|
168
|
+
file = Pathname.new(File.expand_path(file_path))
|
|
169
|
+
base = Pathname.new(File.expand_path(dir))
|
|
170
|
+
rel = file.relative_path_from(base).to_s
|
|
171
|
+
rel unless rel.start_with?("..")
|
|
172
|
+
rescue ArgumentError
|
|
173
|
+
nil
|
|
97
174
|
end
|
|
98
175
|
|
|
99
176
|
def write_emitted_files(files, out_dir)
|
|
@@ -129,6 +206,53 @@ module JsxRosetta
|
|
|
129
206
|
EXIT_FAILURE
|
|
130
207
|
end
|
|
131
208
|
|
|
209
|
+
def run_pages_routes
|
|
210
|
+
options, positional = parse_translate_options
|
|
211
|
+
input_dir = positional.first
|
|
212
|
+
return missing_argument("pages-routes DIR [-o OUT.rb]", "pages-routes") unless input_dir
|
|
213
|
+
|
|
214
|
+
ensure_pages_dir!(input_dir, allow_any: options[:allow_any_dir])
|
|
215
|
+
extensions = options[:ext] || PagesRouting::DEFAULT_EXTENSIONS
|
|
216
|
+
routes, skipped = PagesRouting.scan(input_dir, extensions: extensions)
|
|
217
|
+
contents = PagesRouting.emit(routes: routes, skipped: skipped, source_dir: input_dir)
|
|
218
|
+
|
|
219
|
+
if options[:out]
|
|
220
|
+
File.write(options[:out], contents)
|
|
221
|
+
@stdout.puts "wrote #{options[:out]}"
|
|
222
|
+
else
|
|
223
|
+
@stdout.print(contents)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
write_controllers(routes, options[:controllers]) if options[:controllers]
|
|
227
|
+
EXIT_OK
|
|
228
|
+
rescue ArgumentError => e
|
|
229
|
+
@stderr.puts "jsx_rosetta pages-routes: #{e.message}"
|
|
230
|
+
EXIT_FAILURE
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def write_controllers(routes, dir)
|
|
234
|
+
FileUtils.mkdir_p(dir)
|
|
235
|
+
PagesRouting.emit_controllers(routes: routes).each do |file|
|
|
236
|
+
target = File.join(dir, file.path)
|
|
237
|
+
if File.exist?(target)
|
|
238
|
+
@stdout.puts "skipped #{target} (exists)"
|
|
239
|
+
else
|
|
240
|
+
File.write(target, file.contents)
|
|
241
|
+
@stdout.puts "wrote #{target}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def ensure_pages_dir!(dir, allow_any:)
|
|
247
|
+
return if allow_any
|
|
248
|
+
return if File.basename(dir) == "pages"
|
|
249
|
+
return if File.directory?(File.join(dir, "pages"))
|
|
250
|
+
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"#{dir.inspect} does not look like a Next.js pages directory " \
|
|
253
|
+
"(basename != 'pages' and no nested 'pages/'). Pass --allow-any-dir to override."
|
|
254
|
+
end
|
|
255
|
+
|
|
132
256
|
def run_parse
|
|
133
257
|
options, positional = parse_translate_options
|
|
134
258
|
input_path = positional.first
|
|
@@ -157,22 +281,59 @@ module JsxRosetta
|
|
|
157
281
|
|
|
158
282
|
until @argv.empty?
|
|
159
283
|
arg = @argv.shift
|
|
160
|
-
|
|
161
|
-
when "-o", "--out" then options[:out] = @argv.shift
|
|
162
|
-
when "--tsx", "--typescript" then options[:tsx] = true
|
|
163
|
-
when "--as" then options[:as] = @argv.shift
|
|
164
|
-
when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
|
|
165
|
-
when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
|
|
166
|
-
when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
|
|
167
|
-
when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
|
|
168
|
-
when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
|
|
169
|
-
else positional << arg
|
|
170
|
-
end
|
|
284
|
+
positional << arg unless option_consumed?(arg, options)
|
|
171
285
|
end
|
|
172
286
|
|
|
173
287
|
[options, positional]
|
|
174
288
|
end
|
|
175
289
|
|
|
290
|
+
def option_consumed?(arg, options)
|
|
291
|
+
consume_translate_option?(arg, options) ||
|
|
292
|
+
consume_phlex_option?(arg, options) ||
|
|
293
|
+
consume_pages_routes_option?(arg, options)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def consume_translate_option?(arg, options)
|
|
297
|
+
case arg
|
|
298
|
+
when "-o", "--out" then options[:out] = @argv.shift
|
|
299
|
+
when "--tsx", "--typescript" then options[:tsx] = true
|
|
300
|
+
when "--as" then options[:as] = @argv.shift
|
|
301
|
+
when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
|
|
302
|
+
when "--keep-slot" then options[:keep_slot] = true
|
|
303
|
+
else return false
|
|
304
|
+
end
|
|
305
|
+
true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def consume_phlex_option?(arg, options)
|
|
309
|
+
case arg
|
|
310
|
+
when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
|
|
311
|
+
when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
|
|
312
|
+
when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
|
|
313
|
+
when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
|
|
314
|
+
when "--rails-routes" then options[:rails_routes] = @argv.shift
|
|
315
|
+
when /\A--rails-routes=(.+)\z/ then options[:rails_routes] = ::Regexp.last_match(1)
|
|
316
|
+
else return false
|
|
317
|
+
end
|
|
318
|
+
true
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def consume_pages_routes_option?(arg, options)
|
|
322
|
+
case arg
|
|
323
|
+
when "--ext" then options[:ext] = parse_ext_list(@argv.shift)
|
|
324
|
+
when /\A--ext=(.+)\z/ then options[:ext] = parse_ext_list(::Regexp.last_match(1))
|
|
325
|
+
when "--allow-any-dir" then options[:allow_any_dir] = true
|
|
326
|
+
when "--controllers" then options[:controllers] = @argv.shift
|
|
327
|
+
when /\A--controllers=(.+)\z/ then options[:controllers] = ::Regexp.last_match(1)
|
|
328
|
+
else return false
|
|
329
|
+
end
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def parse_ext_list(value)
|
|
334
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?).map { |ext| ext.start_with?(".") ? ext : ".#{ext}" }
|
|
335
|
+
end
|
|
336
|
+
|
|
176
337
|
def missing_argument(usage, command)
|
|
177
338
|
@stderr.puts "jsx_rosetta #{command}: missing required argument."
|
|
178
339
|
@stderr.puts " usage: jsx_rosetta #{usage}"
|
|
@@ -180,30 +341,7 @@ module JsxRosetta
|
|
|
180
341
|
end
|
|
181
342
|
|
|
182
343
|
def print_help(exit_code)
|
|
183
|
-
@stdout.puts
|
|
184
|
-
Usage: jsx_rosetta <command> [args]
|
|
185
|
-
|
|
186
|
-
Commands:
|
|
187
|
-
install Install the gem's Node sidecar dependencies (runs `npm install`).
|
|
188
|
-
translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
|
|
189
|
-
Pass --tsx to force TypeScript parsing if the input is .jsx.
|
|
190
|
-
Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
|
|
191
|
-
instead of a ViewComponent class + sidecar template — appropriate
|
|
192
|
-
for pages tied to a route.
|
|
193
|
-
Pass --as=phlex to emit a single-file Phlex 2.x view class
|
|
194
|
-
(`<snake>.rb`) instead of a ViewComponent. Configure the class
|
|
195
|
-
name with --phlex-suffix=Component or --phlex-namespace=Components
|
|
196
|
-
(mutually exclusive; default is bare class name).
|
|
197
|
-
routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
|
|
198
|
-
and emit a reviewable Ruby script that calls `rails generate
|
|
199
|
-
controller` and prints suggested config/routes.rb additions.
|
|
200
|
-
parse FILE Parse the input and print the Babel AST as JSON.
|
|
201
|
-
version Print the gem version.
|
|
202
|
-
help Show this help.
|
|
203
|
-
|
|
204
|
-
Environment:
|
|
205
|
-
JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
|
|
206
|
-
USAGE
|
|
344
|
+
@stdout.puts USAGE_TEXT
|
|
207
345
|
exit_code
|
|
208
346
|
end
|
|
209
347
|
end
|