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
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/inflector"
|
|
4
|
+
require_relative "../ir/types"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "view_component/expression_translator"
|
|
7
|
+
|
|
8
|
+
module JsxRosetta
|
|
9
|
+
module Backend
|
|
10
|
+
# Emits a Phlex 2.x view class (one Ruby file per component) from
|
|
11
|
+
# an IR::Component. Single-file output by design — the JSX `<h1>...`
|
|
12
|
+
# template lives as Ruby inside `view_template`, not in a sibling
|
|
13
|
+
# .erb. When the source uses `onClick`/`onChange` etc., a sibling
|
|
14
|
+
# Stimulus controller `_controller.js` is emitted alongside (same
|
|
15
|
+
# convention as the ViewComponent backend).
|
|
16
|
+
#
|
|
17
|
+
# Naming strategies (mutually exclusive):
|
|
18
|
+
# default class FlashyHeader < Phlex::HTML
|
|
19
|
+
# suffix: "Component" class FlashyHeaderComponent < Phlex::HTML
|
|
20
|
+
# namespace: "Components" module Components
|
|
21
|
+
# class FlashyHeader < Phlex::HTML
|
|
22
|
+
#
|
|
23
|
+
# Hyphenated attributes (`data-testid`, `aria-label`, etc.) emit as
|
|
24
|
+
# string-keyed hash entries inside a splat — `**{ "data-testid" => @x }`
|
|
25
|
+
# — since Ruby kwargs can't carry hyphens. Snake_case-friendly attrs
|
|
26
|
+
# emit as regular keyword arguments.
|
|
27
|
+
class Phlex < Base
|
|
28
|
+
DEFAULT_SLOT_NAME = "children"
|
|
29
|
+
DEFAULT_SUFFIX = "Component"
|
|
30
|
+
PHLEX_BASE_CLASS = "Phlex::HTML"
|
|
31
|
+
VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
|
|
32
|
+
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
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
|
+
|
|
68
|
+
# Structured intermediate for the data-action attribute — mirrors the
|
|
69
|
+
# ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
|
|
70
|
+
EventDescriptor = Data.define(:kind, :body)
|
|
71
|
+
|
|
72
|
+
def initialize(suffix: nil, namespace: nil)
|
|
73
|
+
super()
|
|
74
|
+
raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
|
|
75
|
+
|
|
76
|
+
@suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
|
|
77
|
+
@namespace = namespace
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def emit(component)
|
|
81
|
+
translator = build_translator(component)
|
|
82
|
+
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
83
|
+
@lambda_methods = []
|
|
84
|
+
@lambda_method_counts = {}
|
|
85
|
+
|
|
86
|
+
files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
|
|
87
|
+
if component.stimulus_methods.any?
|
|
88
|
+
files << File.new(
|
|
89
|
+
path: stimulus_path(component),
|
|
90
|
+
contents: render_stimulus_controller_js(component)
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
files
|
|
94
|
+
end
|
|
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
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# JSX-returning lowercase helpers (e.g. `textRender`, `getNodeIcon`)
|
|
149
|
+
# have lowercase-starting names. Ruby class names must be constants
|
|
150
|
+
# (begin with an uppercase letter), so we capitalize the first
|
|
151
|
+
# letter when forming the class name. Pure-PascalCase names pass
|
|
152
|
+
# through unchanged.
|
|
153
|
+
def class_name(component)
|
|
154
|
+
base = "#{component.name[0].upcase}#{component.name[1..]}"
|
|
155
|
+
@suffix ? "#{base}#{@suffix}" : base
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ruby_path(component)
|
|
159
|
+
"#{AST::Inflector.underscore(class_name(component))}.rb"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def stimulus_path(component)
|
|
163
|
+
"#{AST::Inflector.underscore(class_name(component))}_controller.js"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def stimulus_identifier(component)
|
|
167
|
+
AST::Inflector.underscore(component.name).tr("_", "-")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def render_ruby_class(component, translator)
|
|
171
|
+
class_body = render_class_body(component, translator)
|
|
172
|
+
prefix = render_module_bindings_prefix(component)
|
|
173
|
+
wrap_in_namespace("#{prefix}#{class_body}")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Top-level `const`/`let` declarations outside the component
|
|
177
|
+
# function — captured at lowering time and surfaced here as a TODO
|
|
178
|
+
# comment block above the class definition. We don't try to
|
|
179
|
+
# translate the JS; the human reviewer either copies the value as
|
|
180
|
+
# a Ruby constant or moves it to a Rails initializer.
|
|
181
|
+
def render_module_bindings_prefix(component)
|
|
182
|
+
return "" if component.module_bindings.empty?
|
|
183
|
+
|
|
184
|
+
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
185
|
+
"or move to a Rails initializer:"]
|
|
186
|
+
component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
187
|
+
"#{lines.join("\n")}\n"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def wrap_in_namespace(body)
|
|
191
|
+
return "# frozen_string_literal: true\n\n#{body}" unless @namespace
|
|
192
|
+
|
|
193
|
+
indented = body.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
|
|
194
|
+
"# frozen_string_literal: true\n\nmodule #{@namespace}\n#{indented}end\n"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def render_class_body(component, translator)
|
|
198
|
+
initializer = render_initializer(component, translator)
|
|
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"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def data_factory_signature(method_name, param_names)
|
|
240
|
+
return method_name if param_names.empty?
|
|
241
|
+
|
|
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|
|
|
253
|
+
render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
|
|
254
|
+
end
|
|
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"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_lambda_method_definition(method_name, lambda, translator)
|
|
274
|
+
snake_params = lambda.params.map { |p| AST::Inflector.underscore(p) }
|
|
275
|
+
signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
|
|
276
|
+
body = translator.with_locals(lambda.params) do
|
|
277
|
+
render_ir_node(lambda.body, translator, indent: 4)
|
|
278
|
+
end
|
|
279
|
+
" def #{signature}\n#{body}\n end"
|
|
280
|
+
end
|
|
281
|
+
|
|
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
|
+
|
|
287
|
+
props = initializable_props(component)
|
|
288
|
+
rest_name = component.rest_prop_name
|
|
289
|
+
return nil if props.empty? && rest_name.nil?
|
|
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)
|
|
296
|
+
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
297
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
298
|
+
|
|
299
|
+
body = [" super()"]
|
|
300
|
+
body.concat(props.map do |prop|
|
|
301
|
+
snake = AST::Inflector.underscore(prop.name)
|
|
302
|
+
" @#{snake} = #{snake}"
|
|
303
|
+
end)
|
|
304
|
+
body << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
305
|
+
|
|
306
|
+
" def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def initializable_props(component)
|
|
310
|
+
component.props.reject { |prop| prop.name == DEFAULT_SLOT_NAME }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def ruby_kwarg(prop, translator)
|
|
314
|
+
snake_name = AST::Inflector.underscore(prop.name)
|
|
315
|
+
default = ruby_default_for(prop, translator)
|
|
316
|
+
"#{snake_name}: #{default}"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def ruby_default_for(prop, translator)
|
|
320
|
+
return "nil" if prop.default.nil?
|
|
321
|
+
|
|
322
|
+
case prop.default
|
|
323
|
+
when IR::Interpolation
|
|
324
|
+
translated = translator.translate(prop.default.expression)
|
|
325
|
+
translated ? translated.ruby : "nil"
|
|
326
|
+
when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
|
|
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)
|
|
334
|
+
else
|
|
335
|
+
"nil"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def render_view_template(component, translator)
|
|
340
|
+
body = render_template_body(component, translator)
|
|
341
|
+
prefix = render_template_prefix(component)
|
|
342
|
+
body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
|
|
343
|
+
" def view_template\n#{body_with_prefix}\n end"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def render_template_prefix(component)
|
|
347
|
+
lines = []
|
|
348
|
+
lines.concat(render_react_hooks_todo(component.react_hooks))
|
|
349
|
+
lines.concat(render_local_bindings_todo(component.local_bindings))
|
|
350
|
+
return "" if lines.empty?
|
|
351
|
+
|
|
352
|
+
"#{lines.map { |l| " #{l}" }.join("\n")}\n"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def render_react_hooks_todo(hooks)
|
|
356
|
+
return [] if hooks.empty?
|
|
357
|
+
|
|
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
|
|
371
|
+
lines
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def render_local_bindings_todo(bindings)
|
|
375
|
+
return [] if bindings.empty?
|
|
376
|
+
|
|
377
|
+
unique_sources = bindings.map(&:source).uniq
|
|
378
|
+
["# TODO: translate JS to Ruby — original:"] + unique_sources.flat_map { |src| comment_lines(src) }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Prefix every line of `source` with `# ` so multi-line JS bodies
|
|
382
|
+
# remain inside a Ruby comment block (single `#` on the first line
|
|
383
|
+
# would leave subsequent lines as bare Ruby and break parsing).
|
|
384
|
+
def comment_lines(source)
|
|
385
|
+
source.split("\n").map { |line| "# #{line}" }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def render_template_body(component, translator)
|
|
389
|
+
root = component.body
|
|
390
|
+
root = decorate_with_stimulus_controller(root) if component.stimulus_methods.any? && root.is_a?(IR::Element)
|
|
391
|
+
render_ir_node(root, translator, indent: 4)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def decorate_with_stimulus_controller(element)
|
|
395
|
+
attr = IR::Attribute.new(name: "data-controller", value: @stimulus_identifier)
|
|
396
|
+
IR::Element.new(tag: element.tag, attributes: [attr] + element.attributes, children: element.children)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def render_ir_node(node, translator, indent:)
|
|
400
|
+
case node
|
|
401
|
+
when IR::Element then render_element(node, translator, indent: indent)
|
|
402
|
+
when IR::ComponentInvocation then render_component_invocation(node, translator, indent: indent)
|
|
403
|
+
when IR::Fragment then render_fragment(node, translator, indent: indent)
|
|
404
|
+
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
405
|
+
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
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)
|
|
408
|
+
when IR::Slot then render_slot(node, indent: indent)
|
|
409
|
+
when IR::Text then render_text(node, indent: indent)
|
|
410
|
+
when IR::Interpolation then render_interpolation(node, translator, indent: indent)
|
|
411
|
+
when IR::Comment then render_comment(node, indent: indent)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
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
|
+
|
|
432
|
+
# An orphan RenderProp (i.e. one that didn't get consumed as a block
|
|
433
|
+
# by a parent ComponentInvocation). Emit the body inline within the
|
|
434
|
+
# appropriate translator scope; the param names are pushed but no
|
|
435
|
+
# block syntax is generated.
|
|
436
|
+
def render_orphan_render_prop(render_prop, translator, indent:)
|
|
437
|
+
translator.with_locals(render_prop.params) do
|
|
438
|
+
render_ir_node(render_prop.body, translator, indent: indent)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def render_element(element, translator, indent:)
|
|
443
|
+
todos = []
|
|
444
|
+
attrs_source = format_attributes(element.attributes, translator, context: :html, todos: todos, indent: indent)
|
|
445
|
+
method_call = "#{element.tag}#{attrs_source}"
|
|
446
|
+
|
|
447
|
+
body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
|
|
448
|
+
"#{spaces(indent)}#{method_call}"
|
|
449
|
+
else
|
|
450
|
+
inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
451
|
+
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
prepend_attribute_todos(todos, indent, body)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def render_component_invocation(invocation, translator, indent:)
|
|
458
|
+
todos = []
|
|
459
|
+
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: indent)
|
|
460
|
+
class_ref = component_class_reference(invocation.name)
|
|
461
|
+
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
462
|
+
|
|
463
|
+
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
464
|
+
body = if render_prop
|
|
465
|
+
render_with_render_prop(new_call, render_prop, translator, indent)
|
|
466
|
+
elsif invocation.children.empty?
|
|
467
|
+
"#{spaces(indent)}render #{new_call}"
|
|
468
|
+
else
|
|
469
|
+
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
470
|
+
"#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
prepend_attribute_todos(todos, indent, body)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Emit a render-prop child as a Ruby block on the parent `render` call.
|
|
477
|
+
# `<Form.List>{(fields) => <p/>}</Form.List>` →
|
|
478
|
+
# `render Form::List.new do |fields|\n p\nend`. Param names are
|
|
479
|
+
# snake_cased and pushed into the translator scope so identifier
|
|
480
|
+
# references inside the body resolve to the block locals.
|
|
481
|
+
def render_with_render_prop(new_call, render_prop, translator, indent)
|
|
482
|
+
snake_params = render_prop.params.map { |p| AST::Inflector.underscore(p) }
|
|
483
|
+
param_str = snake_params.empty? ? "" : " |#{snake_params.join(", ")}|"
|
|
484
|
+
inner = translator.with_locals(render_prop.params) do
|
|
485
|
+
render_ir_node(render_prop.body, translator, indent: indent + 2)
|
|
486
|
+
end
|
|
487
|
+
"#{spaces(indent)}render #{new_call} do#{param_str}\n#{inner}\n#{spaces(indent)}end"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def prepend_attribute_todos(todos, indent, body)
|
|
491
|
+
return body if todos.empty?
|
|
492
|
+
|
|
493
|
+
prefix = todos.map { |t| "#{spaces(indent)}# TODO: #{t}" }.join("\n")
|
|
494
|
+
"#{prefix}\n#{body}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
|
|
498
|
+
# `Foo` again under namespace (Ruby's constant lookup finds the
|
|
499
|
+
# peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
|
|
500
|
+
def component_class_reference(jsx_tag)
|
|
501
|
+
segments = jsx_tag.split(".")
|
|
502
|
+
segments[-1] = "#{segments.last}#{@suffix}" if @suffix
|
|
503
|
+
segments.join("::")
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def render_fragment(fragment, translator, indent:)
|
|
507
|
+
fragment.children.map { |child| render_ir_node(child, translator, indent: indent) }.join("\n")
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def render_conditional(conditional, translator, indent:)
|
|
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")
|
|
526
|
+
lines << "#{spaces(indent)}# TODO: translate condition: #{todo}" if todo
|
|
527
|
+
lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
|
|
528
|
+
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
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
|
|
536
|
+
lines << "#{spaces(indent)}else"
|
|
537
|
+
lines << render_ir_node(alt, translator, indent: indent + 2)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def render_loop(loop_node, translator, indent:)
|
|
542
|
+
iterable_ruby, todo = render_loop_iterable(loop_node.iterable, translator)
|
|
543
|
+
js_bindings = [loop_node.item_binding, loop_node.index_binding].compact
|
|
544
|
+
ruby_bindings = js_bindings.map { |name| AST::Inflector.underscore(name) }
|
|
545
|
+
binding_str = ruby_bindings.size == 1 ? "|#{ruby_bindings.first}|" : "|#{ruby_bindings.join(", ")}|"
|
|
546
|
+
|
|
547
|
+
body = translator.with_locals(js_bindings) do
|
|
548
|
+
render_ir_node(loop_node.body, translator, indent: indent + 2)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
lines = []
|
|
552
|
+
lines << "#{spaces(indent)}# TODO: translate iterable: #{todo}" if todo
|
|
553
|
+
lines << "#{spaces(indent)}#{iterable_ruby}.each do #{binding_str}"
|
|
554
|
+
lines << body
|
|
555
|
+
lines << "#{spaces(indent)}end"
|
|
556
|
+
lines.join("\n")
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Translate the iterable side of a `.each` call. Handles both the
|
|
560
|
+
# traditional Interpolation form and the new ArrayLiteral form
|
|
561
|
+
# (literal array `.map(...)`) introduced by Gap H. Returns
|
|
562
|
+
# [ruby_source, todo_text]; todo_text is nil when translation succeeded.
|
|
563
|
+
def render_loop_iterable(iterable, translator)
|
|
564
|
+
case iterable
|
|
565
|
+
when IR::ArrayLiteral
|
|
566
|
+
[render_array_literal_value(iterable, translator, todos: []), nil]
|
|
567
|
+
when IR::Interpolation
|
|
568
|
+
safe_test_expression(iterable.expression, translator, fallback: "[]")
|
|
569
|
+
else
|
|
570
|
+
["[]", iterable.inspect]
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Translate an expression intended to drive an `if` or `.each` call.
|
|
575
|
+
# Returns `[ruby_source, todo_text]`. When the translator can parse
|
|
576
|
+
# the expression, `todo_text` is nil. When it can't, the caller's
|
|
577
|
+
# `fallback` (e.g. `"false"` for conditions, `"[]"` for iterables)
|
|
578
|
+
# is returned along with the original expression so a TODO comment
|
|
579
|
+
# can be emitted above the call. Without this, JS operators like
|
|
580
|
+
# `!==`, `===`, optional chaining, and `in` would leak into the
|
|
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.
|
|
588
|
+
def safe_test_expression(expression, translator, fallback:)
|
|
589
|
+
translated = translator.translate(expression)
|
|
590
|
+
return [translated.ruby, nil] if translated && translated.ruby != "nil"
|
|
591
|
+
|
|
592
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
593
|
+
[fallback, compact]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def render_slot(slot, indent:)
|
|
597
|
+
if slot.name == DEFAULT_SLOT_NAME
|
|
598
|
+
"#{spaces(indent)}yield"
|
|
599
|
+
else
|
|
600
|
+
"#{spaces(indent)}# TODO: named slot #{slot.name.inspect}"
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def render_text(text, indent:)
|
|
605
|
+
"#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def render_interpolation(interpolation, translator, indent:)
|
|
609
|
+
translated = translator.translate(interpolation.expression)
|
|
610
|
+
return render_untranslated_interpolation(interpolation.expression, indent) unless translated
|
|
611
|
+
|
|
612
|
+
unresolved = translated.unresolved_identifiers
|
|
613
|
+
if unresolved.empty?
|
|
614
|
+
"#{spaces(indent)}plain #{translated.ruby}#{react_node_hint(translated.ruby)}"
|
|
615
|
+
else
|
|
616
|
+
names = unresolved.map(&:inspect).join(", ")
|
|
617
|
+
"#{spaces(indent)}# TODO: unresolved identifier #{names}\n" \
|
|
618
|
+
"#{spaces(indent)}plain #{translated.ruby}"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Gap G: when the translated value is a bare `@ivar` reference, the
|
|
623
|
+
# prop may be a ReactNode (children-typed prop) rather than a plain
|
|
624
|
+
# string. `plain` HTML-escapes its argument; `raw` doesn't. We can't
|
|
625
|
+
# tell at translation time which is intended, so default to `plain`
|
|
626
|
+
# (safe for string props) and emit a comment hint pointing at `raw`.
|
|
627
|
+
def react_node_hint(ruby)
|
|
628
|
+
return "" unless ruby.is_a?(String)
|
|
629
|
+
return "" unless ruby.match?(/\A@[a-z_][a-z0-9_]*\z/)
|
|
630
|
+
|
|
631
|
+
" # NOTE: use `raw` instead of `plain` if this is a ReactNode-typed prop"
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# The original JS expression couldn't be translated to Ruby. We can't
|
|
635
|
+
# emit `plain <verbatim-JS>` because raw JS (TypeScript casts, JSX
|
|
636
|
+
# method chains, ternary spreads, etc.) usually isn't valid Ruby.
|
|
637
|
+
# Instead, emit two safe lines: a `# TODO:` comment naming the
|
|
638
|
+
# expression, then a string-literal placeholder so the template still
|
|
639
|
+
# renders something visible at runtime.
|
|
640
|
+
def render_untranslated_interpolation(expression, indent)
|
|
641
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
642
|
+
placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
|
|
643
|
+
"#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
|
|
644
|
+
"#{spaces(indent)}plain #{placeholder}"
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def render_comment(comment, indent:)
|
|
648
|
+
# Multi-line JSX comments need every line prefixed with `# ` — a
|
|
649
|
+
# bare first-line `#` would leave subsequent lines as Ruby code.
|
|
650
|
+
comment.text.split("\n").map { |line| "#{spaces(indent)}# #{line}" }.join("\n")
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Build the Ruby attribute list — `(id: @id, class: @class, ...)` —
|
|
654
|
+
# to splice immediately after the tag method name. Returns "" when
|
|
655
|
+
# there are no attributes (so the caller emits a bare `h1` instead
|
|
656
|
+
# of `h1()`). The `context:` param selects naming convention:
|
|
657
|
+
# - :html (HTML element attrs — preserve camelCase for SVG)
|
|
658
|
+
# - :component (Ruby method args — snake_case via Inflector.underscore)
|
|
659
|
+
def format_attributes(attributes, translator, context: :html, todos: [], indent: 0)
|
|
660
|
+
events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
661
|
+
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
662
|
+
|
|
663
|
+
parts = { sym: [], str: [] }
|
|
664
|
+
plain_attrs.each do |a|
|
|
665
|
+
append_attribute_part(a, translator, parts, context: context, todos: todos, indent: indent)
|
|
666
|
+
end
|
|
667
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
668
|
+
|
|
669
|
+
joined = build_attribute_list(parts, spreads, translator)
|
|
670
|
+
joined.empty? ? "" : "(#{joined})"
|
|
671
|
+
end
|
|
672
|
+
|
|
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)
|
|
675
|
+
return unless part
|
|
676
|
+
|
|
677
|
+
(part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def build_attribute_list(parts, spreads, translator)
|
|
681
|
+
pieces = parts[:sym].dup
|
|
682
|
+
pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
|
|
683
|
+
pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
|
|
684
|
+
pieces.join(", ")
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# Emit one attribute as either a {string_key: false, source: "id: @x"}
|
|
688
|
+
# (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
|
|
689
|
+
# (rare; non-identifier name — goes into a **{ ... } splat).
|
|
690
|
+
def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0)
|
|
691
|
+
case attribute
|
|
692
|
+
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
|
|
693
|
+
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
|
|
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)
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def class_attribute_part(expression, translator)
|
|
701
|
+
translated = translator.translate(expression)
|
|
702
|
+
ruby = translated ? translated.ruby : expression.inspect
|
|
703
|
+
{ string_key: false, source: "class: #{ruby}" }
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Map a JSX attribute name to its Ruby kwarg form. For HTML element
|
|
707
|
+
# attrs (`context: :html`), only hyphens convert to underscores —
|
|
708
|
+
# camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
|
|
709
|
+
# so SVG attributes render correctly through Phlex. For component
|
|
710
|
+
# invocations (`context: :component`), full Inflector.underscore
|
|
711
|
+
# converts both hyphens AND camelCase, since Ruby method args
|
|
712
|
+
# follow snake_case convention (`defaultValue` → `default_value`).
|
|
713
|
+
# Names that aren't valid Ruby identifiers after conversion (rare:
|
|
714
|
+
# `xml:lang` and friends) fall back to a quoted string key.
|
|
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)
|
|
717
|
+
ruby_name = case context
|
|
718
|
+
when :component then AST::Inflector.underscore(attribute.name)
|
|
719
|
+
else attribute.name.tr("-", "_")
|
|
720
|
+
end
|
|
721
|
+
if ruby_name.match?(VALID_IDENTIFIER)
|
|
722
|
+
{ string_key: false, source: "#{ruby_name}: #{value_ruby}" }
|
|
723
|
+
else
|
|
724
|
+
{ string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def attribute_value_to_ruby(name, value, translator, todos:, indent: 0)
|
|
729
|
+
case value
|
|
730
|
+
when true then "true"
|
|
731
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
732
|
+
when IR::Interpolation then interpolated_attribute_value(name, 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)
|
|
735
|
+
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
|
|
740
|
+
# entries become Ruby kwargs (snake_cased to match Ruby convention);
|
|
741
|
+
# non-identifier keys (numeric, hyphenated) fall back to string keys.
|
|
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
|
|
747
|
+
parts = object_literal.properties.map do |(key, value)|
|
|
748
|
+
render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
|
|
749
|
+
end
|
|
750
|
+
wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
|
|
751
|
+
force_inline: force_inline)
|
|
752
|
+
end
|
|
753
|
+
|
|
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)
|
|
757
|
+
snake = AST::Inflector.underscore(key)
|
|
758
|
+
if snake.match?(VALID_IDENTIFIER)
|
|
759
|
+
"#{snake}: #{value_ruby}"
|
|
760
|
+
else
|
|
761
|
+
"#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
|
|
762
|
+
end
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
|
|
766
|
+
child_indent = indent + 2
|
|
767
|
+
parts = array_literal.elements.map do |el|
|
|
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
|
|
774
|
+
end
|
|
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}"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# An inline value can appear as a kwarg value, an array element, or a
|
|
799
|
+
# hash property value. Recursive shapes route back through the new IR
|
|
800
|
+
# types; primitives fall through the same paths as attribute_value_to_ruby.
|
|
801
|
+
def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
|
|
802
|
+
case value
|
|
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)
|
|
807
|
+
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
|
|
808
|
+
when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
|
|
809
|
+
todos: todos)
|
|
810
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
811
|
+
when true then "true"
|
|
812
|
+
else
|
|
813
|
+
"nil"
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# An IR::Lambda lives inside an object/array literal as a value. We
|
|
818
|
+
# extract it to a method on the class (so it has access to the
|
|
819
|
+
# Phlex tag.* helpers) and reference it via `method(:name)` in the
|
|
820
|
+
# value position. Method names are deterministic so re-runs produce
|
|
821
|
+
# stable output: `<attr-name>_renderer<N>` where N is a per-attr index.
|
|
822
|
+
def render_lambda_method_reference(lambda, translator, attr_name:)
|
|
823
|
+
@lambda_methods ||= []
|
|
824
|
+
base = lambda_method_base(attr_name)
|
|
825
|
+
@lambda_methods << { base: base, lambda: lambda, translator: translator }
|
|
826
|
+
# Index is the position within `@lambda_methods` so re-renders are
|
|
827
|
+
# deterministic in the order encountered.
|
|
828
|
+
method_name = unique_lambda_method_name(base)
|
|
829
|
+
@lambda_methods.last[:method_name] = method_name
|
|
830
|
+
"method(:#{method_name})"
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def lambda_method_base(attr_name)
|
|
834
|
+
return "render_lambda" if attr_name.nil? || attr_name.empty?
|
|
835
|
+
|
|
836
|
+
"render_#{AST::Inflector.underscore(attr_name)}"
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def unique_lambda_method_name(base)
|
|
840
|
+
@lambda_method_counts ||= {}
|
|
841
|
+
@lambda_method_counts[base] ||= 0
|
|
842
|
+
@lambda_method_counts[base] += 1
|
|
843
|
+
@lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
|
|
844
|
+
end
|
|
845
|
+
|
|
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
|
|
858
|
+
# be parsed at all (e.g. `<LeftOutlined .../>`, array literals,
|
|
859
|
+
# template literals with method calls). Same TODO + nil path.
|
|
860
|
+
def interpolated_attribute_value(name, value, translator, todos:)
|
|
861
|
+
translated = translator.translate(value.expression)
|
|
862
|
+
if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
|
|
863
|
+
return translated.ruby
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
compact = value.expression.tr("\n", " ").squeeze(" ")
|
|
867
|
+
todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
|
|
868
|
+
"nil"
|
|
869
|
+
end
|
|
870
|
+
|
|
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)
|
|
876
|
+
events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
877
|
+
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
878
|
+
|
|
879
|
+
parts = { sym: [], str: [] }
|
|
880
|
+
plain_attrs.each do |a|
|
|
881
|
+
append_attribute_part(a, translator, parts, context: :component, todos: todos, indent: indent)
|
|
882
|
+
end
|
|
883
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
884
|
+
|
|
885
|
+
build_attribute_list(parts, spreads, translator)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def class_list_to_ruby_string(class_list, translator)
|
|
889
|
+
parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
|
|
890
|
+
wrap_concatenated_string(parts.join(" "))
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def class_segment_to_ruby(segment, translator)
|
|
894
|
+
case segment
|
|
895
|
+
when String then segment
|
|
896
|
+
when IR::Interpolation
|
|
897
|
+
translated = translator.translate(segment.expression)
|
|
898
|
+
"\#{#{translated&.ruby || segment.expression}}"
|
|
899
|
+
when IR::ConditionalSegment
|
|
900
|
+
cond_translated = translator.translate(segment.condition.expression)
|
|
901
|
+
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
902
|
+
%(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
|
|
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(" "))
|
|
909
|
+
end
|
|
910
|
+
|
|
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
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
945
|
+
# default doesn't raise at render time. `<div {...maybeNil}>` →
|
|
946
|
+
# `**(@maybe_nil || {})`. Cheap to emit unconditionally; the
|
|
947
|
+
# `|| {}` shortcuts on non-nil values.
|
|
948
|
+
def render_spread(expression, translator)
|
|
949
|
+
translated = translator.translate(expression)
|
|
950
|
+
ruby = translated ? translated.ruby : expression
|
|
951
|
+
"(#{ruby} || {})"
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# Build the `data_action: "..."` kwarg. Phlex auto-hyphenates the
|
|
955
|
+
# `data_action` symbol key to `data-action` in the rendered HTML.
|
|
956
|
+
def data_action_entry(events, translator)
|
|
957
|
+
descriptors = events.map { |event| event_descriptor(event, translator) }
|
|
958
|
+
joined = if descriptors.size == 1
|
|
959
|
+
render_single_event_descriptor(descriptors.first)
|
|
960
|
+
else
|
|
961
|
+
%("#{descriptors.map { |d| descriptor_in_string(d) }.join(" ")}")
|
|
962
|
+
end
|
|
963
|
+
"data_action: #{joined}"
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def event_descriptor(event, translator)
|
|
967
|
+
case event
|
|
968
|
+
when IR::EventBinding
|
|
969
|
+
translated = translator.translate(event.handler.expression)
|
|
970
|
+
EventDescriptor.new(:ruby, translated ? translated.ruby : event.handler.expression.inspect)
|
|
971
|
+
when IR::StimulusBinding
|
|
972
|
+
EventDescriptor.new(:literal, "#{event.event}->#{@stimulus_identifier}##{event.method_name}")
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def render_single_event_descriptor(descriptor)
|
|
977
|
+
descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def descriptor_in_string(descriptor)
|
|
981
|
+
descriptor.kind == :literal ? descriptor.body : "\#{#{descriptor.body}}"
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def render_stimulus_controller_js(component)
|
|
985
|
+
lines = [
|
|
986
|
+
'import { Controller } from "@hotwired/stimulus";',
|
|
987
|
+
"",
|
|
988
|
+
"export default class extends Controller {"
|
|
989
|
+
]
|
|
990
|
+
component.stimulus_methods.each_with_index do |method, idx|
|
|
991
|
+
lines << "" if idx.positive?
|
|
992
|
+
lines.concat(stimulus_method_lines(method))
|
|
993
|
+
end
|
|
994
|
+
lines << "}"
|
|
995
|
+
"#{lines.join("\n")}\n"
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def stimulus_method_lines(method)
|
|
999
|
+
body_lines = method.body_source.strip.split("\n")
|
|
1000
|
+
commented = body_lines.map { |line| " // #{line}" }
|
|
1001
|
+
header = [" // TODO: translate from the original JSX handler:"]
|
|
1002
|
+
if method.name != method.original_name
|
|
1003
|
+
header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
1004
|
+
"to avoid collision with an earlier handler")
|
|
1005
|
+
end
|
|
1006
|
+
header + commented + [
|
|
1007
|
+
" #{method.name}(event) {",
|
|
1008
|
+
" // ...",
|
|
1009
|
+
" }"
|
|
1010
|
+
]
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def spaces(count)
|
|
1014
|
+
" " * count
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
end
|
|
1018
|
+
end
|