jsx_rosetta 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +342 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/ROADMAP.md +92 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +32 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1421 -158
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +357 -33
- data/lib/jsx_rosetta/backend/view_component.rb +261 -31
- data/lib/jsx_rosetta/cli.rb +175 -37
- data/lib/jsx_rosetta/icons/lucide.json +37 -0
- data/lib/jsx_rosetta/icons.rb +44 -0
- data/lib/jsx_rosetta/ir/lowering.rb +1164 -70
- data/lib/jsx_rosetta/ir/module_shape_classifier.rb +20 -1
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +264 -19
- data/lib/jsx_rosetta/ir.rb +5 -4
- data/lib/jsx_rosetta/pages_routing.rb +640 -0
- data/lib/jsx_rosetta/version.rb +1 -1
- data/lib/jsx_rosetta.rb +8 -6
- data/plans/nextjs_pages_to_rails.md +200 -0
- data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
- data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
- data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
- data/plans/translator_widening_and_pages_followups.md +120 -0
- data/plans/translator_widening_slice_a.md +208 -0
- data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
- data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
- data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
- data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
- data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
- data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
- data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
- data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
- metadata +30 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../ast/inflector"
|
|
4
4
|
require_relative "../ir/types"
|
|
5
|
+
require_relative "../pages_routing"
|
|
5
6
|
require_relative "base"
|
|
6
7
|
require_relative "view_component/expression_translator"
|
|
7
8
|
|
|
@@ -31,37 +32,198 @@ module JsxRosetta
|
|
|
31
32
|
VALID_IDENTIFIER = /\A[a-z_][a-z0-9_]*\z/i
|
|
32
33
|
VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
33
34
|
|
|
35
|
+
# Inline budget for object/array literal rendering. When the
|
|
36
|
+
# single-line rendering of a literal exceeds this width — measured
|
|
37
|
+
# from the opening bracket — it switches to a multi-line layout
|
|
38
|
+
# with one entry per line, indented two spaces past the parent's
|
|
39
|
+
# line indent. Closing bracket re-aligns to the parent indent.
|
|
40
|
+
# Chosen to keep typical attr lines under ~120 chars after the
|
|
41
|
+
# kwarg name and surrounding `render Foo.new(...)` wrapper.
|
|
42
|
+
LITERAL_INLINE_BUDGET = 80
|
|
43
|
+
|
|
44
|
+
# Per-library TODO header lines surfaced above the verbatim hook
|
|
45
|
+
# source. Each library has a different Rails analog, so we don't
|
|
46
|
+
# collapse them into a single generic block. Keys must mirror the
|
|
47
|
+
# `:library` values produced by IR::Lowering.
|
|
48
|
+
HOOK_TODO_HEADERS = {
|
|
49
|
+
react: [
|
|
50
|
+
"TODO: React hooks detected. None translate automatically.",
|
|
51
|
+
"Hotwire/Stimulus handles behavior; controllers/views handle state;",
|
|
52
|
+
"turbo-frames handle async loading. Original source:"
|
|
53
|
+
].freeze,
|
|
54
|
+
apollo: [
|
|
55
|
+
"TODO: Apollo data-fetching hooks detected. None translate automatically.",
|
|
56
|
+
"Move the fetch to the Rails controller (or a model/service); pass the",
|
|
57
|
+
"result in as a prop. For useMutation, use a form POST + redirect or a",
|
|
58
|
+
"Turbo Stream response. Original source:"
|
|
59
|
+
].freeze,
|
|
60
|
+
next_js: [
|
|
61
|
+
"TODO: Next.js navigation hooks detected. None translate automatically.",
|
|
62
|
+
"Rails analogs: useRouter -> redirect_to / form actions;",
|
|
63
|
+
"usePathname -> request.path; useSearchParams / useParams -> params;",
|
|
64
|
+
"useSelectedLayoutSegment(s) -> match against request.path in the view.",
|
|
65
|
+
"Original source:"
|
|
66
|
+
].freeze
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
34
69
|
# Structured intermediate for the data-action attribute — mirrors the
|
|
35
70
|
# ViewComponent backend pattern (lib/jsx_rosetta/backend/view_component.rb).
|
|
36
71
|
EventDescriptor = Data.define(:kind, :body)
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
RAILS_VIEW_BASE_CLASS = "Views::Base"
|
|
74
|
+
|
|
75
|
+
# Per-tag map of which attribute names carry a URL that the href
|
|
76
|
+
# rewriter should attempt to rewrite. Most link tags use `href`/`to`;
|
|
77
|
+
# `<form action="...">` uses `action`. Slice C1 added the `form`
|
|
78
|
+
# entry — non-GET forms are skipped at format_attributes time
|
|
79
|
+
# before they reach try_rewrite_href.
|
|
80
|
+
LINK_TAG_ATTRS = {
|
|
81
|
+
"a" => %w[href].freeze,
|
|
82
|
+
"Link" => %w[href to].freeze,
|
|
83
|
+
"NavLink" => %w[href to].freeze,
|
|
84
|
+
"RouterLink" => %w[href to].freeze,
|
|
85
|
+
"form" => %w[action].freeze
|
|
86
|
+
}.freeze
|
|
87
|
+
# Matches `router.push("…")` / `router.push('…')` / `router.push(\`…\`)`
|
|
88
|
+
# in verbatim hook source, capturing the quoted argument (including the
|
|
89
|
+
# surrounding quote/backtick). A3: only fires for sync hook bodies —
|
|
90
|
+
# event-handler bodies live in IR::EventHandler / IR::StimulusMethod
|
|
91
|
+
# sources, not in react_hooks, so they're naturally out of scope.
|
|
92
|
+
ROUTER_PUSH_PATTERN = /router\.push\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)\s*[,)]/
|
|
93
|
+
|
|
94
|
+
# Per-wrapper TODO header text. Each HOC that lowering peeled off the
|
|
95
|
+
# source (recorded on IR::Component#hoc_wrappers) gets one short line
|
|
96
|
+
# above the class explaining the Rails analog. Falls back to a
|
|
97
|
+
# generic line for wrappers not in this map.
|
|
98
|
+
HOC_WRAPPER_TODO_LINES = {
|
|
99
|
+
"memo" => "TODO: original component was wrapped in memo(...). React's memo memoizes " \
|
|
100
|
+
"by shallow prop equality. Rails analog: fragment caching (`cache @model do …`) " \
|
|
101
|
+
"when the view is expensive.",
|
|
102
|
+
"forwardRef" => "TODO: original component was wrapped in forwardRef(...). The second " \
|
|
103
|
+
"arg (`ref`) was dropped — Rails has no view-side ref-forwarding analog; " \
|
|
104
|
+
"use Stimulus targets / DOM ids if the host needs a handle.",
|
|
105
|
+
"observer" => "TODO: original component was wrapped in observer(...) (mobx). React's " \
|
|
106
|
+
"observer auto-subscribes to observable state. Rails analog: the controller " \
|
|
107
|
+
"loads data and sets @ivars; reactivity moves to Turbo Streams / Hotwire.",
|
|
108
|
+
"connect" => "TODO: original component was wrapped in connect(...)(X) (redux). Props " \
|
|
109
|
+
"injected by mapState/mapDispatch in the source — in Rails, expect those as " \
|
|
110
|
+
"controller-passed instance variables instead.",
|
|
111
|
+
"withRouter" => "TODO: original component was wrapped in withRouter(...) (React Router). " \
|
|
112
|
+
"Injected router props (location, history, match) map to Rails: request.path, " \
|
|
113
|
+
"redirect_to / form actions, and params.",
|
|
114
|
+
"withTranslation" => "TODO: original component was wrapped in withTranslation()(X) (i18n). " \
|
|
115
|
+
"Injected `t` translator function maps to Rails I18n: `I18n.t(\"…\")` " \
|
|
116
|
+
"or the `t` view helper."
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
def initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil)
|
|
39
120
|
super()
|
|
40
121
|
raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace
|
|
122
|
+
if rails_view && (suffix || namespace)
|
|
123
|
+
raise ArgumentError, "Phlex backend: rails_view: cannot be combined with suffix: or namespace:"
|
|
124
|
+
end
|
|
41
125
|
|
|
42
126
|
@suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
|
|
43
127
|
@namespace = namespace
|
|
128
|
+
@rails_view = rails_view
|
|
129
|
+
@href_rewriter = route_table && PagesRouting::HrefRewriter.new(route_table)
|
|
44
130
|
end
|
|
45
131
|
|
|
46
|
-
def emit(component)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
translator = ViewComponent::ExpressionTranslator.new(
|
|
50
|
-
prop_names: prop_names, local_binding_names: component.local_binding_names
|
|
51
|
-
)
|
|
52
|
-
|
|
132
|
+
def emit(component, source_filename: nil)
|
|
133
|
+
@current_component = component
|
|
134
|
+
translator = build_translator(component)
|
|
53
135
|
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
|
|
54
136
|
@lambda_methods = []
|
|
55
137
|
@lambda_method_counts = {}
|
|
138
|
+
@event_handler_methods = []
|
|
139
|
+
@emit_module_prefix = first_emit_for_module_bindings?(component)
|
|
140
|
+
@source_filename = source_filename
|
|
56
141
|
|
|
57
|
-
files = [File.new(path: ruby_path(component), contents: render_ruby_class(component, translator))]
|
|
142
|
+
files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))]
|
|
58
143
|
if component.stimulus_methods.any?
|
|
59
144
|
files << File.new(
|
|
60
145
|
path: stimulus_path(component),
|
|
61
146
|
contents: render_stimulus_controller_js(component)
|
|
62
147
|
)
|
|
63
148
|
end
|
|
149
|
+
files.concat(lucide_icon_files(component))
|
|
64
150
|
files
|
|
151
|
+
ensure
|
|
152
|
+
# Drop the per-emit IR reference so a long-running emitter
|
|
153
|
+
# instance doesn't pin the entire component tree until the
|
|
154
|
+
# next emit() call.
|
|
155
|
+
@current_component = nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# When a source file lowers to multiple sibling components, lower_all
|
|
159
|
+
# attaches the *same* module_bindings array to every sibling. Emitting
|
|
160
|
+
# the constants TODO block on each one duplicates 40-line GraphQL
|
|
161
|
+
# blocks across every emitted .rb file. Track the array identities
|
|
162
|
+
# we've seen and only emit the prefix the first time.
|
|
163
|
+
def first_emit_for_module_bindings?(component)
|
|
164
|
+
return true if component.module_bindings.empty?
|
|
165
|
+
|
|
166
|
+
@seen_module_bindings ||= Set.new
|
|
167
|
+
key = component.module_bindings.object_id
|
|
168
|
+
return false if @seen_module_bindings.include?(key)
|
|
169
|
+
|
|
170
|
+
@seen_module_bindings << key
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_translator(component)
|
|
175
|
+
prop_names = component.props.map(&:name)
|
|
176
|
+
prop_names << component.rest_prop_name if component.rest_prop_name
|
|
177
|
+
prop_aliases = component.props.each_with_object({}) do |prop, hash|
|
|
178
|
+
hash[prop.alias_name] = prop.name if prop.alias_name
|
|
179
|
+
end
|
|
180
|
+
# `imported_names` covers both top-level `import` declarations AND
|
|
181
|
+
# top-level helper bindings (`function onError(){}`, `const FOO = …`).
|
|
182
|
+
# They behave identically at the use site — the translator bails out
|
|
183
|
+
# to `nil` rather than emitting a bare snake_case ref that NameErrors.
|
|
184
|
+
ViewComponent::ExpressionTranslator.new(
|
|
185
|
+
prop_names: prop_names,
|
|
186
|
+
local_binding_names: component.local_binding_names,
|
|
187
|
+
prop_aliases: prop_aliases,
|
|
188
|
+
imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Strip trailing whitespace from each emitted line — easier than
|
|
193
|
+
# threading rstrip through every formatting helper, and a single
|
|
194
|
+
# source of truth keeps Layout/TrailingWhitespace at zero. Preserve
|
|
195
|
+
# the trailing newline of the file as-is. Also suppresses the
|
|
196
|
+
# intentional-`if false` cops file-wide so the user's rubocop
|
|
197
|
+
# doesn't drown out actionable findings.
|
|
198
|
+
def clean_output(source)
|
|
199
|
+
cleaned = "#{source.split("\n").map(&:rstrip).join("\n")}\n".sub(/\n\n+\z/, "\n")
|
|
200
|
+
suppress_intentional_if_false_cops(cleaned)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# When the file contains any `if false` / `elsif false` branch (the
|
|
204
|
+
# fallback we emit when a JSX condition can't be translated to Ruby),
|
|
205
|
+
# disable the cops that flag those at file scope. The corresponding
|
|
206
|
+
# `# TODO: translate condition:` comment already names the issue, so
|
|
207
|
+
# the cop's report is redundant noise. Only emits when needed.
|
|
208
|
+
def suppress_intentional_if_false_cops(source)
|
|
209
|
+
cops = []
|
|
210
|
+
cops << "Lint/LiteralAsCondition" if source.match?(/^\s*(?:if|elsif) false$/m)
|
|
211
|
+
# An elsif-false adjacent to its leading `if false` is the only
|
|
212
|
+
# configuration that triggers DuplicateElsifCondition — multiple
|
|
213
|
+
# `if false`s in separate scopes don't qualify. The pattern below
|
|
214
|
+
# matches an `if false` directly followed (after consequent lines)
|
|
215
|
+
# by an `elsif false` at the same indent.
|
|
216
|
+
cops << "Lint/DuplicateElsifCondition" if source.match?(/^(\s*)if false\b[\s\S]*?^\1elsif false\b/m)
|
|
217
|
+
return source if cops.empty?
|
|
218
|
+
|
|
219
|
+
disable = "# rubocop:disable #{cops.join(", ")}\n\n"
|
|
220
|
+
enable = "# rubocop:enable #{cops.join(", ")}\n"
|
|
221
|
+
# Magic comment must be followed by a blank line before any other
|
|
222
|
+
# comment (Layout/EmptyLineAfterMagicComment), and every file-level
|
|
223
|
+
# disable needs a matching enable (Lint/MissingCopEnableDirective).
|
|
224
|
+
with_disable = source.sub(/^# frozen_string_literal: true\n\n/,
|
|
225
|
+
"# frozen_string_literal: true\n\n#{disable}")
|
|
226
|
+
"#{with_disable.chomp}\n#{enable}"
|
|
65
227
|
end
|
|
66
228
|
|
|
67
229
|
private
|
|
@@ -72,42 +234,234 @@ module JsxRosetta
|
|
|
72
234
|
# letter when forming the class name. Pure-PascalCase names pass
|
|
73
235
|
# through unchanged.
|
|
74
236
|
def class_name(component)
|
|
237
|
+
return rails_view_class_name if @rails_view
|
|
238
|
+
|
|
75
239
|
base = "#{component.name[0].upcase}#{component.name[1..]}"
|
|
76
|
-
|
|
240
|
+
suffix = effective_suffix_for(base, source_filename: @source_filename)
|
|
241
|
+
suffix ? "#{base}#{suffix}" : base
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def rails_view_class_name
|
|
245
|
+
return rails_layout_class_name if @rails_view.kind == :layout
|
|
246
|
+
|
|
247
|
+
namespace_parts = (@rails_view.namespace || []).map { |ns| AST::Inflector.upper_camelize(ns) }
|
|
248
|
+
controller = AST::Inflector.upper_camelize(@rails_view.controller)
|
|
249
|
+
action = AST::Inflector.upper_camelize(@rails_view.action)
|
|
250
|
+
(["Views"] + namespace_parts + [controller, action]).join("::")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Layouts land under Views::Layouts:: regardless of source path —
|
|
254
|
+
# `_app.tsx` → Views::Layouts::Application. There's only ever one
|
|
255
|
+
# "controller" for layouts (`layouts`), so the controller segment
|
|
256
|
+
# is collapsed into the namespace.
|
|
257
|
+
def rails_layout_class_name
|
|
258
|
+
action = AST::Inflector.upper_camelize(@rails_view.action)
|
|
259
|
+
"Views::Layouts::#{action}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def parent_class
|
|
263
|
+
@rails_view ? RAILS_VIEW_BASE_CLASS : PHLEX_BASE_CLASS
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Pick the suffix to append to a class name, applying two rules in
|
|
267
|
+
# order:
|
|
268
|
+
#
|
|
269
|
+
# 1. **Page detection.** Source name ends in `Page` OR the source
|
|
270
|
+
# file path contains `/pages/` (Next.js / Nuxt convention) →
|
|
271
|
+
# use the literal `Page` suffix, regardless of the configured
|
|
272
|
+
# `@suffix`. Lets the gem keep `<HomePage>` / `pages/home.tsx`
|
|
273
|
+
# landing as `HomePage` / `home_page.rb` even when the user
|
|
274
|
+
# passes `--phlex-suffix=Component` for the rest of the codebase.
|
|
275
|
+
# 2. **No double-suffix.** When the name already ends with the
|
|
276
|
+
# chosen suffix (e.g. source `HomePage` with the `Page` suffix,
|
|
277
|
+
# or source `FooComponent` with the `Component` suffix), skip
|
|
278
|
+
# the append. Returns nil so callers don't concatenate.
|
|
279
|
+
#
|
|
280
|
+
# `source_filename` is the absolute or repo-relative path of the
|
|
281
|
+
# JSX source; nil when callers translate raw source strings
|
|
282
|
+
# without filename context (then only the name-based signal fires).
|
|
283
|
+
def effective_suffix_for(name, source_filename: nil)
|
|
284
|
+
suffix = page?(name, source_filename) ? "Page" : @suffix
|
|
285
|
+
return nil unless suffix
|
|
286
|
+
return nil if name.end_with?(suffix)
|
|
287
|
+
|
|
288
|
+
suffix
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def page?(name, source_filename)
|
|
292
|
+
return true if name.end_with?("Page")
|
|
293
|
+
return false unless source_filename
|
|
294
|
+
|
|
295
|
+
source_filename.include?("/pages/")
|
|
77
296
|
end
|
|
78
297
|
|
|
79
298
|
def ruby_path(component)
|
|
299
|
+
return "#{rails_view_dir}/#{@rails_view.action}.rb" if @rails_view
|
|
300
|
+
|
|
80
301
|
"#{AST::Inflector.underscore(class_name(component))}.rb"
|
|
81
302
|
end
|
|
82
303
|
|
|
83
304
|
def stimulus_path(component)
|
|
305
|
+
return "#{rails_view_dir}/#{@rails_view.action}_controller.js" if @rails_view
|
|
306
|
+
|
|
84
307
|
"#{AST::Inflector.underscore(class_name(component))}_controller.js"
|
|
85
308
|
end
|
|
86
309
|
|
|
310
|
+
def rails_view_dir
|
|
311
|
+
((@rails_view.namespace || []) + [@rails_view.controller]).join("/")
|
|
312
|
+
end
|
|
313
|
+
|
|
87
314
|
def stimulus_identifier(component)
|
|
88
315
|
AST::Inflector.underscore(component.name).tr("_", "-")
|
|
89
316
|
end
|
|
90
317
|
|
|
91
318
|
def render_ruby_class(component, translator)
|
|
92
319
|
class_body = render_class_body(component, translator)
|
|
93
|
-
prefix =
|
|
320
|
+
prefix = "#{render_hoc_wrappers_prefix(component)}" \
|
|
321
|
+
"#{render_server_data_source_prefix(component)}" \
|
|
322
|
+
"#{render_module_bindings_prefix(component)}"
|
|
94
323
|
wrap_in_namespace("#{prefix}#{class_body}")
|
|
95
324
|
end
|
|
96
325
|
|
|
326
|
+
def render_hoc_wrappers_prefix(component)
|
|
327
|
+
return "" if component.hoc_wrappers.empty?
|
|
328
|
+
|
|
329
|
+
lines = component.hoc_wrappers.map do |wrapper|
|
|
330
|
+
"# #{HOC_WRAPPER_TODO_LINES[wrapper] ||
|
|
331
|
+
"TODO: original component was wrapped in #{wrapper}(...). " \
|
|
332
|
+
"Verify the Rails-side equivalent (or absence) and adjust before shipping."}"
|
|
333
|
+
end
|
|
334
|
+
"#{lines.join("\n")}\n"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# When the source file exports `getServerSideProps` /
|
|
338
|
+
# `getStaticProps`, emit a TODO block above the class with the
|
|
339
|
+
# body verbatim. The block names the matching Rails controller
|
|
340
|
+
# action when `@rails_view` is set; otherwise it points at "the
|
|
341
|
+
# host controller" generically.
|
|
342
|
+
def render_server_data_source_prefix(component)
|
|
343
|
+
return "" unless component.server_data_source
|
|
344
|
+
|
|
345
|
+
sds = component.server_data_source
|
|
346
|
+
target = if @rails_view
|
|
347
|
+
"#{AST::Inflector.upper_camelize(@rails_view.controller)}Controller##{@rails_view.action}"
|
|
348
|
+
else
|
|
349
|
+
"the host controller action"
|
|
350
|
+
end
|
|
351
|
+
body_lines = comment_lines(sds.source)
|
|
352
|
+
lines = [
|
|
353
|
+
"# TODO: port the original Next.js `#{sds.hook_name}` to #{target}:",
|
|
354
|
+
"#",
|
|
355
|
+
*body_lines,
|
|
356
|
+
"#",
|
|
357
|
+
"# In Rails: load the data in the controller (set @ivars), and read",
|
|
358
|
+
"# them in this view via the standard props plumbing."
|
|
359
|
+
]
|
|
360
|
+
"#{lines.join("\n")}\n"
|
|
361
|
+
end
|
|
362
|
+
|
|
97
363
|
# Top-level `const`/`let` declarations outside the component
|
|
98
|
-
# function — captured at lowering time
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# a Ruby constant or moves it to a Rails initializer.
|
|
364
|
+
# function — captured at lowering time. Cva-shaped bindings get
|
|
365
|
+
# emitted as real Ruby constants (FOO_BASE_CLASS, etc.); generic
|
|
366
|
+
# local bindings still surface as a TODO comment block.
|
|
102
367
|
def render_module_bindings_prefix(component)
|
|
103
368
|
return "" if component.module_bindings.empty?
|
|
104
369
|
|
|
370
|
+
groups = component.module_bindings.group_by { |b| binding_group(b) }
|
|
371
|
+
cva_bindings = groups[:cva] || []
|
|
372
|
+
module_constants = groups[:module_constant] || []
|
|
373
|
+
other_bindings = groups[:other] || []
|
|
374
|
+
sections = []
|
|
375
|
+
# cva constants are *referenced* by every sibling's class body via
|
|
376
|
+
# FOO_BASE_CLASS / FOO_VARIANT_CLASSES — they have to land in every
|
|
377
|
+
# sibling's file or non-first siblings NameError at render. The
|
|
378
|
+
# non-cva TODO block is informational only, so it suppresses on
|
|
379
|
+
# later siblings to avoid duplicating 40-line GraphQL blocks.
|
|
380
|
+
sections << render_cva_constants(cva_bindings) unless cva_bindings.empty?
|
|
381
|
+
sections << render_module_constants(module_constants) unless module_constants.empty?
|
|
382
|
+
emit_todo_block = @emit_module_prefix && !other_bindings.empty?
|
|
383
|
+
sections << render_module_local_bindings_todo(other_bindings) if emit_todo_block
|
|
384
|
+
sections.compact.join("\n")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def binding_group(binding)
|
|
388
|
+
case binding
|
|
389
|
+
when IR::CvaBinding then :cva
|
|
390
|
+
when IR::ModuleConstant then :module_constant
|
|
391
|
+
else :other
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Emit each literal-shaped module-level const as a real Ruby constant.
|
|
396
|
+
# Hash and array values are emitted via Ruby's `inspect` and frozen so
|
|
397
|
+
# accidental mutation surfaces immediately. Scalars (string / number /
|
|
398
|
+
# bool / nil) skip the `.freeze` since they're already immutable in
|
|
399
|
+
# Ruby — keeps the output terse for the common `const TITLE = "X"` case.
|
|
400
|
+
def render_module_constants(constants)
|
|
401
|
+
lines = constants.map do |constant|
|
|
402
|
+
literal = constant.value.inspect
|
|
403
|
+
literal += ".freeze" if constant.value.is_a?(Hash) || constant.value.is_a?(Array) ||
|
|
404
|
+
constant.value.is_a?(String)
|
|
405
|
+
"#{constant.constant_name} = #{literal}"
|
|
406
|
+
end
|
|
407
|
+
"#{lines.join("\n")}\n"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Emit one cva binding as a triplet of Ruby constants —
|
|
411
|
+
# FOO_BASE_CLASS, FOO_VARIANT_CLASSES, FOO_DEFAULT_VARIANTS — that
|
|
412
|
+
# the call-site interpolation in the class body references.
|
|
413
|
+
def render_cva_constants(cva_bindings)
|
|
414
|
+
lines = []
|
|
415
|
+
cva_bindings.each do |cva|
|
|
416
|
+
prefix = cva_constant_prefix(cva.name)
|
|
417
|
+
lines << "#{prefix}_BASE_CLASS = #{cva.base_class.inspect}"
|
|
418
|
+
lines << "#{prefix}_VARIANT_CLASSES = #{format_variants_literal(cva.variants)}.freeze"
|
|
419
|
+
unless cva.default_variants.empty?
|
|
420
|
+
lines << "#{prefix}_DEFAULT_VARIANTS = #{cva.default_variants.inspect}.freeze"
|
|
421
|
+
end
|
|
422
|
+
if cva.compound_source
|
|
423
|
+
lines << "# TODO: compoundVariants from #{cva.name} aren't translated — port by hand:"
|
|
424
|
+
lines.concat(comment_lines(cva.compound_source))
|
|
425
|
+
end
|
|
426
|
+
lines << ""
|
|
427
|
+
end
|
|
428
|
+
"#{lines.join("\n").rstrip}\n"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Non-cva module bindings — the original Gap E pre-class TODO block.
|
|
432
|
+
# Distinct from the body-level `render_local_bindings_todo` further
|
|
433
|
+
# below in this file.
|
|
434
|
+
def render_module_local_bindings_todo(bindings)
|
|
105
435
|
lines = ["# TODO: module-level constants — translate to Ruby constants " \
|
|
106
436
|
"or move to a Rails initializer:"]
|
|
107
|
-
|
|
437
|
+
bindings.each { |b| lines.concat(comment_lines(b.source)) }
|
|
108
438
|
"#{lines.join("\n")}\n"
|
|
109
439
|
end
|
|
110
440
|
|
|
441
|
+
# buttonVariants → BUTTON, alertVariants → ALERT. The degenerate
|
|
442
|
+
# name `"Variants"` would strip to `""` (a Ruby SyntaxError when
|
|
443
|
+
# used as a `_BASE_CLASS` prefix) — fall back to the raw name in
|
|
444
|
+
# that case. Two cva bindings whose names collapse to the same
|
|
445
|
+
# prefix (`fooVariant` and `fooVariants` → `FOO`) keep both forms
|
|
446
|
+
# disambiguated by upcasing the unstripped name as the fallback.
|
|
447
|
+
def cva_constant_prefix(cva_name)
|
|
448
|
+
stripped = cva_name.sub(/Variants?\z/, "")
|
|
449
|
+
base = stripped.empty? ? cva_name : stripped
|
|
450
|
+
AST::Inflector.underscore(base).upcase
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def format_variants_literal(variants)
|
|
454
|
+
return "{}" if variants.empty?
|
|
455
|
+
|
|
456
|
+
lines = ["{"]
|
|
457
|
+
variants.each do |axis, options|
|
|
458
|
+
opts_pairs = options.map { |k, v| "#{k.inspect} => #{v.inspect}" }.join(", ")
|
|
459
|
+
lines << " #{axis.inspect} => { #{opts_pairs} },"
|
|
460
|
+
end
|
|
461
|
+
lines << "}"
|
|
462
|
+
lines.join("\n")
|
|
463
|
+
end
|
|
464
|
+
|
|
111
465
|
def wrap_in_namespace(body)
|
|
112
466
|
return "# frozen_string_literal: true\n\n#{body}" unless @namespace
|
|
113
467
|
|
|
@@ -117,22 +471,98 @@ module JsxRosetta
|
|
|
117
471
|
|
|
118
472
|
def render_class_body(component, translator)
|
|
119
473
|
initializer = render_initializer(component, translator)
|
|
120
|
-
template =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
474
|
+
template = if component.mode == :data_factory
|
|
475
|
+
render_data_factory_method(component, translator)
|
|
476
|
+
else
|
|
477
|
+
render_view_template(component, translator)
|
|
478
|
+
end
|
|
479
|
+
# Render private methods (render_methods + lambdas) AFTER the
|
|
480
|
+
# template — `@lambda_methods` is populated during attribute-value
|
|
481
|
+
# rendering, and render_methods bodies share the same indent.
|
|
482
|
+
private_section = render_private_methods(component, translator)
|
|
483
|
+
|
|
484
|
+
sections = [initializer, template, private_section].compact.join("\n\n")
|
|
485
|
+
cls = class_name(component)
|
|
486
|
+
# One-line docstring above the class — pacifies Style/Documentation
|
|
487
|
+
# without forcing the host project to disable the cop globally. The
|
|
488
|
+
# body is intentionally minimal so it doesn't drift from the source
|
|
489
|
+
# over time; a richer comment belongs in the host repo's review.
|
|
490
|
+
"# #{cls} — generated by jsx_rosetta from JSX. Review before shipping.\n" \
|
|
491
|
+
"class #{cls} < #{parent_class}\n#{sections}\nend\n"
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# For data-factory components (`export const createColumns = (args)
|
|
495
|
+
# => [{...}, {...}]`) emit a public method that returns the
|
|
496
|
+
# translated data array. The method name is the snake_case of the
|
|
497
|
+
# original JS identifier (`createColumns` → `create_columns`).
|
|
498
|
+
# Param names come from the regular `props:` list so callers can
|
|
499
|
+
# invoke with keyword arguments matching the JS signature.
|
|
500
|
+
def render_data_factory_method(component, translator)
|
|
501
|
+
method_name = AST::Inflector.underscore(component.name)
|
|
502
|
+
param_names = component.props.map(&:name)
|
|
503
|
+
signature = data_factory_signature(method_name, param_names)
|
|
504
|
+
# Param refs translate as locals (`token`) inside the body rather
|
|
505
|
+
# than as ivars (`@token`) — the factory params are method-local,
|
|
506
|
+
# not constructor-stored. `with_locals` pushes the JS names onto
|
|
507
|
+
# the translator's local stack for the duration of the body.
|
|
508
|
+
body = translator.with_locals(param_names) do
|
|
509
|
+
render_inline_value(component.body, translator, todos: [], attr_name: nil, indent: 4)
|
|
510
|
+
end
|
|
511
|
+
" def #{signature}\n #{body}\n end"
|
|
127
512
|
end
|
|
128
513
|
|
|
129
|
-
def
|
|
130
|
-
return
|
|
514
|
+
def data_factory_signature(method_name, param_names)
|
|
515
|
+
return method_name if param_names.empty?
|
|
131
516
|
|
|
132
|
-
|
|
517
|
+
kwargs = param_names.map { |name| "#{AST::Inflector.underscore(name)}: nil" }
|
|
518
|
+
"#{method_name}(#{kwargs.join(", ")})"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Coalesce RenderMethod (from local-arrow extraction) and Lambda
|
|
522
|
+
# (from Gap H object-literal extraction) into one `private` section.
|
|
523
|
+
# Emitting `private` twice is harmless but ugly; one block reads
|
|
524
|
+
# cleaner.
|
|
525
|
+
def render_private_methods(component, translator)
|
|
526
|
+
render_methods = component.render_methods.map { |rm| render_render_method_definition(rm, translator) }
|
|
527
|
+
lambda_methods = (@lambda_methods || []).map do |entry|
|
|
133
528
|
render_lambda_method_definition(entry[:method_name], entry[:lambda], translator)
|
|
134
529
|
end
|
|
135
|
-
|
|
530
|
+
event_handlers = (@event_handler_methods || []).map do |entry|
|
|
531
|
+
render_event_handler_method_definition(entry[:method_name], entry[:handler], entry[:attr_name])
|
|
532
|
+
end
|
|
533
|
+
all = render_methods + lambda_methods + event_handlers
|
|
534
|
+
return nil if all.empty?
|
|
535
|
+
|
|
536
|
+
" private\n\n#{all.join("\n\n")}"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Emit one EventHandler as a stub method on the class. The JS body
|
|
540
|
+
# is preserved verbatim as a TODO comment; the method itself is a
|
|
541
|
+
# no-op so the file loads and the receiving component sees a real
|
|
542
|
+
# `Method` object via `method(:name)`. Parameter names snake_case
|
|
543
|
+
# from JS conventions to Ruby identifiers.
|
|
544
|
+
def render_event_handler_method_definition(method_name, handler, attr_name)
|
|
545
|
+
snake_params = handler.params.map { |p| AST::Inflector.underscore(p) }
|
|
546
|
+
signature = snake_params.empty? ? method_name : "#{method_name}(#{snake_params.join(", ")})"
|
|
547
|
+
body_lines = comment_lines(handler.body_source).map { |l| " #{l}" }
|
|
548
|
+
[
|
|
549
|
+
" def #{signature}",
|
|
550
|
+
" # TODO: translate the original JSX `#{attr_name}` handler:",
|
|
551
|
+
*body_lines,
|
|
552
|
+
" end"
|
|
553
|
+
].join("\n")
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Emit one RenderMethod as a private method on the class. Params are
|
|
557
|
+
# pushed into the translator scope so identifier references inside
|
|
558
|
+
# the body resolve to method-local arguments.
|
|
559
|
+
def render_render_method_definition(render_method, translator)
|
|
560
|
+
snake_params = render_method.params.map { |p| AST::Inflector.underscore(p) }
|
|
561
|
+
signature = snake_params.empty? ? render_method.name : "#{render_method.name}(#{snake_params.join(", ")})"
|
|
562
|
+
body = translator.with_locals(render_method.params) do
|
|
563
|
+
render_ir_node(render_method.body, translator, indent: 4)
|
|
564
|
+
end
|
|
565
|
+
" def #{signature}\n#{body}\n end"
|
|
136
566
|
end
|
|
137
567
|
|
|
138
568
|
def render_lambda_method_definition(method_name, lambda, translator)
|
|
@@ -145,20 +575,30 @@ module JsxRosetta
|
|
|
145
575
|
end
|
|
146
576
|
|
|
147
577
|
def render_initializer(component, translator)
|
|
578
|
+
# Data-factory components consume their params as method args, not
|
|
579
|
+
# as constructor props — skip the initializer entirely.
|
|
580
|
+
return nil if component.mode == :data_factory
|
|
581
|
+
|
|
148
582
|
props = initializable_props(component)
|
|
149
583
|
rest_name = component.rest_prop_name
|
|
150
584
|
return nil if props.empty? && rest_name.nil?
|
|
151
585
|
|
|
586
|
+
# Snake-case the rest-name kwarg so it matches the snake_case ivar
|
|
587
|
+
# the body uses (`**(@description_props || {})`). Emitting the
|
|
588
|
+
# camelCase JS name straight to the kwarg would create a different
|
|
589
|
+
# ivar than the body reads, silently dropping the splat's contents.
|
|
590
|
+
rest_snake = rest_name && AST::Inflector.underscore(rest_name)
|
|
152
591
|
kwargs = props.map { |prop| ruby_kwarg(prop, translator) }
|
|
153
|
-
kwargs << "**#{
|
|
592
|
+
kwargs << "**#{rest_snake}" if rest_snake
|
|
154
593
|
|
|
155
|
-
|
|
594
|
+
body = [" super()"]
|
|
595
|
+
body.concat(props.map do |prop|
|
|
156
596
|
snake = AST::Inflector.underscore(prop.name)
|
|
157
597
|
" @#{snake} = #{snake}"
|
|
158
|
-
end
|
|
159
|
-
|
|
598
|
+
end)
|
|
599
|
+
body << " @#{rest_snake} = #{rest_snake}" if rest_snake
|
|
160
600
|
|
|
161
|
-
" def initialize(#{kwargs.join(", ")})\n#{
|
|
601
|
+
" def initialize(#{kwargs.join(", ")})\n#{body.join("\n")}\n end"
|
|
162
602
|
end
|
|
163
603
|
|
|
164
604
|
def initializable_props(component)
|
|
@@ -172,6 +612,15 @@ module JsxRosetta
|
|
|
172
612
|
end
|
|
173
613
|
|
|
174
614
|
def ruby_default_for(prop, translator)
|
|
615
|
+
# Use the cva defaultVariants entry as the initializer default when
|
|
616
|
+
# the prop name matches a cva axis and the JSX didn't already
|
|
617
|
+
# specify its own default. So `variant: 'default'` flows from the
|
|
618
|
+
# cva binding's defaultVariants, even though the React function
|
|
619
|
+
# signature took it as an undefaulted prop.
|
|
620
|
+
if prop.default.nil? && (cva_default = cva_axis_default_for(prop.name))
|
|
621
|
+
return cva_default.inspect
|
|
622
|
+
end
|
|
623
|
+
|
|
175
624
|
return "nil" if prop.default.nil?
|
|
176
625
|
|
|
177
626
|
case prop.default
|
|
@@ -179,43 +628,117 @@ module JsxRosetta
|
|
|
179
628
|
translated = translator.translate(prop.default.expression)
|
|
180
629
|
translated ? translated.ruby : "nil"
|
|
181
630
|
when IR::ObjectLiteral, IR::ArrayLiteral, IR::Lambda
|
|
182
|
-
# Inline values: route through the recursive renderer
|
|
183
|
-
#
|
|
184
|
-
# parameter
|
|
185
|
-
|
|
631
|
+
# Inline values: route through the recursive renderer with
|
|
632
|
+
# `force_inline: true`. A wrapped multi-line default inside the
|
|
633
|
+
# `initialize(...)` parameter list would put the `{` at one
|
|
634
|
+
# column and the children at the indent-aligned column —
|
|
635
|
+
# legal Ruby but trips Layout/FirstHashElementIndentation. Empty
|
|
636
|
+
# todos array — TODO markers wouldn't survive a parameter list.
|
|
637
|
+
render_inline_value(prop.default, translator, todos: [], attr_name: prop.name, force_inline: true)
|
|
186
638
|
else
|
|
187
639
|
"nil"
|
|
188
640
|
end
|
|
189
641
|
end
|
|
190
642
|
|
|
643
|
+
# Look up `prop_name` (e.g. "variant") across every CvaBinding on the
|
|
644
|
+
# current component. Returns the cva default value (e.g. "default") or
|
|
645
|
+
# nil when no cva binding declares that axis with a default.
|
|
646
|
+
def cva_axis_default_for(prop_name)
|
|
647
|
+
return nil unless @current_component
|
|
648
|
+
|
|
649
|
+
@current_component.module_bindings.each do |b|
|
|
650
|
+
next unless b.is_a?(IR::CvaBinding)
|
|
651
|
+
next unless b.default_variants.key?(prop_name)
|
|
652
|
+
|
|
653
|
+
return b.default_variants[prop_name]
|
|
654
|
+
end
|
|
655
|
+
nil
|
|
656
|
+
end
|
|
657
|
+
|
|
191
658
|
def render_view_template(component, translator)
|
|
192
659
|
body = render_template_body(component, translator)
|
|
193
|
-
prefix = render_template_prefix(component)
|
|
660
|
+
prefix = render_template_prefix(component, translator)
|
|
194
661
|
body_with_prefix = prefix.empty? ? body : "#{prefix}#{body}"
|
|
195
662
|
" def view_template\n#{body_with_prefix}\n end"
|
|
196
663
|
end
|
|
197
664
|
|
|
198
|
-
def render_template_prefix(component)
|
|
665
|
+
def render_template_prefix(component, translator)
|
|
199
666
|
lines = []
|
|
200
|
-
lines.concat(render_react_hooks_todo(component.react_hooks))
|
|
667
|
+
lines.concat(render_react_hooks_todo(component.react_hooks, translator))
|
|
201
668
|
lines.concat(render_local_bindings_todo(component.local_bindings))
|
|
202
669
|
return "" if lines.empty?
|
|
203
670
|
|
|
204
671
|
"#{lines.map { |l| " #{l}" }.join("\n")}\n"
|
|
205
672
|
end
|
|
206
673
|
|
|
207
|
-
def render_react_hooks_todo(hooks)
|
|
674
|
+
def render_react_hooks_todo(hooks, translator)
|
|
208
675
|
return [] if hooks.empty?
|
|
209
676
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
677
|
+
# Preserve the source order of the first occurrence per library so
|
|
678
|
+
# the React block (typical) lands before Apollo/Next.js blocks when
|
|
679
|
+
# all three are present. group_by preserves first-seen order.
|
|
680
|
+
hooks.group_by(&:library).flat_map { |library, calls| hook_todo_block_lines(library, calls, translator) }
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def hook_todo_block_lines(library, calls, translator)
|
|
684
|
+
header_lines = HOOK_TODO_HEADERS.fetch(library, HOOK_TODO_HEADERS[:react])
|
|
685
|
+
lines = header_lines.map { |line| "# #{line}" }
|
|
686
|
+
calls.each do |call|
|
|
687
|
+
lines.concat(router_push_hint_lines(call.source, translator))
|
|
688
|
+
lines << "# operation: #{call.operation}" if call.operation
|
|
689
|
+
lines.concat(comment_lines(call.source))
|
|
690
|
+
end
|
|
216
691
|
lines
|
|
217
692
|
end
|
|
218
693
|
|
|
694
|
+
# Scan a hook's verbatim source for `router.push("…")` invocations and
|
|
695
|
+
# emit a one-line hint per match. With a route table the hint names
|
|
696
|
+
# the matching URL helper (`redirect_to user_path(@id)`); without one,
|
|
697
|
+
# it suggests adding the helper. See ROUTER_PUSH_PATTERN for the
|
|
698
|
+
# accepted shapes and why event-handler bodies are out of scope.
|
|
699
|
+
def router_push_hint_lines(source, translator)
|
|
700
|
+
source.scan(ROUTER_PUSH_PATTERN).flat_map do |(arg)|
|
|
701
|
+
hint = router_push_hint(arg, translator)
|
|
702
|
+
hint ? ["# #{hint}"] : []
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def router_push_hint(arg, translator)
|
|
707
|
+
if arg.start_with?("`")
|
|
708
|
+
template_router_push_hint(arg, translator)
|
|
709
|
+
else
|
|
710
|
+
path = arg[1..-2]
|
|
711
|
+
literal_router_push_hint(path)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def literal_router_push_hint(path)
|
|
716
|
+
helper = @href_rewriter&.rewrite_literal(path)
|
|
717
|
+
return "→ redirect_to #{helper} (translated from router.push(#{path.inspect}))" if helper
|
|
718
|
+
|
|
719
|
+
"→ consider redirect_to <helper> for router.push(#{path.inspect}) (not in route table)"
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def template_router_push_hint(template_src, translator)
|
|
723
|
+
segments = PagesRouting::HrefRewriter.parse_template_source(template_src)
|
|
724
|
+
return "→ consider redirect_to <helper> for router.push(#{template_src})" unless segments
|
|
725
|
+
|
|
726
|
+
translated = segments.map { |kind, value| kind == :hole ? translate_hole(value, translator) : [kind, value] }
|
|
727
|
+
return "→ consider redirect_to <helper> for router.push(#{template_src})" if translated.any?(&:nil?)
|
|
728
|
+
|
|
729
|
+
helper = @href_rewriter&.rewrite_template(translated)
|
|
730
|
+
return "→ redirect_to #{helper} (translated from router.push(#{template_src}))" if helper
|
|
731
|
+
|
|
732
|
+
"→ consider redirect_to <helper> for router.push(#{template_src}) (path not in route table)"
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def translate_hole(js_source, translator)
|
|
736
|
+
translated = translator.translate(js_source)
|
|
737
|
+
return nil unless translated && translated.ruby != "nil"
|
|
738
|
+
|
|
739
|
+
[:hole, translated.ruby]
|
|
740
|
+
end
|
|
741
|
+
|
|
219
742
|
def render_local_bindings_todo(bindings)
|
|
220
743
|
return [] if bindings.empty?
|
|
221
744
|
|
|
@@ -249,10 +772,29 @@ module JsxRosetta
|
|
|
249
772
|
when IR::Conditional then render_conditional(node, translator, indent: indent)
|
|
250
773
|
when IR::Loop then render_loop(node, translator, indent: indent)
|
|
251
774
|
when IR::RenderProp then render_orphan_render_prop(node, translator, indent: indent)
|
|
775
|
+
when IR::LocalRenderCall then render_local_render_call(node, translator, indent: indent)
|
|
252
776
|
when IR::Slot then render_slot(node, indent: indent)
|
|
253
777
|
when IR::Text then render_text(node, indent: indent)
|
|
254
778
|
when IR::Interpolation then render_interpolation(node, translator, indent: indent)
|
|
255
779
|
when IR::Comment then render_comment(node, indent: indent)
|
|
780
|
+
when IR::LayoutYield then "#{" " * indent}yield"
|
|
781
|
+
end
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
# Emit a call to a previously-extracted RenderMethod. The method body
|
|
785
|
+
# uses `tag.*`/`render` helpers (Phlex executes inside the view), so
|
|
786
|
+
# invoking it inline produces output at the right place in the
|
|
787
|
+
# template. Arg expressions are translated; any that fail translation
|
|
788
|
+
# fall back to verbatim source.
|
|
789
|
+
def render_local_render_call(call, translator, indent:)
|
|
790
|
+
if call.args.empty?
|
|
791
|
+
"#{spaces(indent)}#{call.method_name}"
|
|
792
|
+
else
|
|
793
|
+
arg_sources = call.args.map do |arg|
|
|
794
|
+
translated = translator.translate(arg.expression)
|
|
795
|
+
translated ? translated.ruby : arg.expression
|
|
796
|
+
end
|
|
797
|
+
"#{spaces(indent)}#{call.method_name}(#{arg_sources.join(", ")})"
|
|
256
798
|
end
|
|
257
799
|
end
|
|
258
800
|
|
|
@@ -268,36 +810,81 @@ module JsxRosetta
|
|
|
268
810
|
|
|
269
811
|
def render_element(element, translator, indent:)
|
|
270
812
|
todos = []
|
|
271
|
-
attrs_source = format_attributes(element.attributes, translator,
|
|
813
|
+
attrs_source = format_attributes(element.attributes, translator,
|
|
814
|
+
context: :html, tag: element.tag, todos: todos, indent: indent)
|
|
272
815
|
method_call = "#{element.tag}#{attrs_source}"
|
|
816
|
+
body = element_body(element, method_call, translator, indent)
|
|
817
|
+
prepend_attribute_todos(todos, indent, body)
|
|
818
|
+
end
|
|
273
819
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
279
|
-
end
|
|
820
|
+
# Decide whether the HTML tag is blockless, yield-only (auto-yield for
|
|
821
|
+
# self-closing-with-spread), or full do/end (explicit children).
|
|
822
|
+
def element_body(element, method_call, translator, indent)
|
|
823
|
+
return "#{spaces(indent)}#{method_call}" if blockless_element?(element)
|
|
280
824
|
|
|
281
|
-
|
|
825
|
+
if element.children.empty?
|
|
826
|
+
# `<tag {...rest} />` — the rest-spread carries React `children`,
|
|
827
|
+
# but JSX self-closes so there are no explicit IR children. Yield
|
|
828
|
+
# to the Phlex caller's block so `Component.new { ... }` nesting
|
|
829
|
+
# actually renders; guard with `block_given?` so callers who pass
|
|
830
|
+
# no block don't blow up.
|
|
831
|
+
yield_only_block(method_call, indent)
|
|
832
|
+
else
|
|
833
|
+
inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
834
|
+
"#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def blockless_element?(element)
|
|
839
|
+
return true if VOID_ELEMENTS.include?(element.tag)
|
|
840
|
+
|
|
841
|
+
element.children.empty? && !spreads_children?(element)
|
|
282
842
|
end
|
|
283
843
|
|
|
284
844
|
def render_component_invocation(invocation, translator, indent:)
|
|
285
845
|
todos = []
|
|
286
|
-
kwargs = component_invocation_kwargs(invocation.props, translator,
|
|
846
|
+
kwargs = component_invocation_kwargs(invocation.props, translator,
|
|
847
|
+
todos: todos, indent: indent, tag: invocation.name)
|
|
287
848
|
class_ref = component_class_reference(invocation.name)
|
|
288
849
|
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
850
|
+
body = component_invocation_body(invocation, new_call, translator, indent)
|
|
851
|
+
prepend_attribute_todos(todos, indent, body)
|
|
852
|
+
end
|
|
289
853
|
|
|
854
|
+
def component_invocation_body(invocation, new_call, translator, indent)
|
|
290
855
|
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
|
|
291
|
-
|
|
292
|
-
render_with_render_prop(new_call, render_prop, translator, indent)
|
|
293
|
-
elsif invocation.children.empty?
|
|
294
|
-
"#{spaces(indent)}render #{new_call}"
|
|
295
|
-
else
|
|
296
|
-
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
297
|
-
"#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
|
|
298
|
-
end
|
|
856
|
+
return render_with_render_prop(new_call, render_prop, translator, indent) if render_prop
|
|
299
857
|
|
|
300
|
-
|
|
858
|
+
call = "render #{new_call}"
|
|
859
|
+
return "#{spaces(indent)}#{call}" if blockless_invocation?(invocation)
|
|
860
|
+
|
|
861
|
+
if invocation.children.empty?
|
|
862
|
+
# `<Component {...rest} />` — same idiom as element_body. The
|
|
863
|
+
# spread carries `children`; yield to the caller's block.
|
|
864
|
+
yield_only_block(call, indent)
|
|
865
|
+
else
|
|
866
|
+
inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
|
|
867
|
+
"#{spaces(indent)}#{call} do\n#{inner}\n#{spaces(indent)}end"
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def blockless_invocation?(invocation)
|
|
872
|
+
invocation.children.empty? && !spreads_children?(invocation)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def yield_only_block(call, indent)
|
|
876
|
+
outer = spaces(indent)
|
|
877
|
+
inner = spaces(indent + 2)
|
|
878
|
+
"#{outer}#{call} do\n#{inner}yield if block_given?\n#{outer}end"
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Does this Element/ComponentInvocation carry a JSX rest-spread
|
|
882
|
+
# (`{...props}`) that may transport React `children` we can't see in
|
|
883
|
+
# the IR? Used to decide whether a self-closing JSX tag should still
|
|
884
|
+
# yield to the Phlex caller's block.
|
|
885
|
+
def spreads_children?(node)
|
|
886
|
+
attrs = node.respond_to?(:attributes) ? node.attributes : node.props
|
|
887
|
+
attrs.any?(IR::SpreadAttribute)
|
|
301
888
|
end
|
|
302
889
|
|
|
303
890
|
# Emit a render-prop child as a Ruby block on the parent `render` call.
|
|
@@ -324,9 +911,14 @@ module JsxRosetta
|
|
|
324
911
|
# JSX `<Foo>` → `Foo` (default), `FooComponent` (suffix), or just
|
|
325
912
|
# `Foo` again under namespace (Ruby's constant lookup finds the
|
|
326
913
|
# peer class). JSX `<Foo.Bar>` → `Foo::Bar` (plus suffix when set).
|
|
914
|
+
# `<HomePage>` keeps the `Page` suffix without doubling (no
|
|
915
|
+
# `HomePageComponent`) — see effective_suffix_for. The path-based
|
|
916
|
+
# page detection doesn't apply here: an invocation only carries
|
|
917
|
+
# the JSX tag name, not the target file's path.
|
|
327
918
|
def component_class_reference(jsx_tag)
|
|
328
919
|
segments = jsx_tag.split(".")
|
|
329
|
-
|
|
920
|
+
suffix = effective_suffix_for(segments.last)
|
|
921
|
+
segments[-1] = "#{segments.last}#{suffix}" if suffix
|
|
330
922
|
segments.join("::")
|
|
331
923
|
end
|
|
332
924
|
|
|
@@ -335,17 +927,107 @@ module JsxRosetta
|
|
|
335
927
|
end
|
|
336
928
|
|
|
337
929
|
def render_conditional(conditional, translator, indent:)
|
|
338
|
-
|
|
930
|
+
if guard_ladder?(conditional, translator)
|
|
931
|
+
return render_guard_ladder_collapse(conditional, translator, indent: indent)
|
|
932
|
+
end
|
|
933
|
+
|
|
339
934
|
lines = []
|
|
340
|
-
|
|
341
|
-
lines << "#{spaces(indent)}
|
|
935
|
+
emit_conditional_branches(conditional, translator, indent, lines, leading_keyword: "if")
|
|
936
|
+
lines << "#{spaces(indent)}end"
|
|
937
|
+
lines.join("\n")
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
# A guard ladder is a chain of `if/elsif` branches whose tests are all
|
|
941
|
+
# untranslatable AND whose consequents are all "render nothing" (the
|
|
942
|
+
# lowered form of `return null` in a guard), terminating in a real
|
|
943
|
+
# else branch. Emitted naively as `if false / elsif false / .../ else
|
|
944
|
+
# <main>`, the else *always* fires — silently inverting the source
|
|
945
|
+
# semantic ("render nothing when any guard hits") into "render main
|
|
946
|
+
# unconditionally." Collapse to a single TODO block + just the else
|
|
947
|
+
# so the reviewer sees what guards used to gate the render, and the
|
|
948
|
+
# main render is at least visible without the misleading `if false`s.
|
|
949
|
+
def guard_ladder?(conditional, translator)
|
|
950
|
+
branches, else_branch = walk_conditional_chain(conditional)
|
|
951
|
+
return false unless else_branch
|
|
952
|
+
return false if branches.empty?
|
|
953
|
+
|
|
954
|
+
branches.all? do |b|
|
|
955
|
+
test_translates_to_untranslatable?(b[:test], translator) && empty_consequent?(b[:consequent])
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
def render_guard_ladder_collapse(conditional, translator, indent:)
|
|
960
|
+
branches, else_branch = walk_conditional_chain(conditional)
|
|
961
|
+
lines = ["#{spaces(indent)}# TODO: #{branches.length} render guard(s) couldn't translate; wire up Rails-side:"]
|
|
962
|
+
branches.each do |b|
|
|
963
|
+
compact = b[:test].tr("\n", " ").squeeze(" ")
|
|
964
|
+
lines << "#{spaces(indent)}# #{compact}"
|
|
965
|
+
end
|
|
966
|
+
lines << render_ir_node(else_branch, translator, indent: indent)
|
|
967
|
+
lines.join("\n")
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def walk_conditional_chain(conditional)
|
|
971
|
+
branches = []
|
|
972
|
+
node = conditional
|
|
973
|
+
while node.is_a?(IR::Conditional)
|
|
974
|
+
branches << { test: node.test.expression, consequent: node.consequent }
|
|
975
|
+
node = node.alternate
|
|
976
|
+
end
|
|
977
|
+
[branches, node]
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def test_translates_to_untranslatable?(expression, translator)
|
|
981
|
+
# Use condition-mode translation so bucket-4 hits (hook-tuple
|
|
982
|
+
# destructures, top-level imports) count as translatable. When a
|
|
983
|
+
# guard ladder's tests *do* translate under widening, the ladder
|
|
984
|
+
# collapses to nothing — render_conditional emits the real
|
|
985
|
+
# `if @ivar / elsif @ivar / else <main>` form, which correctly
|
|
986
|
+
# short-circuits to render nothing when any guard fires. Only
|
|
987
|
+
# untranslatable tests (verbatim JS we can't ivar-promote) still
|
|
988
|
+
# trip the collapse-to-TODO path.
|
|
989
|
+
translated = translator.translate_condition(expression)
|
|
990
|
+
translated.nil? || translated.ruby == "nil"
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# An empty consequent is what `return null` (the JS guard idiom) lowers
|
|
994
|
+
# to. Detected as either a literal empty Text node or a Fragment whose
|
|
995
|
+
# children are all empty.
|
|
996
|
+
def empty_consequent?(node)
|
|
997
|
+
case node
|
|
998
|
+
when IR::Text then node.value.to_s.empty?
|
|
999
|
+
when IR::Fragment then node.children.all? { |c| empty_consequent?(c) }
|
|
1000
|
+
else false
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Walk a Conditional and its `alternate` chain, emitting `if` for the
|
|
1005
|
+
# first test, `elsif` for each alternate that is itself a Conditional,
|
|
1006
|
+
# and a final `else` for a non-Conditional alternate. Flattens the
|
|
1007
|
+
# `if X / else / if Y / end / end` shape that JS `else if` chains
|
|
1008
|
+
# produce into idiomatic Ruby `if X / elsif Y / else / end`. Without
|
|
1009
|
+
# this, deeply nested conditional chains explode the file's
|
|
1010
|
+
# indentation and trip Style/IfInsideElse + Metrics/BlockNesting.
|
|
1011
|
+
def emit_conditional_branches(conditional, translator, indent, lines, leading_keyword:)
|
|
1012
|
+
test_ruby, todo, promoted = safe_test_expression(conditional.test.expression, translator, fallback: "false")
|
|
1013
|
+
if todo
|
|
1014
|
+
lines << "#{spaces(indent)}# TODO: translate condition: #{todo}"
|
|
1015
|
+
elsif promoted && !promoted.empty?
|
|
1016
|
+
lines << "#{spaces(indent)}# TODO: render condition references binding(s) promoted to @ivar — " \
|
|
1017
|
+
"thread as controller-passed prop(s): #{promoted.join(", ")}"
|
|
1018
|
+
end
|
|
1019
|
+
lines << "#{spaces(indent)}#{leading_keyword} #{test_ruby}"
|
|
342
1020
|
lines << render_ir_node(conditional.consequent, translator, indent: indent + 2)
|
|
343
|
-
|
|
1021
|
+
|
|
1022
|
+
alt = conditional.alternate
|
|
1023
|
+
return unless alt
|
|
1024
|
+
|
|
1025
|
+
if alt.is_a?(IR::Conditional)
|
|
1026
|
+
emit_conditional_branches(alt, translator, indent, lines, leading_keyword: "elsif")
|
|
1027
|
+
else
|
|
344
1028
|
lines << "#{spaces(indent)}else"
|
|
345
|
-
lines << render_ir_node(
|
|
1029
|
+
lines << render_ir_node(alt, translator, indent: indent + 2)
|
|
346
1030
|
end
|
|
347
|
-
lines << "#{spaces(indent)}end"
|
|
348
|
-
lines.join("\n")
|
|
349
1031
|
end
|
|
350
1032
|
|
|
351
1033
|
def render_loop(loop_node, translator, indent:)
|
|
@@ -375,26 +1057,51 @@ module JsxRosetta
|
|
|
375
1057
|
when IR::ArrayLiteral
|
|
376
1058
|
[render_array_literal_value(iterable, translator, todos: []), nil]
|
|
377
1059
|
when IR::Interpolation
|
|
378
|
-
|
|
1060
|
+
safe_iterable_expression(iterable.expression, translator)
|
|
379
1061
|
else
|
|
380
1062
|
["[]", iterable.inspect]
|
|
381
1063
|
end
|
|
382
1064
|
end
|
|
383
1065
|
|
|
1066
|
+
# Iterable variant of safe_test_expression. Uses narrow (non-condition)
|
|
1067
|
+
# translation: a hook-tuple `items` translates to `nil` here rather than
|
|
1068
|
+
# being promoted to `@items`. Rationale: `nil.each` would crash at
|
|
1069
|
+
# render time; `[].each` renders nothing while the accompanying TODO
|
|
1070
|
+
# documents what didn't translate. Render-condition contexts (A1)
|
|
1071
|
+
# take the opposite trade-off because driving `if nil` silently
|
|
1072
|
+
# disables a load-bearing decision.
|
|
1073
|
+
def safe_iterable_expression(expression, translator)
|
|
1074
|
+
translated = translator.translate(expression)
|
|
1075
|
+
return [translated.ruby, nil] if translated && translated.ruby != "nil"
|
|
1076
|
+
|
|
1077
|
+
compact = expression.tr("\n", " ").squeeze(" ")
|
|
1078
|
+
["[]", compact]
|
|
1079
|
+
end
|
|
1080
|
+
|
|
384
1081
|
# Translate an expression intended to drive an `if` or `.each` call.
|
|
385
|
-
# Returns `[ruby_source, todo_text]
|
|
386
|
-
# the
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
1082
|
+
# Returns `[ruby_source, todo_text, promoted_locals]`:
|
|
1083
|
+
# - When the translator can parse the expression cleanly, `todo_text`
|
|
1084
|
+
# is nil. `promoted_locals` carries any bucket-4 names the
|
|
1085
|
+
# condition-mode translator promoted to `@ivar` — the caller
|
|
1086
|
+
# surfaces these in a TODO so a reviewer knows which bindings need
|
|
1087
|
+
# to become controller-passed props.
|
|
1088
|
+
# - When the translator can't, the caller's `fallback` (`"false"` for
|
|
1089
|
+
# conditions, `"[]"` for iterables) is returned with the verbatim
|
|
1090
|
+
# expression in `todo_text` so a TODO comment can be emitted. Without
|
|
1091
|
+
# this, JS operators like `!==`, `===`, optional chaining, and `in`
|
|
1092
|
+
# would leak into Ruby and produce SyntaxError on load.
|
|
1093
|
+
#
|
|
1094
|
+
# Condition-mode translation (`translate_condition`) is used for both
|
|
1095
|
+
# branches — it widens bucket-4 hits to `@ivar` so `if loading; …`
|
|
1096
|
+
# becomes `if @loading; …` instead of the dead `if false` fallback.
|
|
1097
|
+
# The promoted list separates that path from full-clean translation,
|
|
1098
|
+
# which has nothing to flag.
|
|
392
1099
|
def safe_test_expression(expression, translator, fallback:)
|
|
393
|
-
translated = translator.
|
|
394
|
-
return [translated.ruby, nil] if translated
|
|
1100
|
+
translated = translator.translate_condition(expression)
|
|
1101
|
+
return [translated.ruby, nil, translated.promoted_locals] if translated && translated.ruby != "nil"
|
|
395
1102
|
|
|
396
1103
|
compact = expression.tr("\n", " ").squeeze(" ")
|
|
397
|
-
[fallback, compact]
|
|
1104
|
+
[fallback, compact, []]
|
|
398
1105
|
end
|
|
399
1106
|
|
|
400
1107
|
def render_slot(slot, indent:)
|
|
@@ -406,7 +1113,7 @@ module JsxRosetta
|
|
|
406
1113
|
end
|
|
407
1114
|
|
|
408
1115
|
def render_text(text, indent:)
|
|
409
|
-
"#{spaces(indent)}plain #{text.value
|
|
1116
|
+
"#{spaces(indent)}plain #{AST::Inflector.ruby_string_literal(text.value)}"
|
|
410
1117
|
end
|
|
411
1118
|
|
|
412
1119
|
def render_interpolation(interpolation, translator, indent:)
|
|
@@ -443,8 +1150,9 @@ module JsxRosetta
|
|
|
443
1150
|
# renders something visible at runtime.
|
|
444
1151
|
def render_untranslated_interpolation(expression, indent)
|
|
445
1152
|
compact = expression.tr("\n", " ").squeeze(" ")
|
|
1153
|
+
placeholder = AST::Inflector.ruby_string_literal("[untranslated: #{compact}]")
|
|
446
1154
|
"#{spaces(indent)}# TODO: translate #{compact.inspect}\n" \
|
|
447
|
-
"#{spaces(indent)}plain #{
|
|
1155
|
+
"#{spaces(indent)}plain #{placeholder}"
|
|
448
1156
|
end
|
|
449
1157
|
|
|
450
1158
|
def render_comment(comment, indent:)
|
|
@@ -459,53 +1167,141 @@ module JsxRosetta
|
|
|
459
1167
|
# of `h1()`). The `context:` param selects naming convention:
|
|
460
1168
|
# - :html (HTML element attrs — preserve camelCase for SVG)
|
|
461
1169
|
# - :component (Ruby method args — snake_case via Inflector.underscore)
|
|
462
|
-
def format_attributes(attributes, translator, context: :html, todos: [])
|
|
1170
|
+
def format_attributes(attributes, translator, context: :html, tag: nil, todos: [], indent: 0)
|
|
463
1171
|
events, others = attributes.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
464
1172
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
465
1173
|
|
|
466
|
-
|
|
467
|
-
|
|
1174
|
+
# Per-element form-method check. Set before any attribute renders
|
|
1175
|
+
# so try_rewrite_href can consult it. Scoped per element via a
|
|
1176
|
+
# local backup/restore around the iteration — nested forms are
|
|
1177
|
+
# rare but legal HTML.
|
|
1178
|
+
prev_form_rewritable = @form_action_rewritable
|
|
1179
|
+
@form_action_rewritable = tag == "form" ? form_action_rewritable?(plain_attrs) : false
|
|
1180
|
+
|
|
1181
|
+
parts = { sym: [], str: [] }
|
|
468
1182
|
plain_attrs.each do |a|
|
|
469
|
-
append_attribute_part(a, translator,
|
|
1183
|
+
append_attribute_part(a, translator, parts, context: context, tag: tag, todos: todos, indent: indent)
|
|
470
1184
|
end
|
|
471
|
-
|
|
1185
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
472
1186
|
|
|
473
|
-
joined = build_attribute_list(
|
|
1187
|
+
joined = build_attribute_list(parts, spreads, translator)
|
|
474
1188
|
joined.empty? ? "" : "(#{joined})"
|
|
1189
|
+
ensure
|
|
1190
|
+
@form_action_rewritable = prev_form_rewritable
|
|
475
1191
|
end
|
|
476
1192
|
|
|
477
|
-
def append_attribute_part(attribute, translator,
|
|
478
|
-
part = phlex_attribute_part(attribute, translator, context: context, todos: todos)
|
|
1193
|
+
def append_attribute_part(attribute, translator, parts, context:, todos:, indent: 0, tag: nil)
|
|
1194
|
+
part = phlex_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
|
|
479
1195
|
return unless part
|
|
480
1196
|
|
|
481
|
-
(part[:string_key] ?
|
|
1197
|
+
(part[:string_key] ? parts[:str] : parts[:sym]) << part[:source]
|
|
482
1198
|
end
|
|
483
1199
|
|
|
484
|
-
def build_attribute_list(
|
|
485
|
-
pieces =
|
|
486
|
-
pieces << "**{ #{
|
|
1200
|
+
def build_attribute_list(parts, spreads, translator)
|
|
1201
|
+
pieces = parts[:sym].dup
|
|
1202
|
+
pieces << "**{ #{parts[:str].join(", ")} }" if parts[:str].any?
|
|
487
1203
|
pieces.concat(spreads.map { |s| "**#{render_spread(s.expression, translator)}" })
|
|
488
1204
|
pieces.join(", ")
|
|
489
1205
|
end
|
|
490
1206
|
|
|
491
1207
|
# Emit one attribute as either a {string_key: false, source: "id: @x"}
|
|
492
1208
|
# (Ruby-kwarg-safe name) or {string_key: true, source: '"xml:lang" => @x'}
|
|
493
|
-
# (rare; non-identifier name — goes into a **{ ... } splat).
|
|
494
|
-
|
|
1209
|
+
# (rare; non-identifier name — goes into a **{ ... } splat). Returns
|
|
1210
|
+
# nil to signal "drop this attribute entirely" — used when every style
|
|
1211
|
+
# declaration dropped (would emit `style: ''`) or every plain-attribute
|
|
1212
|
+
# value bailed (would emit `attr: nil`); the TODO comment above the
|
|
1213
|
+
# element already describes what was lost.
|
|
1214
|
+
def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
|
|
495
1215
|
case attribute
|
|
1216
|
+
when IR::CvaCallSite then cva_call_site_attribute_part(attribute, translator)
|
|
496
1217
|
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
|
|
497
1218
|
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
|
|
498
|
-
when IR::Style then
|
|
499
|
-
when IR::Attribute
|
|
1219
|
+
when IR::Style then style_attribute_part(attribute, translator, todos: todos)
|
|
1220
|
+
when IR::Attribute
|
|
1221
|
+
plain_attribute_part(attribute, translator, context: context, tag: tag, todos: todos, indent: indent)
|
|
500
1222
|
end
|
|
501
1223
|
end
|
|
502
1224
|
|
|
1225
|
+
# Skip the `style:` kwarg entirely when every declaration failed to
|
|
1226
|
+
# translate — `style: ''` is invalid HTML output and pure noise; the
|
|
1227
|
+
# per-declaration TODO comments above the element preserve what was
|
|
1228
|
+
# there.
|
|
1229
|
+
def style_attribute_part(style, translator, todos:)
|
|
1230
|
+
ruby = style_to_ruby_string(style, translator, todos: todos)
|
|
1231
|
+
return nil if empty_style_ruby?(ruby)
|
|
1232
|
+
|
|
1233
|
+
{ string_key: false, source: "style: #{ruby}" }
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def empty_style_ruby?(ruby)
|
|
1237
|
+
["''", '""'].include?(ruby)
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
# A "dropped" attribute is one where translation failed AND the
|
|
1241
|
+
# fallback was the literal `nil` string. We detect this by watching
|
|
1242
|
+
# whether a TODO was appended during the value computation: a real
|
|
1243
|
+
# `attr={null}` in the source produces `nil` *without* a TODO and
|
|
1244
|
+
# should still emit (preserves intent); a failed translation
|
|
1245
|
+
# produces `nil` *with* a TODO and we drop the kwarg to keep output
|
|
1246
|
+
# clean — the TODO above the element already describes the loss.
|
|
1247
|
+
def dropped_value?(value_ruby, todos_before, todos_after)
|
|
1248
|
+
value_ruby == "nil" && todos_after.length > todos_before
|
|
1249
|
+
end
|
|
1250
|
+
|
|
503
1251
|
def class_attribute_part(expression, translator)
|
|
504
1252
|
translated = translator.translate(expression)
|
|
505
1253
|
ruby = translated ? translated.ruby : expression.inspect
|
|
506
1254
|
{ string_key: false, source: "class: #{ruby}" }
|
|
507
1255
|
end
|
|
508
1256
|
|
|
1257
|
+
# Render an IR::CvaCallSite as the `class:` kwarg. Always produces
|
|
1258
|
+
# a single Ruby string-interpolation literal that references the
|
|
1259
|
+
# backend-emitted constants for the cva binding. The detection
|
|
1260
|
+
# happened at lowering time (in `try_lower_cva_call_site`), so the
|
|
1261
|
+
# node already carries the binding name, axes, and optional
|
|
1262
|
+
# class_arg — no regex over verbatim JS source here.
|
|
1263
|
+
def cva_call_site_attribute_part(node, translator)
|
|
1264
|
+
cva = find_cva_binding(node.binding_name)
|
|
1265
|
+
return { string_key: false, source: "class: nil" } unless cva
|
|
1266
|
+
|
|
1267
|
+
parts = cva_call_site_parts(node, cva, translator)
|
|
1268
|
+
{ string_key: false, source: %(class: "#{parts.join(" ")}") }
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
def find_cva_binding(binding_name)
|
|
1272
|
+
@current_component&.module_bindings&.find do |b|
|
|
1273
|
+
b.is_a?(IR::CvaBinding) && b.name == binding_name
|
|
1274
|
+
end
|
|
1275
|
+
end
|
|
1276
|
+
|
|
1277
|
+
def cva_call_site_parts(node, cva, translator)
|
|
1278
|
+
prefix = cva_constant_prefix(cva.name)
|
|
1279
|
+
parts = ["\#{#{prefix}_BASE_CLASS}"]
|
|
1280
|
+
node.axes.each do |pair|
|
|
1281
|
+
next unless cva.variants.key?(pair.axis)
|
|
1282
|
+
|
|
1283
|
+
parts << "\#{#{prefix}_VARIANT_CLASSES[#{pair.axis.inspect}][#{cva_axis_ruby_value(pair)}]}"
|
|
1284
|
+
end
|
|
1285
|
+
parts << "\#{#{cva_class_arg_ruby(node.class_arg, translator)}}" if node.class_arg
|
|
1286
|
+
parts
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def cva_class_arg_ruby(class_arg, translator)
|
|
1290
|
+
translated = translator.translate(class_arg.expression)
|
|
1291
|
+
return translated.ruby if translated
|
|
1292
|
+
|
|
1293
|
+
"@#{AST::Inflector.underscore(class_arg.expression)}"
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def cva_axis_ruby_value(pair)
|
|
1297
|
+
case pair.kind
|
|
1298
|
+
when :literal_string then pair.source.inspect
|
|
1299
|
+
when :literal_other then pair.source
|
|
1300
|
+
when :literal_nil then "nil"
|
|
1301
|
+
when :prop_ref then "@#{AST::Inflector.underscore(pair.source)}"
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
|
|
509
1305
|
# Map a JSX attribute name to its Ruby kwarg form. For HTML element
|
|
510
1306
|
# attrs (`context: :html`), only hyphens convert to underscores —
|
|
511
1307
|
# camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
|
|
@@ -515,8 +1311,12 @@ module JsxRosetta
|
|
|
515
1311
|
# follow snake_case convention (`defaultValue` → `default_value`).
|
|
516
1312
|
# Names that aren't valid Ruby identifiers after conversion (rare:
|
|
517
1313
|
# `xml:lang` and friends) fall back to a quoted string key.
|
|
518
|
-
def plain_attribute_part(attribute, translator, context:, todos:)
|
|
519
|
-
|
|
1314
|
+
def plain_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
|
|
1315
|
+
todos_before = todos.length
|
|
1316
|
+
value_ruby = attribute_value_to_ruby(attribute.name, attribute.value, translator,
|
|
1317
|
+
todos: todos, indent: indent, tag: tag)
|
|
1318
|
+
return nil if dropped_value?(value_ruby, todos_before, todos)
|
|
1319
|
+
|
|
520
1320
|
ruby_name = case context
|
|
521
1321
|
when :component then AST::Inflector.underscore(attribute.name)
|
|
522
1322
|
else attribute.name.tr("-", "_")
|
|
@@ -524,59 +1324,182 @@ module JsxRosetta
|
|
|
524
1324
|
if ruby_name.match?(VALID_IDENTIFIER)
|
|
525
1325
|
{ string_key: false, source: "#{ruby_name}: #{value_ruby}" }
|
|
526
1326
|
else
|
|
527
|
-
{ string_key: true, source: "#{attribute.name
|
|
1327
|
+
{ string_key: true, source: "#{AST::Inflector.ruby_string_literal(attribute.name)} => #{value_ruby}" }
|
|
528
1328
|
end
|
|
529
1329
|
end
|
|
530
1330
|
|
|
531
|
-
def attribute_value_to_ruby(name, value, translator, todos:)
|
|
1331
|
+
def attribute_value_to_ruby(name, value, translator, todos:, indent: 0, tag: nil)
|
|
1332
|
+
if (rewrite = try_rewrite_href(name, value, translator, tag: tag))
|
|
1333
|
+
return rewrite
|
|
1334
|
+
end
|
|
1335
|
+
|
|
532
1336
|
case value
|
|
533
1337
|
when true then "true"
|
|
534
|
-
when String then value
|
|
1338
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
535
1339
|
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)
|
|
1340
|
+
when IR::ObjectLiteral then render_object_literal_value(value, translator, todos: todos, indent: indent)
|
|
1341
|
+
when IR::ArrayLiteral then render_array_literal_value(value, translator, todos: todos, indent: indent)
|
|
538
1342
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: name)
|
|
1343
|
+
when IR::EventHandler then render_event_handler_method_reference(value, attr_name: name)
|
|
1344
|
+
when IR::ComponentInvocation
|
|
1345
|
+
component_invocation_value(value, translator, todos: todos, attr_name: name)
|
|
1346
|
+
when IR::Element, IR::Fragment
|
|
1347
|
+
# An HTML tag (`title={<span>x</span>}`) or a multi-element
|
|
1348
|
+
# Fragment as an attribute value needs a Phlex execution context
|
|
1349
|
+
# the receiver might not provide. Drop with a TODO rather than
|
|
1350
|
+
# emit a broken kwarg or a speculative method reference.
|
|
1351
|
+
drop_jsx_value_with_todo(name, value, todos: todos)
|
|
1352
|
+
end
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
# Slice 3: rewrite `href`/`to` on link-shaped tags to a Rails URL
|
|
1356
|
+
# helper call when the literal/template-literal path matches a
|
|
1357
|
+
# route in the scanned table. Returns nil to fall through to the
|
|
1358
|
+
# default emission path. Slice C1 added `<form action>` — see
|
|
1359
|
+
# form_action_rewritable? for the method-attribute check.
|
|
1360
|
+
def try_rewrite_href(name, value, translator, tag:)
|
|
1361
|
+
return nil unless @href_rewriter
|
|
1362
|
+
return nil unless tag
|
|
1363
|
+
|
|
1364
|
+
allowed_attrs = LINK_TAG_ATTRS[tag]
|
|
1365
|
+
return nil unless allowed_attrs&.include?(name)
|
|
1366
|
+
return nil if tag == "form" && !@form_action_rewritable
|
|
1367
|
+
|
|
1368
|
+
case value
|
|
1369
|
+
when String
|
|
1370
|
+
@href_rewriter.rewrite_literal(value)
|
|
1371
|
+
when IR::Interpolation
|
|
1372
|
+
rewrite_interpolation_href(value.expression, translator)
|
|
1373
|
+
end
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
# `<form action="…">` only rewrites for safe (GET) submissions —
|
|
1377
|
+
# slice-1 routes are GET-only, so POST/PUT/DELETE forms must stay
|
|
1378
|
+
# verbatim until the route table supports non-GET routes. Returns
|
|
1379
|
+
# true when no method attr is set (HTML default = GET) or when the
|
|
1380
|
+
# method is a literal GET; false otherwise (including interpolated
|
|
1381
|
+
# / TODO methods where we can't statically prove GET).
|
|
1382
|
+
def form_action_rewritable?(attributes)
|
|
1383
|
+
method_attr = attributes.find { |a| a.is_a?(IR::Attribute) && a.name == "method" }
|
|
1384
|
+
return true unless method_attr
|
|
1385
|
+
|
|
1386
|
+
case method_attr.value
|
|
1387
|
+
when nil, true then true
|
|
1388
|
+
when String then method_attr.value.casecmp("get").zero?
|
|
1389
|
+
else false
|
|
539
1390
|
end
|
|
540
1391
|
end
|
|
541
1392
|
|
|
1393
|
+
def rewrite_interpolation_href(js_source, translator)
|
|
1394
|
+
if (literal = string_literal_path(js_source))
|
|
1395
|
+
return @href_rewriter.rewrite_literal(literal)
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
segments = PagesRouting::HrefRewriter.parse_template_source(js_source)
|
|
1399
|
+
return nil unless segments
|
|
1400
|
+
|
|
1401
|
+
translated = segments.map do |kind, val|
|
|
1402
|
+
next [:literal, val] if kind == :literal
|
|
1403
|
+
|
|
1404
|
+
result = translator.translate(val)
|
|
1405
|
+
return nil unless result && result.ruby != "nil"
|
|
1406
|
+
|
|
1407
|
+
[:hole, result.ruby]
|
|
1408
|
+
end
|
|
1409
|
+
@href_rewriter.rewrite_template(translated)
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
def string_literal_path(js_source)
|
|
1413
|
+
return nil unless js_source.is_a?(String)
|
|
1414
|
+
return nil unless js_source.length >= 2
|
|
1415
|
+
|
|
1416
|
+
first = js_source[0]
|
|
1417
|
+
return nil unless ['"', "'"].include?(first)
|
|
1418
|
+
return nil unless js_source[-1] == first
|
|
1419
|
+
|
|
1420
|
+
body = js_source[1..-2]
|
|
1421
|
+
return nil if body.include?(first) || body.include?("\\")
|
|
1422
|
+
|
|
1423
|
+
body
|
|
1424
|
+
end
|
|
1425
|
+
|
|
542
1426
|
# Render an ObjectLiteral as a Ruby hash literal. Identifier-keyed
|
|
543
1427
|
# entries become Ruby kwargs (snake_cased to match Ruby convention);
|
|
544
1428
|
# non-identifier keys (numeric, hyphenated) fall back to string keys.
|
|
545
|
-
|
|
1429
|
+
# When the single-line rendering exceeds LITERAL_INLINE_BUDGET, or
|
|
1430
|
+
# when any rendered child value spans multiple lines, the layout
|
|
1431
|
+
# switches to one entry per line, indented two spaces past `indent`.
|
|
1432
|
+
def render_object_literal_value(object_literal, translator, todos:, indent: 0, force_inline: false)
|
|
1433
|
+
child_indent = indent + 2
|
|
546
1434
|
parts = object_literal.properties.map do |(key, value)|
|
|
547
|
-
render_object_property(key, value, translator, todos: todos)
|
|
1435
|
+
render_object_property(key, value, translator, todos: todos, indent: child_indent, force_inline: force_inline)
|
|
548
1436
|
end
|
|
549
|
-
"{
|
|
1437
|
+
wrap_literal_parts(parts, open: "{", close: "}", indent: indent, inline_sep: ", ", inline_pad: " ",
|
|
1438
|
+
force_inline: force_inline)
|
|
550
1439
|
end
|
|
551
1440
|
|
|
552
|
-
def render_object_property(key, value, translator, todos:)
|
|
553
|
-
value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key
|
|
1441
|
+
def render_object_property(key, value, translator, todos:, indent: 0, force_inline: false)
|
|
1442
|
+
value_ruby = render_inline_value(value, translator, todos: todos, attr_name: key, indent: indent,
|
|
1443
|
+
force_inline: force_inline)
|
|
554
1444
|
snake = AST::Inflector.underscore(key)
|
|
555
1445
|
if snake.match?(VALID_IDENTIFIER)
|
|
556
1446
|
"#{snake}: #{value_ruby}"
|
|
557
1447
|
else
|
|
558
|
-
"#{key
|
|
1448
|
+
"#{AST::Inflector.ruby_string_literal(key)} => #{value_ruby}"
|
|
559
1449
|
end
|
|
560
1450
|
end
|
|
561
1451
|
|
|
562
|
-
def render_array_literal_value(array_literal, translator, todos:)
|
|
1452
|
+
def render_array_literal_value(array_literal, translator, todos:, indent: 0, force_inline: false)
|
|
1453
|
+
child_indent = indent + 2
|
|
563
1454
|
parts = array_literal.elements.map do |el|
|
|
564
|
-
el.nil?
|
|
1455
|
+
if el.nil?
|
|
1456
|
+
"nil"
|
|
1457
|
+
else
|
|
1458
|
+
render_inline_value(el, translator, todos: todos, attr_name: nil, indent: child_indent,
|
|
1459
|
+
force_inline: force_inline)
|
|
1460
|
+
end
|
|
565
1461
|
end
|
|
566
|
-
"[
|
|
1462
|
+
wrap_literal_parts(parts, open: "[", close: "]", indent: indent, inline_sep: ", ", inline_pad: "",
|
|
1463
|
+
force_inline: force_inline)
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
# Pick single-line vs multi-line layout for a rendered literal.
|
|
1467
|
+
# Multi-line is forced when any rendered part already contains a
|
|
1468
|
+
# newline (a nested literal that wrapped); otherwise we wrap only
|
|
1469
|
+
# when the single-line form exceeds LITERAL_INLINE_BUDGET.
|
|
1470
|
+
def wrap_literal_parts(parts, **opts)
|
|
1471
|
+
open = opts[:open]
|
|
1472
|
+
close = opts[:close]
|
|
1473
|
+
return "#{open}#{close}" if parts.empty?
|
|
1474
|
+
|
|
1475
|
+
inline = "#{open}#{opts[:inline_pad]}#{parts.join(opts[:inline_sep])}#{opts[:inline_pad]}#{close}"
|
|
1476
|
+
any_multiline = parts.any? { |p| p.include?("\n") }
|
|
1477
|
+
return inline if opts[:force_inline]
|
|
1478
|
+
return inline if !any_multiline && inline.length <= LITERAL_INLINE_BUDGET
|
|
1479
|
+
|
|
1480
|
+
child_pad = " " * (opts[:indent] + 2)
|
|
1481
|
+
close_pad = " " * opts[:indent]
|
|
1482
|
+
"#{open}\n#{child_pad}#{parts.join(",\n#{child_pad}")}\n#{close_pad}#{close}"
|
|
567
1483
|
end
|
|
568
1484
|
|
|
569
1485
|
# An inline value can appear as a kwarg value, an array element, or a
|
|
570
1486
|
# hash property value. Recursive shapes route back through the new IR
|
|
571
1487
|
# types; primitives fall through the same paths as attribute_value_to_ruby.
|
|
572
|
-
def render_inline_value(value, translator, todos:, attr_name:)
|
|
1488
|
+
def render_inline_value(value, translator, todos:, attr_name:, indent: 0, force_inline: false)
|
|
573
1489
|
case value
|
|
574
|
-
when IR::ObjectLiteral
|
|
575
|
-
|
|
1490
|
+
when IR::ObjectLiteral
|
|
1491
|
+
render_object_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
|
|
1492
|
+
when IR::ArrayLiteral
|
|
1493
|
+
render_array_literal_value(value, translator, todos: todos, indent: indent, force_inline: force_inline)
|
|
576
1494
|
when IR::Lambda then render_lambda_method_reference(value, translator, attr_name: attr_name)
|
|
1495
|
+
when IR::EventHandler then render_event_handler_method_reference(value, attr_name: attr_name)
|
|
577
1496
|
when IR::Interpolation then interpolated_attribute_value(attr_name || "<element>", value, translator,
|
|
578
1497
|
todos: todos)
|
|
579
|
-
when
|
|
1498
|
+
when IR::ComponentInvocation
|
|
1499
|
+
component_invocation_value(value, translator, todos: todos, attr_name: attr_name)
|
|
1500
|
+
when IR::Element, IR::Fragment
|
|
1501
|
+
drop_jsx_value_with_todo(attr_name, value, todos: todos)
|
|
1502
|
+
when String then AST::Inflector.ruby_string_literal(value)
|
|
580
1503
|
when true then "true"
|
|
581
1504
|
else
|
|
582
1505
|
"nil"
|
|
@@ -612,43 +1535,145 @@ module JsxRosetta
|
|
|
612
1535
|
@lambda_method_counts[base] == 1 ? base : "#{base}#{@lambda_method_counts[base]}"
|
|
613
1536
|
end
|
|
614
1537
|
|
|
615
|
-
#
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
#
|
|
621
|
-
|
|
1538
|
+
# Inline arrow event handler on a PascalCase tag — `onClick={() =>
|
|
1539
|
+
# doX()}` on `<Button>`. Extract to a stub method on the class and
|
|
1540
|
+
# reference via `method(:handle_click)` at the kwarg position so the
|
|
1541
|
+
# receiving component has a callable. The body translation is left
|
|
1542
|
+
# to the human reviewer (the JS source is preserved as a TODO
|
|
1543
|
+
# comment inside the method), but the structural wiring is intact.
|
|
1544
|
+
def render_event_handler_method_reference(handler, attr_name:)
|
|
1545
|
+
@event_handler_methods ||= []
|
|
1546
|
+
base = event_handler_method_base(attr_name)
|
|
1547
|
+
method_name = unique_lambda_method_name(base)
|
|
1548
|
+
@event_handler_methods << { handler: handler, method_name: method_name, attr_name: attr_name }
|
|
1549
|
+
"method(:#{method_name})"
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
# Map a JSX attribute name to an idiomatic Ruby handler-method name.
|
|
1553
|
+
# `onClick` → `handle_click` (mirrors React's `handleClick` convention,
|
|
1554
|
+
# snake_cased). Non-event attrs (rare — a callback prop with no `on`
|
|
1555
|
+
# prefix) fall back to `<attr>_handler`.
|
|
1556
|
+
def event_handler_method_base(attr_name)
|
|
1557
|
+
return "anonymous_handler" if attr_name.nil? || attr_name.empty?
|
|
1558
|
+
|
|
1559
|
+
snake = AST::Inflector.underscore(attr_name)
|
|
1560
|
+
snake.start_with?("on_") ? "handle_#{snake.delete_prefix("on_")}" : "#{snake}_handler"
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# Attribute-position interpolation. Three failure modes:
|
|
1564
|
+
# 1. Translator returns non-nil, no unresolved identifiers — emit
|
|
1565
|
+
# the Ruby reference directly. Common case.
|
|
1566
|
+
# 2. Translator returns non-nil but with unresolved identifiers
|
|
1567
|
+
# starting with uppercase (PascalCase / SCREAMING_SNAKE_CASE —
|
|
1568
|
+
# almost always imported constants or enums) — emitting bare
|
|
1569
|
+
# `default_page_size` from `DEFAULT_PAGE_SIZE` produces a
|
|
1570
|
+
# runtime NameError with no marker. Drop the value to `nil`
|
|
1571
|
+
# and surface a TODO with the verbatim source. Lowercase
|
|
1572
|
+
# unresolved identifiers may be Rails helpers (`current_user`)
|
|
1573
|
+
# and are passed through as before.
|
|
1574
|
+
# 3. Translator returns nil — the original JS expression couldn't
|
|
622
1575
|
# 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.
|
|
1576
|
+
# template literals with method calls). Same TODO + nil path.
|
|
626
1577
|
def interpolated_attribute_value(name, value, translator, todos:)
|
|
627
1578
|
translated = translator.translate(value.expression)
|
|
628
|
-
return translated.ruby if translated
|
|
1579
|
+
return translated.ruby if translated && !uppercase_unresolved?(translated.unresolved_identifiers)
|
|
629
1580
|
|
|
630
1581
|
compact = value.expression.tr("\n", " ").squeeze(" ")
|
|
631
1582
|
todos << "attribute #{name.inspect} dropped — couldn't translate: #{compact}"
|
|
632
1583
|
"nil"
|
|
633
1584
|
end
|
|
634
1585
|
|
|
635
|
-
def
|
|
1586
|
+
def uppercase_unresolved?(unresolved_identifiers)
|
|
1587
|
+
unresolved_identifiers.any? { |name| name[0] == name[0].upcase }
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
# JSX appearing as an attribute value — typically `icon={<Foo/>}` or
|
|
1591
|
+
# `fallback={<Loading/>}` on antd/MUI components. Emitted as a
|
|
1592
|
+
# component-instance value: `icon: FooComponent.new`. The receiving
|
|
1593
|
+
# Phlex component can render it directly via `render @icon`. Closes
|
|
1594
|
+
# the largest single category of attribute-value drops we were
|
|
1595
|
+
# silently emitting as `attr: nil` + TODO.
|
|
1596
|
+
#
|
|
1597
|
+
# Three child-handling tiers:
|
|
1598
|
+
# 1. No children — `ClassRef.new(kwargs)` on one line.
|
|
1599
|
+
# 2. Children whose rendered Phlex body fits a single line — emit
|
|
1600
|
+
# as a block: `ClassRef.new(kwargs) { plain "x" }`. The block
|
|
1601
|
+
# runs in the child component's render context, so HTML helpers
|
|
1602
|
+
# resolve correctly.
|
|
1603
|
+
# 3. Children that need multiple lines, or any IR::Element /
|
|
1604
|
+
# IR::Fragment we can't represent inline — fall back to the
|
|
1605
|
+
# existing TODO drop. Out of MVP scope; expand later if the
|
|
1606
|
+
# stress numbers warrant it.
|
|
1607
|
+
def component_invocation_value(invocation, translator, todos:, attr_name:)
|
|
1608
|
+
return drop_attribute_with_todo(attr_name, invocation, todos: todos) if invocation_has_render_prop?(invocation)
|
|
1609
|
+
|
|
1610
|
+
kwargs = component_invocation_kwargs(invocation.props, translator, todos: todos, indent: 0)
|
|
1611
|
+
class_ref = component_class_reference(invocation.name)
|
|
1612
|
+
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
|
|
1613
|
+
|
|
1614
|
+
return new_call if invocation.children.empty?
|
|
1615
|
+
|
|
1616
|
+
block_body = render_inline_children(invocation.children, translator)
|
|
1617
|
+
return drop_attribute_with_todo(attr_name, invocation, todos: todos) unless block_body
|
|
1618
|
+
|
|
1619
|
+
"#{new_call} { #{block_body} }"
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
def invocation_has_render_prop?(invocation)
|
|
1623
|
+
invocation.children.any?(IR::RenderProp)
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
# Render children to a single-line Phlex block body. Returns nil when
|
|
1627
|
+
# any child needs multiple lines or isn't representable inline — the
|
|
1628
|
+
# caller falls back to the TODO drop.
|
|
1629
|
+
def render_inline_children(children, translator)
|
|
1630
|
+
rendered = children.map { |c| render_ir_node(c, translator, indent: 0) }
|
|
1631
|
+
return nil if rendered.any? { |line| line.include?("\n") }
|
|
1632
|
+
|
|
1633
|
+
joined = rendered.join("; ")
|
|
1634
|
+
joined.length <= LITERAL_INLINE_BUDGET ? joined : nil
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
def drop_attribute_with_todo(attr_name, invocation, todos:)
|
|
1638
|
+
label = attr_name || "<element>"
|
|
1639
|
+
compact = invocation_source_summary(invocation)
|
|
1640
|
+
todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{compact}"
|
|
1641
|
+
"nil"
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
def invocation_source_summary(invocation)
|
|
1645
|
+
tag = invocation.name
|
|
1646
|
+
suffix = invocation.children.empty? ? "/" : "...>"
|
|
1647
|
+
"<#{tag}#{suffix}"
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
def drop_jsx_value_with_todo(attr_name, value, todos:)
|
|
1651
|
+
label = attr_name || "<element>"
|
|
1652
|
+
summary = case value
|
|
1653
|
+
when IR::Element then "<#{value.tag}...>"
|
|
1654
|
+
when IR::Fragment then "<>...</>"
|
|
1655
|
+
else "<JSX>"
|
|
1656
|
+
end
|
|
1657
|
+
todos << "attribute #{label.inspect} dropped — couldn't inline JSX value: #{summary}"
|
|
1658
|
+
"nil"
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
def component_invocation_kwargs(props, translator, todos: [], indent: 0, tag: nil)
|
|
636
1662
|
events, others = props.partition { |a| a.is_a?(IR::EventBinding) || a.is_a?(IR::StimulusBinding) }
|
|
637
1663
|
spreads, plain_attrs = others.partition { |a| a.is_a?(IR::SpreadAttribute) }
|
|
638
1664
|
|
|
639
|
-
|
|
640
|
-
str_parts = []
|
|
1665
|
+
parts = { sym: [], str: [] }
|
|
641
1666
|
plain_attrs.each do |a|
|
|
642
|
-
append_attribute_part(a, translator,
|
|
1667
|
+
append_attribute_part(a, translator, parts, context: :component, tag: tag, todos: todos, indent: indent)
|
|
643
1668
|
end
|
|
644
|
-
|
|
1669
|
+
parts[:sym] << data_action_entry(events, translator) if events.any?
|
|
645
1670
|
|
|
646
|
-
build_attribute_list(
|
|
1671
|
+
build_attribute_list(parts, spreads, translator)
|
|
647
1672
|
end
|
|
648
1673
|
|
|
649
1674
|
def class_list_to_ruby_string(class_list, translator)
|
|
650
1675
|
parts = class_list.segments.map { |seg| class_segment_to_ruby(seg, translator) }
|
|
651
|
-
|
|
1676
|
+
wrap_concatenated_string(parts.join(" "))
|
|
652
1677
|
end
|
|
653
1678
|
|
|
654
1679
|
def class_segment_to_ruby(segment, translator)
|
|
@@ -660,23 +1685,46 @@ module JsxRosetta
|
|
|
660
1685
|
when IR::ConditionalSegment
|
|
661
1686
|
cond_translated = translator.translate(segment.condition.expression)
|
|
662
1687
|
cond_ruby = cond_translated&.ruby || segment.condition.expression
|
|
663
|
-
%(\#{#{cond_ruby} ? #{segment.class_name
|
|
1688
|
+
%(\#{#{cond_ruby} ? #{AST::Inflector.ruby_string_literal(segment.class_name)} : ''})
|
|
664
1689
|
end
|
|
665
1690
|
end
|
|
666
1691
|
|
|
667
|
-
def style_to_ruby_string(style, translator)
|
|
668
|
-
parts = style.declarations.
|
|
669
|
-
|
|
1692
|
+
def style_to_ruby_string(style, translator, todos: [])
|
|
1693
|
+
parts = style.declarations.filter_map { |decl| style_declaration_to_ruby(decl, translator, todos: todos) }
|
|
1694
|
+
wrap_concatenated_string(parts.join(" "))
|
|
670
1695
|
end
|
|
671
1696
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
"#{
|
|
1697
|
+
# Wrap a built-up Ruby string body in single quotes when it contains
|
|
1698
|
+
# no interpolation (`\#{...}`) and no escaping pitfalls; otherwise
|
|
1699
|
+
# use double quotes so the interpolation is honored. Keeps class /
|
|
1700
|
+
# style attribute output passing Style/StringLiterals when no
|
|
1701
|
+
# dynamic segments are present (the common case for hardcoded
|
|
1702
|
+
# `style="margin-bottom: 16px"`).
|
|
1703
|
+
def wrap_concatenated_string(body)
|
|
1704
|
+
return %("#{body}") if body.include?("\#{") || body.include?("'") || body.include?("\\")
|
|
1705
|
+
|
|
1706
|
+
"'#{body}'"
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
def style_declaration_to_ruby(decl, translator, todos: [])
|
|
1710
|
+
case decl.value
|
|
1711
|
+
when String
|
|
1712
|
+
"#{decl.property}: #{decl.value};"
|
|
1713
|
+
when IR::Interpolation
|
|
1714
|
+
translated = translator.translate(decl.value.expression)
|
|
1715
|
+
# Translation failed (e.g., interpolation rooted at an
|
|
1716
|
+
# unresolvable local). Emitting the verbatim JS source inside
|
|
1717
|
+
# `\#{}` would render valid Ruby that NameErrors at runtime.
|
|
1718
|
+
# Drop the declaration and surface a TODO above the element so
|
|
1719
|
+
# the reviewer sees what was lost.
|
|
1720
|
+
if translated.nil?
|
|
1721
|
+
todos << "style declaration #{decl.property.inspect} dropped — " \
|
|
1722
|
+
"couldn't translate: #{decl.value.expression}"
|
|
1723
|
+
nil
|
|
1724
|
+
else
|
|
1725
|
+
"#{decl.property}: \#{#{translated.ruby}};"
|
|
1726
|
+
end
|
|
1727
|
+
end
|
|
680
1728
|
end
|
|
681
1729
|
|
|
682
1730
|
# Wrap the spread expression in `(… || {})` so a nil-valued prop
|
|
@@ -712,7 +1760,7 @@ module JsxRosetta
|
|
|
712
1760
|
end
|
|
713
1761
|
|
|
714
1762
|
def render_single_event_descriptor(descriptor)
|
|
715
|
-
descriptor.kind == :literal ?
|
|
1763
|
+
descriptor.kind == :literal ? AST::Inflector.ruby_string_literal(descriptor.body) : descriptor.body
|
|
716
1764
|
end
|
|
717
1765
|
|
|
718
1766
|
def descriptor_in_string(descriptor)
|
|
@@ -733,15 +1781,77 @@ module JsxRosetta
|
|
|
733
1781
|
"#{lines.join("\n")}\n"
|
|
734
1782
|
end
|
|
735
1783
|
|
|
1784
|
+
# Stimulus method emission. JSX inline arrow bodies are valid JS already;
|
|
1785
|
+
# for DOM-driven handlers (the common shadcn shape) we just paste the body
|
|
1786
|
+
# verbatim into the method, naming the JS parameter to match the original
|
|
1787
|
+
# arrow's parameter so identifier references in the body still resolve.
|
|
1788
|
+
#
|
|
1789
|
+
# When the body references React-state setters or hooks we can't run in
|
|
1790
|
+
# the browser, fall back to the previous TODO-comment behavior so the
|
|
1791
|
+
# human reviewer ports it by hand.
|
|
736
1792
|
def stimulus_method_lines(method)
|
|
737
|
-
|
|
738
|
-
commented = body_lines.map { |line| " // #{line}" }
|
|
739
|
-
header = [" // TODO: translate from the original JSX handler:"]
|
|
1793
|
+
lines = []
|
|
740
1794
|
if method.name != method.original_name
|
|
741
|
-
|
|
742
|
-
|
|
1795
|
+
lines << " // NOTE: method renamed from #{method.original_name.inspect} " \
|
|
1796
|
+
"to avoid collision with an earlier handler"
|
|
743
1797
|
end
|
|
744
|
-
|
|
1798
|
+
|
|
1799
|
+
if safe_to_paste_handler?(method)
|
|
1800
|
+
lines.concat(pasted_handler_lines(method))
|
|
1801
|
+
else
|
|
1802
|
+
lines.concat(todo_handler_lines(method))
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
lines
|
|
1806
|
+
end
|
|
1807
|
+
|
|
1808
|
+
# Heuristic for "this JS body is safe to drop into a Stimulus method
|
|
1809
|
+
# verbatim." Bails out when:
|
|
1810
|
+
# - any arrow param wasn't a plain Identifier (destructured / rest) —
|
|
1811
|
+
# pasting would reference an undefined local at runtime;
|
|
1812
|
+
# - the body is the identifier-bound pseudo-comment we synthesize
|
|
1813
|
+
# when an `onClick={onChange}` reference resolved to no arrow;
|
|
1814
|
+
# - the body calls a top-level React state setter (`setX(`) or hook
|
|
1815
|
+
# (`useX(`) — the negative lookbehind on `[.\w]` makes sure DOM
|
|
1816
|
+
# methods like `e.setAttribute(` / `el.setPointerCapture(` don't
|
|
1817
|
+
# trip the guard.
|
|
1818
|
+
def safe_to_paste_handler?(method)
|
|
1819
|
+
return false unless method.params.all?
|
|
1820
|
+
|
|
1821
|
+
body = method.body_source
|
|
1822
|
+
return false if body.lstrip.start_with?("//")
|
|
1823
|
+
return false if body =~ /(?<![.\w])set[A-Z]\w*\(/
|
|
1824
|
+
return false if body =~ /(?<![.\w])use[A-Z]\w*\(/
|
|
1825
|
+
|
|
1826
|
+
true
|
|
1827
|
+
end
|
|
1828
|
+
|
|
1829
|
+
# Paste the JS body into the method, using the original arrow's first
|
|
1830
|
+
# parameter name (so the body's references still resolve). Strip the
|
|
1831
|
+
# outer `{ … }` wrapper only when the body was an arrow BlockStatement
|
|
1832
|
+
# (`(e) => { … }`), not an expression-form body like `(e) => ({ x: 1 })`
|
|
1833
|
+
# which Babel hands back as `{ x: 1 }` already — stripping would yield
|
|
1834
|
+
# `x: 1`, a JS label statement (no-op).
|
|
1835
|
+
def pasted_handler_lines(method)
|
|
1836
|
+
param = method.params.first || "event"
|
|
1837
|
+
body = method.body_source.strip
|
|
1838
|
+
body = body[1..-2].strip if method.body_is_block
|
|
1839
|
+
inner_lines = body.split("\n").map { |l| " #{l.lstrip}" }
|
|
1840
|
+
[
|
|
1841
|
+
" #{method.name}(#{param}) {",
|
|
1842
|
+
*inner_lines,
|
|
1843
|
+
" }"
|
|
1844
|
+
]
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
# Fallback for handlers that aren't safe to paste verbatim — preserve
|
|
1848
|
+
# the original body as a comment and emit an empty method body.
|
|
1849
|
+
def todo_handler_lines(method)
|
|
1850
|
+
body_lines = method.body_source.strip.split("\n")
|
|
1851
|
+
commented = body_lines.map { |line| " // #{line}" }
|
|
1852
|
+
[
|
|
1853
|
+
" // TODO: translate from the original JSX handler:",
|
|
1854
|
+
*commented,
|
|
745
1855
|
" #{method.name}(event) {",
|
|
746
1856
|
" // ...",
|
|
747
1857
|
" }"
|
|
@@ -751,6 +1861,159 @@ module JsxRosetta
|
|
|
751
1861
|
def spaces(count)
|
|
752
1862
|
" " * count
|
|
753
1863
|
end
|
|
1864
|
+
|
|
1865
|
+
# ----------------------------------------------------------------------
|
|
1866
|
+
# Lucide icon sidecars
|
|
1867
|
+
# ----------------------------------------------------------------------
|
|
1868
|
+
#
|
|
1869
|
+
# When a translated component references `<ChevronRight />` after
|
|
1870
|
+
# `import { ChevronRight } from "lucide-react"`, the consumer ends up
|
|
1871
|
+
# with a `render ChevronRight.new(...)` call against a Ruby class that
|
|
1872
|
+
# doesn't exist. To close that NameError, we emit one Phlex class per
|
|
1873
|
+
# referenced icon as a sidecar file (`chevron_right.rb` etc.) plus a
|
|
1874
|
+
# shared `lucide_icon.rb` base. Each file follows Zeitwerk's
|
|
1875
|
+
# one-constant-per-file convention so it drops straight into
|
|
1876
|
+
# `app/components/` without further configuration.
|
|
1877
|
+
|
|
1878
|
+
def lucide_icon_files(component)
|
|
1879
|
+
usages = referenced_lucide_icons(component)
|
|
1880
|
+
return [] if usages.empty?
|
|
1881
|
+
|
|
1882
|
+
@seen_lucide_icons ||= Set.new
|
|
1883
|
+
files = []
|
|
1884
|
+
unless @seen_lucide_icons.include?(:base)
|
|
1885
|
+
files << File.new(path: "lucide_icon.rb", contents: render_lucide_icon_base_rb)
|
|
1886
|
+
@seen_lucide_icons << :base
|
|
1887
|
+
end
|
|
1888
|
+
|
|
1889
|
+
usages.sort_by { |u| u[:local_name] }.each do |usage|
|
|
1890
|
+
next if @seen_lucide_icons.include?(usage[:local_name])
|
|
1891
|
+
|
|
1892
|
+
path = "#{AST::Inflector.underscore(usage[:local_name])}.rb"
|
|
1893
|
+
files << File.new(
|
|
1894
|
+
path: path,
|
|
1895
|
+
contents: render_lucide_icon_class_rb(usage[:local_name], canonical: usage[:canonical_name])
|
|
1896
|
+
)
|
|
1897
|
+
@seen_lucide_icons << usage[:local_name]
|
|
1898
|
+
end
|
|
1899
|
+
files
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
# Lucide imports used as JSX tags, each carrying both the local
|
|
1903
|
+
# binding (what the emitted file/class is named after) and the
|
|
1904
|
+
# canonical export (used to look up the vendored SVG path data).
|
|
1905
|
+
# The two diverge under aliased imports — `import { ChevronRight as
|
|
1906
|
+
# CR }` should still resolve `ChevronRight`'s SVG while emitting a
|
|
1907
|
+
# `cr.rb` defining `class CR < LucideIcon`. Names not in the
|
|
1908
|
+
# vendored data still emit, but the class falls back to a TODO
|
|
1909
|
+
# `inner_svg` instead of NameError-ing at render.
|
|
1910
|
+
def referenced_lucide_icons(component)
|
|
1911
|
+
lucide_by_local = component.module_imports
|
|
1912
|
+
.select { |i| Icons.lucide_source?(i.source) }
|
|
1913
|
+
.to_h { |i| [i.name, i.imported_name || i.name] }
|
|
1914
|
+
return [] if lucide_by_local.empty?
|
|
1915
|
+
|
|
1916
|
+
invocations = Set.new
|
|
1917
|
+
collect_component_invocations(component.body, invocations)
|
|
1918
|
+
invocations.intersection(lucide_by_local.keys).map do |local_name|
|
|
1919
|
+
{ local_name: local_name, canonical_name: lucide_by_local.fetch(local_name) }
|
|
1920
|
+
end
|
|
1921
|
+
end
|
|
1922
|
+
|
|
1923
|
+
# Walk an IR subtree and collect every ComponentInvocation tag name we
|
|
1924
|
+
# see. Recurses through any field that holds an IR node or array of
|
|
1925
|
+
# nodes. Conservative — visits every container; cost is proportional
|
|
1926
|
+
# to IR size.
|
|
1927
|
+
def collect_component_invocations(node, acc)
|
|
1928
|
+
return if node.nil?
|
|
1929
|
+
|
|
1930
|
+
acc << node.name if node.is_a?(IR::ComponentInvocation)
|
|
1931
|
+
return unless node.respond_to?(:members)
|
|
1932
|
+
|
|
1933
|
+
node.members.each do |field|
|
|
1934
|
+
value = node.public_send(field)
|
|
1935
|
+
if value.is_a?(Array)
|
|
1936
|
+
value.each { |child| collect_component_invocations(child, acc) }
|
|
1937
|
+
elsif value.respond_to?(:members)
|
|
1938
|
+
collect_component_invocations(value, acc)
|
|
1939
|
+
end
|
|
1940
|
+
end
|
|
1941
|
+
end
|
|
1942
|
+
|
|
1943
|
+
def render_lucide_icon_base_rb
|
|
1944
|
+
mod_open, mod_close, indent = lucide_module_wrap
|
|
1945
|
+
<<~RUBY
|
|
1946
|
+
# frozen_string_literal: true
|
|
1947
|
+
|
|
1948
|
+
# Generated by jsx_rosetta. Don't edit by hand — re-translate the source
|
|
1949
|
+
# to refresh. Shared SVG-wrapper base for Lucide icon shims; one subclass
|
|
1950
|
+
# per icon (e.g. ChevronRight, Search) sits alongside this file.
|
|
1951
|
+
#{mod_open}#{indent}class LucideIcon < Phlex::HTML
|
|
1952
|
+
#{indent} BASE_ATTRS = %{xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"}.freeze
|
|
1953
|
+
|
|
1954
|
+
#{indent} def initialize(class_name: nil, **)
|
|
1955
|
+
#{indent} super()
|
|
1956
|
+
#{indent} @class_name = class_name
|
|
1957
|
+
#{indent} end
|
|
1958
|
+
|
|
1959
|
+
#{indent} def view_template
|
|
1960
|
+
#{indent} cls = @class_name.to_s.empty? ? "" : %{ class="\#{@class_name}"}
|
|
1961
|
+
#{indent} raw safe("<svg \#{BASE_ATTRS}\#{cls}>\#{inner_svg}</svg>")
|
|
1962
|
+
#{indent} end
|
|
1963
|
+
|
|
1964
|
+
#{indent} def inner_svg
|
|
1965
|
+
#{indent} raise NotImplementedError
|
|
1966
|
+
#{indent} end
|
|
1967
|
+
#{indent}end
|
|
1968
|
+
#{mod_close}
|
|
1969
|
+
RUBY
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
# `name` is the local binding (the Ruby class name we emit), `canonical`
|
|
1973
|
+
# is the original Lucide export — they diverge under aliased imports.
|
|
1974
|
+
# SVG lookup keys on `canonical`; the class definition keys on `name`.
|
|
1975
|
+
def render_lucide_icon_class_rb(name, canonical: name)
|
|
1976
|
+
inner = Icons.lucide_for(canonical)
|
|
1977
|
+
mod_open, mod_close, indent = lucide_module_wrap
|
|
1978
|
+
body = if inner
|
|
1979
|
+
"#{indent} def inner_svg = #{format_svg_string(inner)}"
|
|
1980
|
+
else
|
|
1981
|
+
"#{indent} # TODO: #{canonical.inspect} isn't in jsx_rosetta's vendored lucide.json.\n" \
|
|
1982
|
+
"#{indent} # Fill in inner_svg with the SVG path data from lucide.dev, or refresh\n" \
|
|
1983
|
+
"#{indent} # `lib/jsx_rosetta/icons/lucide.json`.\n" \
|
|
1984
|
+
"#{indent} def inner_svg = \"\""
|
|
1985
|
+
end
|
|
1986
|
+
<<~RUBY
|
|
1987
|
+
# frozen_string_literal: true
|
|
1988
|
+
|
|
1989
|
+
# Generated by jsx_rosetta from a "lucide-react" import. Refresh with
|
|
1990
|
+
# a re-translate; don't edit by hand.
|
|
1991
|
+
#{mod_open}#{indent}class #{name} < LucideIcon
|
|
1992
|
+
#{body}
|
|
1993
|
+
#{indent}end
|
|
1994
|
+
#{mod_close}
|
|
1995
|
+
RUBY
|
|
1996
|
+
end
|
|
1997
|
+
|
|
1998
|
+
# Match the namespace wrapping we use for the main component class.
|
|
1999
|
+
# Returns ["module Foo\n", "end\n", " "] when a namespace is set,
|
|
2000
|
+
# or ["", "", ""] for the top-level case.
|
|
2001
|
+
def lucide_module_wrap
|
|
2002
|
+
return ["", "", ""] unless @namespace
|
|
2003
|
+
|
|
2004
|
+
["module #{@namespace}\n", "end\n", " "]
|
|
2005
|
+
end
|
|
2006
|
+
|
|
2007
|
+
# Wrap an SVG inner-markup snippet in a Ruby string literal that
|
|
2008
|
+
# preserves its embedded double quotes. Single-quoted when the markup
|
|
2009
|
+
# contains no single quotes (most cases), otherwise %q delimited.
|
|
2010
|
+
def format_svg_string(svg)
|
|
2011
|
+
if svg.include?("'")
|
|
2012
|
+
%(%q{#{svg}})
|
|
2013
|
+
else
|
|
2014
|
+
"'#{svg}'"
|
|
2015
|
+
end
|
|
2016
|
+
end
|
|
754
2017
|
end
|
|
755
2018
|
end
|
|
756
2019
|
end
|