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
|
@@ -31,6 +31,40 @@ module JsxRosetta
|
|
|
31
31
|
VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
|
|
32
32
|
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
33
33
|
|
|
34
|
+
# Inline budget for object/array literal rendering. When the
|
|
35
|
+
# single-line rendering of a literal exceeds this width — measured
|
|
36
|
+
# from the opening bracket — it switches to a multi-line layout
|
|
37
|
+
# with one entry per line, indented two spaces past the parent's
|
|
38
|
+
# line indent. Closing bracket re-aligns to the parent indent.
|
|
39
|
+
# Chosen to keep typical attr lines under ~120 chars after the
|
|
40
|
+
# kwarg name and surrounding `render Foo.new(...)` wrapper.
|
|
41
|
+
LITERAL_INLINE_BUDGET = 80
|
|
42
|
+
|
|
43
|
+
# Per-library TODO header lines surfaced above the verbatim hook
|
|
44
|
+
# source. Each library has a different Rails analog, so we don't
|
|
45
|
+
# collapse them into a single generic block. Keys must mirror the
|
|
46
|
+
# `:library` values produced by IR::Lowering.
|
|
47
|
+
HOOK_TODO_HEADERS = {
|
|
48
|
+
react: [
|
|
49
|
+
"TODO: React hooks detected. None translate automatically.",
|
|
50
|
+
"Hotwire/Stimulus handles behavior; controllers/views handle state;",
|
|
51
|
+
"turbo-frames handle async loading. Original source:"
|
|
52
|
+
].freeze,
|
|
53
|
+
apollo: [
|
|
54
|
+
"TODO: Apollo data-fetching hooks detected. None translate automatically.",
|
|
55
|
+
"Move the fetch to the Rails controller (or a model/service); pass the",
|
|
56
|
+
"result in as a prop. For useMutation, use a form POST + redirect or a",
|
|
57
|
+
"Turbo Stream response. Original source:"
|
|
58
|
+
].freeze,
|
|
59
|
+
next_js: [
|
|
60
|
+
"TODO: Next.js navigation hooks detected. None translate automatically.",
|
|
61
|
+
"Rails analogs: useRouter -> redirect_to / form actions;",
|
|
62
|
+
"usePathname -> request.path; useSearchParams / useParams -> params;",
|
|
63
|
+
"useSelectedLayoutSegment(s) -> match against request.path in the view.",
|
|
64
|
+
"Original source:"
|
|
65
|
+
].freeze
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
34
68
|
# Structured intermediate for the data-action attribute — mirrors the
|
|
35
69
|
# ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
|
|
36
70
|
EventDescriptor = Data.define(:kind, :body)
|
|
@@ -44,17 +78,12 @@ module JsxRosetta
|
|
|
44
78
|
end
|
|
45
79
|
|
|
46
80
|
def emit(component)
|
|
47
|
-
|
|
48
|
-
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
49
|
-
translator = ViewComponent::ExpressionTranslator.new(
|
|
50
|
-
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
51
|
-
)
|
|
52
|
-
|
|
81
|
+
translator = build_translator(component)
|
|
53
82
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
54
83
|
@lambda_methods = []
|
|
55
84
|
@lambda_method_counts = {}
|
|
56
85
|
|
|
57
|
-
files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
|
|
86
|
+
files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
|
|
58
87
|
if component.stimulus_methods.any?
|
|
59
88
|
files << File.new(
|
|
60
89
|
path: stimulus_path(component),
|
|
@@ -64,6 +93,56 @@ module JsxRosetta
|
|
|
64
93
|
files
|
|
65
94
|
end
|
|
66
95
|
|
|
96
|
+
def build_translator(component)
|
|
97
|
+
prop_names = component.props.map(&:name)
|
|
98
|
+
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
99
|
+
prop_aliases = component.props.each_with_object({}) do |prop, hash|
|
|
100
|
+
hash[prop.alias_name] = prop.name if prop.alias_name
|
|
101
|
+
end
|
|
102
|
+
ViewComponent::ExpressionTranslator.new(
|
|
103
|
+
prop_names: prop_names,
|
|
104
|
+
local_binding_names: component.local_binding_names,
|
|
105
|
+
prop_aliases: prop_aliases
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Strip trailing whitespace from each emitted line — easier than
|
|
110
|
+
# threading rstrip through every formatting helper, and a single
|
|
111
|
+
# source of truth keeps Layout/TrailingWhitespace at zero. Preserve
|
|
112
|
+
# the trailing newline of the file as-is. Also suppresses the
|
|
113
|
+
# intentional-`if false` cops file-wide so the user's rubocop
|
|
114
|
+
# doesn't drown out actionable findings.
|
|
115
|
+
def clean_output(source)
|
|
116
|
+
cleaned = "#{source.split("\n").map(&:rstrip).join("\n")}\n".sub(/\n\n+\z/, "\n")
|
|
117
|
+
suppress_intentional_if_false_cops(cleaned)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# When the file contains any `if false` / `elsif false` branch (the
|
|
121
|
+
# fallback we emit when a JSX condition can't be translated to Ruby),
|
|
122
|
+
# disable the cops that flag those at file scope. The corresponding
|
|
123
|
+
# `# TODO: translate condition:` comment already names the issue, so
|
|
124
|
+
# the cop's report is redundant noise. Only emits when needed.
|
|
125
|
+
def suppress_intentional_if_false_cops(source)
|
|
126
|
+
cops = []
|
|
127
|
+
cops << "Lint/LiteralAsCondition" if source.match?(/^\s*(?:if|elsif) false$/m)
|
|
128
|
+
# An elsif-false adjacent to its leading `if false` is the only
|
|
129
|
+
# configuration that triggers DuplicateElsifCondition — multiple
|
|
130
|
+
# `if false`s in separate scopes don't qualify. The pattern below
|
|
131
|
+
# matches an `if false` directly followed (after consequent lines)
|
|
132
|
+
# by an `elsif false` at the same indent.
|
|
133
|
+
cops << "Lint/DuplicateElsifCondition" if source.match?(/^(\s*)if false\b[\s\S]*?^\1elsif false\b/m)
|
|
134
|
+
return source if cops.empty?
|
|
135
|
+
|
|
136
|
+
disable = "# rubocop:disable #{cops.join(", ")}\n\n"
|
|
137
|
+
enable = "# rubocop:enable #{cops.join(", ")}\n"
|
|
138
|
+
# Magic comment must be followed by a blank line before any other
|
|
139
|
+
# comment (Layout/EmptyLineAfterMagicComment), and every file-level
|
|
140
|
+
# disable needs a matching enable (Lint/MissingCopEnableDirective).
|
|
141
|
+
with_disable = source.sub(/^# frozen_string_literal: true\n\n/,
|
|
142
|
+
"# frozen_string_literal: true\n\n#{disable}")
|
|
143
|
+
"#{with_disable.chomp}\n#{enable}"
|
|
144
|
+
end
|
|
145
|
+
|
|
67
146
|
private
|
|
68
147
|
|
|
69
148
|
# JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
|
|
@@ -117,22 +196,78 @@ module JsxRosetta
|
|
|
117
196
|
|
|
118
197
|
def render_class_body(component, translator)
|
|
119
198
|
initializer = render_initializer(component, translator)
|
|
120
|
-
template =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
199
|
+
template = if component.mode == :data_factory
|
|
200
|
+
render_data_factory_method(component, translator)
|
|
201
|
+
else
|
|
202
|
+
render_view_template(component, translator)
|
|
203
|
+
end
|
|
204
|
+
# Render private methods (render_methods + lambdas) AFTER the
|
|
205
|
+
# template — `@lambda_methods` is populated during attribute-value
|
|
206
|
+
# rendering, and render_methods bodies share the same indent.
|
|
207
|
+
private_section = render_private_methods(component, translator)
|
|
208
|
+
|
|
209
|
+
sections = [initializer, template, private_section].compact.join("\n\n")
|
|
210
|
+
cls = class_name(component)
|
|
211
|
+
# One-line docstring above the class — pacifies Style/Documentation
|
|
212
|
+
# without forcing the host project to disable the cop globally. The
|
|
213
|
+
# body is intentionally minimal so it doesn't drift from the source
|
|
214
|
+
# over time; a richer comment belongs in the host repo's review.
|
|
215
|
+
"# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
|
|
216
|
+
"class #{cls} < #{PHLEX_BASE_CLASS}\n#{sections}\nend\n"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# For data-factory components (`export const createColumns = (args)
|
|
220
|
+
# => [{...}, {...}]`) emit a public method that returns the
|
|
221
|
+
# translated data array. The method name is the snake_case of the
|
|
222
|
+
# original JS identifier (`createColumns` → `create_columns`).
|
|
223
|
+
# Param names come from the regular `props:` list so callers can
|
|
224
|
+
# invoke with keyword arguments matching the JS signature.
|
|
225
|
+
def render_data_factory_method(component, translator)
|
|
226
|
+
method_name = AST::Inflector.underscore(component.name)
|
|
227
|
+
param_names = component.props.map(&:name)
|
|
228
|
+
signature = data_factory_signature(method_name, param_names)
|
|
229
|
+
# Param refs translate as locals (`token`) inside the body rather
|
|
230
|
+
# than as ivars (`@token`) — the factory params are method-local,
|
|
231
|
+
# not constructor-stored. `with_locals` pushes the JS names onto
|
|
232
|
+
# the translator's local stack for the duration of the body.
|
|
233
|
+
body = translator.with_locals(param_names) do
|
|
234
|
+
render_inline_value(component.body, translator, todos: [], attr_name: nil, indent: 4)
|
|
235
|
+
end
|
|
236
|
+
" def #{signature}\n #{body}\n end"
|
|
127
237
|
end
|
|
128
238
|
|
|
129
|
-
def
|
|
130
|
-
return
|
|
239
|
+
def data_factory_signature(method_name, param_names)
|
|
240
|
+
return method_name if param_names.empty?
|
|
131
241
|
|
|
132
|
-
|
|
242
|
+
kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
|
|
243
|
+
"#{method_name}(#{kwargs.join(", ")})"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Coalesce RenderMethod (from local-arrow extraction) and Lambda
|
|
247
|
+
# (from Gap H object-literal extraction) into one `private` section.
|
|
248
|
+
# Emitting `private` twice is harmless but ugly; one block reads
|
|
249
|
+
# cleaner.
|
|
250
|
+
def render_private_methods(component, translator)
|
|
251
|
+
render_methods = component.render_methods.map { |rm| render_render_method_definition(rm, translator) }
|
|
252
|
+
lambda_methods = (@lambda_methods || []).map do |entry|
|
|
133
253
|
render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
|
|
134
254
|
end
|
|
135
|
-
|
|
255
|
+
all = render_methods + lambda_methods
|
|
256
|
+
return nil if all.empty?
|
|
257
|
+
|
|
258
|
+
" private\n\n#{all.join("\n\n")}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Emit one RenderMethod as a private method on the class. Params are
|
|
262
|
+
# pushed into the translator scope so identifier references inside
|
|
263
|
+
# the body resolve to method-local arguments.
|
|
264
|
+
def render_render_method_definition(render_method, translator)
|
|
265
|
+
snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
|
|
266
|
+
signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
|
|
267
|
+
body = translator.with_locals(render_method.params) do
|
|
268
|
+
render_ir_node(render_method.body, translator, indent: 4)
|
|
269
|
+
end
|
|
270
|
+
" def #{signature}\n#{body}\n end"
|
|
136
271
|
end
|
|
137
272
|
|
|
138
273
|
def render_lambda_method_definition(method_name, lambda, translator)
|
|
@@ -145,20 +280,30 @@ module JsxRosetta
|
|
|
145
280
|
end
|
|
146
281
|
|
|
147
282
|
def render_initializer(component, translator)
|
|
283
|
+
# Data-factory components consume their params as method args, not
|
|
284
|
+
# as constructor props — skip the initializer entirely.
|
|
285
|
+
return nil if component.mode == :data_factory
|
|
286
|
+
|
|
148
287
|
props = initializable_props(component)
|
|
149
288
|
rest_name = component.rest_prop_name
|
|
150
289
|
return nil if props.empty? && rest_name.nil?
|
|
151
290
|
|
|
291
|
+
# Snake-case the rest-name kwarg so it matches the snake_case ivar
|
|
292
|
+
# the body uses (`**(@description_props || {})`). Emitting the
|
|
293
|
+
# camelCase JS name straight to the kwarg would create a different
|
|
294
|
+
# ivar than the body reads, silently dropping the splat's contents.
|
|
295
|
+
rest_snake = rest_name && AST::Inflector.underscore(rest_name)
|
|
152
296
|
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
153
|
-
kwargs << "**#{
|
|
297
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
154
298
|
|
|
155
|
-
|
|
299
|
+
body = [" super()"]
|
|
300
|
+
body.concat(props.map do |prop|
|
|
156
301
|
snake = AST::Inflector.underscore(prop.name)
|
|
157
302
|
" @#{snake} = #{snake}"
|
|
158
|
-
end
|
|
159
|
-
|
|
303
|
+
end)
|
|
304
|
+
body << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
160
305
|
|
|
161
|
-
" def initialize(#{kwargs.join(", ")})\n#{
|
|
306
|
+
" def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
|
|
162
307
|
end
|
|
163
308
|
|
|
164
309
|
def initializable_props(component)
|
|
@@ -179,10 +324,13 @@ module JsxRosetta
|
|
|
179
324
|
translated = translator.translate(prop.default.expression)
|
|
180
325
|
translated ? translated.ruby : "nil"
|
|
181
326
|
when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
|
|
182
|
-
# Inline values: route through the recursive renderer
|
|
183
|
-
#
|
|
184
|
-
# parameter
|
|
185
|
-
|
|
327
|
+
# Inline values: route through the recursive renderer with
|
|
328
|
+
# `force_inline: true`. A wrapped multi-line default inside the
|
|
329
|
+
# `initialize(...)` parameter list would put the `{` at one
|
|
330
|
+
# column and the children at the indent-aligned column —
|
|
331
|
+
# legal Ruby but trips Layout/FirstHashElementIndentation. Empty
|
|
332
|
+
# todos array — TODO markers wouldn't survive a parameter list.
|
|
333
|
+
render_inline_value(prop.default, translator, todos: [], attr_name: prop.name, force_inline: true)
|
|
186
334
|
else
|
|
187
335
|
"nil"
|
|
188
336
|
end
|
|
@@ -207,12 +355,19 @@ module JsxRosetta
|
|
|
207
355
|
def render_react_hooks_todo(hooks)
|
|
208
356
|
return [] if hooks.empty?
|
|
209
357
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
358
|
+
# Preserve the source order of the first occurrence per library so
|
|
359
|
+
# the React block (typical) lands before Apollo/Next.js blocks when
|
|
360
|
+
# 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) }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def hook_todo_block_lines(library, calls)
|
|
365
|
+
header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
|
|
366
|
+
lines = header_lines.map { |line| "# #{line}" }
|
|
367
|
+
calls.each do |call|
|
|
368
|
+
lines << "# operation: #{call.operation}" if call.operation
|
|
369
|
+
lines.concat(comment_lines(call.source))
|
|
370
|
+
end
|
|
216
371
|
lines
|
|
217
372
|
end
|
|
218
373
|
|
|
@@ -249,6 +404,7 @@ module JsxRosetta
|
|
|
249
404
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
250
405
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
251
406
|
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
407
|
+
when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
|
|
252
408
|
when IR::Slot then render_slot(node, indent: indent)
|
|
253
409
|
when IR::Text then render_text(node, indent: indent)
|
|
254
410
|
when IR::Interpolation then render_interpolation(node, translator, indent: indent)
|
|
@@ -256,6 +412,23 @@ module JsxRosetta
|
|
|
256
412
|
end
|
|
257
413
|
end
|
|
258
414
|
|
|
415
|
+
# Emit a call to a previously-extracted RenderMethod. The method body
|
|
416
|
+
# uses `tag.*`/`render` helpers (Phlex executes inside the view), so
|
|
417
|
+
# invoking it inline produces output at the right place in the
|
|
418
|
+
# template. Arg expressions are translated; any that fail translation
|
|
419
|
+
# fall back to verbatim source.
|
|
420
|
+
def render_local_render_call(call, translator, indent:)
|
|
421
|
+
if call.args.empty?
|
|
422
|
+
"#{spaces(indent)}#{call.method_name}"
|
|
423
|
+
else
|
|
424
|
+
arg_sources = call.args.map do |arg|
|
|
425
|
+
translated = translator.translate(arg.expression)
|
|
426
|
+
translated ? translated.ruby : arg.expression
|
|
427
|
+
end
|
|
428
|
+
"#{spaces(indent)}#{call.method_name}(#{arg_sources.join(", ")})"
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
259
432
|
# An orphan RenderProp (i.e. one that didn't get consumed as a block
|
|
260
433
|
# by a parent ComponentInvocation). Emit the body inline within the
|
|
261
434
|
# appropriate translator scope; the param names are pushed but no
|
|
@@ -268,7 +441,7 @@ module JsxRosetta
|
|
|
268
441
|
|
|
269
442
|
def render_element(element, translator, indent:)
|
|
270
443
|
todos = []
|
|
271
|
-
attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos)
|
|
444
|
+
attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos, indent: indent)
|
|
272
445
|
method_call = "#{element.tag}#{attrs_source}"
|
|
273
446
|
|
|
274
447
|
body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
|
|
@@ -283,7 +456,7 @@ module JsxRosetta
|
|
|
283
456
|
|
|
284
457
|
def render_component_invocation(invocation, translator, indent:)
|
|
285
458
|
todos = []
|
|
286
|
-
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos)
|
|
459
|
+
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: indent)
|
|
287
460
|
class_ref = component_class_reference(invocation.name)
|
|
288
461
|
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
289
462
|
|
|
@@ -335,17 +508,34 @@ module JsxRosetta
|
|
|
335
508
|
end
|
|
336
509
|
|
|
337
510
|
def render_conditional(conditional, translator, indent:)
|
|
338
|
-
test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
339
511
|
lines = []
|
|
512
|
+
emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
|
|
513
|
+
lines << "#{spaces(indent)}end"
|
|
514
|
+
lines.join("\n")
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Walk a Conditional and its `alternate` chain, emitting `if` for the
|
|
518
|
+
# first test, `elsif` for each alternate that is itself a Conditional,
|
|
519
|
+
# and a final `else` for a non-Conditional alternate. Flattens the
|
|
520
|
+
# `if X / else / if Y / end / end` shape that JS `else if` chains
|
|
521
|
+
# produce into idiomatic Ruby `if X / elsif Y / else / end`. Without
|
|
522
|
+
# this, deeply nested conditional chains explode the file's
|
|
523
|
+
# indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
|
|
524
|
+
def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
|
|
525
|
+
test_ruby, todo = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
340
526
|
lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
|
|
341
|
-
lines << "#{spaces(indent)}
|
|
527
|
+
lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
|
|
342
528
|
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
343
|
-
|
|
529
|
+
|
|
530
|
+
alt = conditional.alternate
|
|
531
|
+
return unless alt
|
|
532
|
+
|
|
533
|
+
if alt.is_a?(IR::Conditional)
|
|
534
|
+
emit_conditional_branches(alt, translator, indent, lines, leading_keyword: "elsif")
|
|
535
|
+
else
|
|
344
536
|
lines << "#{spaces(indent)}else"
|
|
345
|
-
lines << render_ir_node(
|
|
537
|
+
lines << render_ir_node(alt, translator, indent: indent + 2)
|
|
346
538
|
end
|
|
347
|
-
lines << "#{spaces(indent)}end"
|
|
348
|
-
lines.join("\n")
|
|
349
539
|
end
|
|
350
540
|
|
|
351
541
|
def render_loop(loop_node, translator, indent:)
|
|
@@ -389,9 +579,15 @@ module JsxRosetta
|
|
|
389
579
|
# can be emitted above the call. Without this, JS operators like
|
|
390
580
|
# `!==`, `===`, optional chaining, and `in` would leak into the
|
|
391
581
|
# emitted Ruby and produce SyntaxError on load.
|
|
582
|
+
#
|
|
583
|
+
# A translated value of `"nil"` is treated as untranslatable: the
|
|
584
|
+
# translator returns `"nil"` for known-local bindings (so the file
|
|
585
|
+
# loads as a leaf reference), but driving an `if` with `nil` silently
|
|
586
|
+
# disables the whole branch. Fall through to the TODO path instead so
|
|
587
|
+
# the human reviewer sees what needs filling in.
|
|
392
588
|
def safe_test_expression(expression, translator, fallback:)
|
|
393
589
|
translated = translator.translate(expression)
|
|
394
|
-
return [translated.ruby, nil] if translated
|
|
590
|
+
return [translated.ruby, nil] if translated && translated.ruby != "nil"
|
|
395
591
|
|
|
396
592
|
compact = expression.tr("\n", " ").squeeze(" ")
|
|
397
593
|
[fallback, compact]
|
|
@@ -406,7 +602,7 @@ module JsxRosetta
|
|
|
406
602
|
end
|
|
407
603
|
|
|
408
604
|
def render_text(text, indent:)
|
|
409
|
-
"#{spaces(indent)}plain #{text.value
|
|
605
|
+
"#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
|
|
410
606
|
end
|
|
411
607
|
|
|
412
608
|
def render_interpolation(interpolation, translator, indent:)
|
|
@@ -443,8 +639,9 @@ module JsxRosetta
|
|
|
443
639
|
# renders something visible at runtime.
|
|
444
640
|
def render_untranslated_interpolation(expression, indent)
|
|
445
641
|
compact = expression.tr("\n", " ").squeeze(" ")
|
|
642
|
+
placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
|
|
446
643
|
"#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
|
|
447
|
-
"#{spaces(indent)}plain #{
|
|
644
|
+
"#{spaces(indent)}plain #{placeholder}"
|
|
448
645
|
end
|
|
449
646
|
|
|
450
647
|
def render_comment(comment, indent:)
|
|
@@ -459,31 +656,30 @@ module JsxRosetta
|
|
|
459
656
|
# of `h1()`). The `context:` param selects naming convention:
|
|
460
657
|
# - :html (HTML element attrs — preserve camelCase for SVG)
|
|
461
658
|
# - :component (Ruby method args — snake_case via Inflector.underscore)
|
|
462
|
-
def format_attributes(attributes, translator, context: :html, todos: [])
|
|
659
|
+
def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
|
|
463
660
|
events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
464
661
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
465
662
|
|
|
466
|
-
|
|
467
|
-
str_parts = []
|
|
663
|
+
parts = { sym: [], str: [] }
|
|
468
664
|
plain_attrs.each do |a|
|
|
469
|
-
append_attribute_part(a, translator,
|
|
665
|
+
append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
|
|
470
666
|
end
|
|
471
|
-
|
|
667
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
472
668
|
|
|
473
|
-
joined = build_attribute_list(
|
|
669
|
+
joined = build_attribute_list(parts, spreads, translator)
|
|
474
670
|
joined.empty? ? "" : "(#{joined})"
|
|
475
671
|
end
|
|
476
672
|
|
|
477
|
-
def append_attribute_part(attribute, translator,
|
|
478
|
-
part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
|
|
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)
|
|
479
675
|
return unless part
|
|
480
676
|
|
|
481
|
-
(part[:string_key] ?
|
|
677
|
+
(part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
|
|
482
678
|
end
|
|
483
679
|
|
|
484
|
-
def build_attribute_list(
|
|
485
|
-
pieces =
|
|
486
|
-
pieces << "**{ #{
|
|
680
|
+
def build_attribute_list(parts, spreads, translator)
|
|
681
|
+
pieces = parts[:sym].dup
|
|
682
|
+
pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
|
|
487
683
|
pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
|
|
488
684
|
pieces.join(", ")
|
|
489
685
|
end
|
|
@@ -491,12 +687,13 @@ module JsxRosetta
|
|
|
491
687
|
# Emit one attribute as either a {string_key: false, source: "id: @x"}
|
|
492
688
|
# (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
|
|
493
689
|
# (rare; non-identifier name — goes into a **{ ... } splat).
|
|
494
|
-
def phlex_attribute_part(attribute, translator, context:, todos:)
|
|
690
|
+
def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0)
|
|
495
691
|
case attribute
|
|
496
692
|
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
|
|
497
693
|
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
|
|
498
|
-
when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator)}" }
|
|
499
|
-
when IR::Attribute
|
|
694
|
+
when IR::Style then { string_key: false, source: "style: #{style_to_ruby_string(attribute, translator, todos: todos)}" }
|
|
695
|
+
when IR::Attribute
|
|
696
|
+
plain_attribute_part(attribute, translator, context: context, todos: todos, indent: indent)
|
|
500
697
|
end
|
|
501
698
|
end
|
|
502
699
|
|
|
@@ -515,8 +712,8 @@ module JsxRosetta
|
|
|
515
712
|
# follow snake_case convention (`defaultValue` → `default_value`).
|
|
516
713
|
# Names that aren't valid Ruby identifiers after conversion (rare:
|
|
517
714
|
# `xml:lang` and friends) fall back to a quoted string key.
|
|
518
|
-
def plain_attribute_part(attribute, translator, context:, todos:)
|
|
519
|
-
value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos)
|
|
715
|
+
def plain_attribute_part(attribute, translator, context:, todos:, indent: 0)
|
|
716
|
+
value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator, todos: todos, indent: indent)
|
|
520
717
|
ruby_name = case context
|
|
521
718
|
when :component then AST::Inflector.underscore(attribute.name)
|
|
522
719
|
else attribute.name.tr("-", "_")
|
|
@@ -524,17 +721,17 @@ module JsxRosetta
|
|
|
524
721
|
if ruby_name.match?(VALID_IDENTIFIER)
|
|
525
722
|
{ string_key: false, source: "#{ruby_name}: #{value_ruby}" }
|
|
526
723
|
else
|
|
527
|
-
{ string_key: true, source: "#{attribute.name
|
|
724
|
+
{ string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
|
|
528
725
|
end
|
|
529
726
|
end
|
|
530
727
|
|
|
531
|
-
def attribute_value_to_ruby(name, value, translator, todos:)
|
|
728
|
+
def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
|
|
532
729
|
case value
|
|
533
730
|
when true then "true"
|
|
534
|
-
when String then value
|
|
731
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
535
732
|
when IR::Interpolation then interpolated_attribute_value(name, value, translator, todos: todos)
|
|
536
|
-
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos)
|
|
537
|
-
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos)
|
|
733
|
+
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
|
|
734
|
+
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
|
|
538
735
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
|
|
539
736
|
end
|
|
540
737
|
end
|
|
@@ -542,41 +739,75 @@ module JsxRosetta
|
|
|
542
739
|
# Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
|
|
543
740
|
# entries become Ruby kwargs (snake_cased to match Ruby convention);
|
|
544
741
|
# non-identifier keys (numeric, hyphenated) fall back to string keys.
|
|
545
|
-
|
|
742
|
+
# When the single-line rendering exceeds LITERAL_INLINE_BUDGET, or
|
|
743
|
+
# when any rendered child value spans multiple lines, the layout
|
|
744
|
+
# switches to one entry per line, indented two spaces past `indent`.
|
|
745
|
+
def render_object_literal_value(object_literal, translator, todos:, indent: 0, force_inline: false)
|
|
746
|
+
child_indent = indent + 2
|
|
546
747
|
parts = object_literal.properties.map do |(key, value)|
|
|
547
|
-
render_object_property(key, value, translator, todos: todos)
|
|
748
|
+
render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
|
|
548
749
|
end
|
|
549
|
-
"{
|
|
750
|
+
wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
|
|
751
|
+
force_inline: force_inline)
|
|
550
752
|
end
|
|
551
753
|
|
|
552
|
-
def render_object_property(key, value, translator, todos:)
|
|
553
|
-
value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key
|
|
754
|
+
def render_object_property(key, value, translator, todos:, indent: 0, force_inline: false)
|
|
755
|
+
value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key, indent: indent,
|
|
756
|
+
force_inline: force_inline)
|
|
554
757
|
snake = AST::Inflector.underscore(key)
|
|
555
758
|
if snake.match?(VALID_IDENTIFIER)
|
|
556
759
|
"#{snake}: #{value_ruby}"
|
|
557
760
|
else
|
|
558
|
-
"#{key
|
|
761
|
+
"#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
|
|
559
762
|
end
|
|
560
763
|
end
|
|
561
764
|
|
|
562
|
-
def render_array_literal_value(array_literal, translator, todos:)
|
|
765
|
+
def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
|
|
766
|
+
child_indent = indent + 2
|
|
563
767
|
parts = array_literal.elements.map do |el|
|
|
564
|
-
el.nil?
|
|
768
|
+
if el.nil?
|
|
769
|
+
"nil"
|
|
770
|
+
else
|
|
771
|
+
render_inline_value(el, translator, todos: todos, attr_name: nil, indent: child_indent,
|
|
772
|
+
force_inline: force_inline)
|
|
773
|
+
end
|
|
565
774
|
end
|
|
566
|
-
"[
|
|
775
|
+
wrap_literal_parts(parts, open: "[", close: "]", indent: indent, inline_sep: ", ", inline_pad: "",
|
|
776
|
+
force_inline: force_inline)
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Pick single-line vs multi-line layout for a rendered literal.
|
|
780
|
+
# Multi-line is forced when any rendered part already contains a
|
|
781
|
+
# newline (a nested literal that wrapped); otherwise we wrap only
|
|
782
|
+
# when the single-line form exceeds LITERAL_INLINE_BUDGET.
|
|
783
|
+
def wrap_literal_parts(parts, **opts)
|
|
784
|
+
open = opts[:open]
|
|
785
|
+
close = opts[:close]
|
|
786
|
+
return "#{open}#{close}" if parts.empty?
|
|
787
|
+
|
|
788
|
+
inline = "#{open}#{opts[:inline_pad]}#{parts.join(opts[:inline_sep])}#{opts[:inline_pad]}#{close}"
|
|
789
|
+
any_multiline = parts.any? { |p| p.include?("\n") }
|
|
790
|
+
return inline if opts[:force_inline]
|
|
791
|
+
return inline if !any_multiline && inline.length <= LITERAL_INLINE_BUDGET
|
|
792
|
+
|
|
793
|
+
child_pad = " " * (opts[:indent] + 2)
|
|
794
|
+
close_pad = " " * opts[:indent]
|
|
795
|
+
"#{open}\n#{child_pad}#{parts.join(",\n#{child_pad}")}\n#{close_pad}#{close}"
|
|
567
796
|
end
|
|
568
797
|
|
|
569
798
|
# An inline value can appear as a kwarg value, an array element, or a
|
|
570
799
|
# hash property value. Recursive shapes route back through the new IR
|
|
571
800
|
# types; primitives fall through the same paths as attribute_value_to_ruby.
|
|
572
|
-
def render_inline_value(value, translator, todos:, attr_name:)
|
|
801
|
+
def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
|
|
573
802
|
case value
|
|
574
|
-
when IR::ObjectLiteral
|
|
575
|
-
|
|
803
|
+
when IR::ObjectLiteral
|
|
804
|
+
render_object_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
|
|
805
|
+
when IR::ArrayLiteral
|
|
806
|
+
render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
|
|
576
807
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
|
|
577
808
|
when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
|
|
578
809
|
todos: todos)
|
|
579
|
-
when String then value
|
|
810
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
580
811
|
when true then "true"
|
|
581
812
|
else
|
|
582
813
|
"nil"
|
|
@@ -612,43 +843,51 @@ module JsxRosetta
|
|
|
612
843
|
@lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
|
|
613
844
|
end
|
|
614
845
|
|
|
615
|
-
# Attribute-position interpolation.
|
|
616
|
-
# 1. Translator returns non-nil
|
|
617
|
-
# the Ruby reference
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
#
|
|
621
|
-
#
|
|
846
|
+
# Attribute-position interpolation. Three failure modes:
|
|
847
|
+
# 1. Translator returns non-nil, no unresolved identifiers — emit
|
|
848
|
+
# the Ruby reference directly. Common case.
|
|
849
|
+
# 2. Translator returns non-nil but with unresolved identifiers
|
|
850
|
+
# starting with uppercase (PascalCase / SCREAMING_SNAKE_CASE —
|
|
851
|
+
# almost always imported constants or enums) — emitting bare
|
|
852
|
+
# `default_page_size` from `DEFAULT_PAGE_SIZE` produces a
|
|
853
|
+
# runtime NameError with no marker. Drop the value to `nil`
|
|
854
|
+
# and surface a TODO with the verbatim source. Lowercase
|
|
855
|
+
# unresolved identifiers may be Rails helpers (`current_user`)
|
|
856
|
+
# and are passed through as before.
|
|
857
|
+
# 3. Translator returns nil — the original JS expression couldn't
|
|
622
858
|
# be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
|
|
623
|
-
# template literals with method calls).
|
|
624
|
-
# kwarg AND record the original expression in `todos` so the
|
|
625
|
-
# caller can prepend a `# TODO:` comment line above the element.
|
|
859
|
+
# template literals with method calls). Same TODO + nil path.
|
|
626
860
|
def interpolated_attribute_value(name, value, translator, todos:)
|
|
627
861
|
translated = translator.translate(value.expression)
|
|
628
|
-
|
|
862
|
+
if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
|
|
863
|
+
return translated.ruby
|
|
864
|
+
end
|
|
629
865
|
|
|
630
866
|
compact = value.expression.tr("\n", " ").squeeze(" ")
|
|
631
867
|
todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
|
|
632
868
|
"nil"
|
|
633
869
|
end
|
|
634
870
|
|
|
635
|
-
def
|
|
871
|
+
def uppercase_unresolved?(unresolved_identifiers)
|
|
872
|
+
unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def component_invocation_kwargs(props, translator, todos: [], indent: 0)
|
|
636
876
|
events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
637
877
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
638
878
|
|
|
639
|
-
|
|
640
|
-
str_parts = []
|
|
879
|
+
parts = { sym: [], str: [] }
|
|
641
880
|
plain_attrs.each do |a|
|
|
642
|
-
append_attribute_part(a, translator,
|
|
881
|
+
append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
|
|
643
882
|
end
|
|
644
|
-
|
|
883
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
645
884
|
|
|
646
|
-
build_attribute_list(
|
|
885
|
+
build_attribute_list(parts, spreads, translator)
|
|
647
886
|
end
|
|
648
887
|
|
|
649
888
|
def class_list_to_ruby_string(class_list, translator)
|
|
650
889
|
parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
|
|
651
|
-
|
|
890
|
+
wrap_concatenated_string(parts.join(" "))
|
|
652
891
|
end
|
|
653
892
|
|
|
654
893
|
def class_segment_to_ruby(segment, translator)
|
|
@@ -660,23 +899,46 @@ module JsxRosetta
|
|
|
660
899
|
when IR::ConditionalSegment
|
|
661
900
|
cond_translated = translator.translate(segment.condition.expression)
|
|
662
901
|
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
663
|
-
%(\#{#{cond_ruby} ? #{segment.class_name
|
|
902
|
+
%(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
|
|
664
903
|
end
|
|
665
904
|
end
|
|
666
905
|
|
|
667
|
-
def style_to_ruby_string(style, translator)
|
|
668
|
-
parts = style.declarations.
|
|
669
|
-
|
|
906
|
+
def style_to_ruby_string(style, translator, todos: [])
|
|
907
|
+
parts = style.declarations.filter_map { |decl| style_declaration_to_ruby(decl, translator, todos: todos) }
|
|
908
|
+
wrap_concatenated_string(parts.join(" "))
|
|
670
909
|
end
|
|
671
910
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
"#{
|
|
911
|
+
# Wrap a built-up Ruby string body in single quotes when it contains
|
|
912
|
+
# no interpolation (`\#{...}`) and no escaping pitfalls; otherwise
|
|
913
|
+
# use double quotes so the interpolation is honored. Keeps class /
|
|
914
|
+
# style attribute output passing Style/StringLiterals when no
|
|
915
|
+
# dynamic segments are present (the common case for hardcoded
|
|
916
|
+
# `style="margin-bottom: 16px"`).
|
|
917
|
+
def wrap_concatenated_string(body)
|
|
918
|
+
return %("#{body}") if body.include?("\#{") || body.include?("'") || body.include?("\\")
|
|
919
|
+
|
|
920
|
+
"'#{body}'"
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def style_declaration_to_ruby(decl, translator, todos: [])
|
|
924
|
+
case decl.value
|
|
925
|
+
when String
|
|
926
|
+
"#{decl.property}: #{decl.value};"
|
|
927
|
+
when IR::Interpolation
|
|
928
|
+
translated = translator.translate(decl.value.expression)
|
|
929
|
+
# Translation failed (e.g., interpolation rooted at an
|
|
930
|
+
# unresolvable local). Emitting the verbatim JS source inside
|
|
931
|
+
# `\#{}` would render valid Ruby that NameErrors at runtime.
|
|
932
|
+
# Drop the declaration and surface a TODO above the element so
|
|
933
|
+
# the reviewer sees what was lost.
|
|
934
|
+
if translated.nil?
|
|
935
|
+
todos << "style declaration #{decl.property.inspect} dropped — " \
|
|
936
|
+
"couldn't translate: #{decl.value.expression}"
|
|
937
|
+
nil
|
|
938
|
+
else
|
|
939
|
+
"#{decl.property}: \#{#{translated.ruby}};"
|
|
940
|
+
end
|
|
941
|
+
end
|
|
680
942
|
end
|
|
681
943
|
|
|
682
944
|
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
@@ -712,7 +974,7 @@ module JsxRosetta
|
|
|
712
974
|
end
|
|
713
975
|
|
|
714
976
|
def render_single_event_descriptor(descriptor)
|
|
715
|
-
descriptor.kind == :literal ?
|
|
977
|
+
descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
|
|
716
978
|
end
|
|
717
979
|
|
|
718
980
|
def descriptor_in_string(descriptor)
|