jsx_rosetta 0.5.1 → 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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -11
  3. data/CLAUDE.md +70 -0
  4. data/README.md +50 -0
  5. data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
  6. data/lib/jsx_rosetta/ast/inflector.rb +17 -0
  7. data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
  8. data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
  9. data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
  10. data/lib/jsx_rosetta/backend/view_component.rb +48 -2
  11. data/lib/jsx_rosetta/cli.rb +175 -37
  12. data/lib/jsx_rosetta/icons/lucide.json +37 -0
  13. data/lib/jsx_rosetta/icons.rb +44 -0
  14. data/lib/jsx_rosetta/ir/lowering.rb +720 -31
  15. data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
  16. data/lib/jsx_rosetta/ir/types.rb +187 -3
  17. data/lib/jsx_rosetta/ir.rb +5 -4
  18. data/lib/jsx_rosetta/pages_routing.rb +640 -0
  19. data/lib/jsx_rosetta/version.rb +1 -1
  20. data/lib/jsx_rosetta.rb +8 -6
  21. data/plans/nextjs_pages_to_rails.md +200 -0
  22. data/plans/nextjs_pages_to_rails_slice_2.md +118 -0
  23. data/plans/nextjs_pages_to_rails_slice_3.md +121 -0
  24. data/plans/nextjs_pages_to_rails_slice_4.md +301 -0
  25. data/plans/translator_widening_and_pages_followups.md +120 -0
  26. data/plans/translator_widening_slice_a.md +208 -0
  27. data/skills/jsx-rosetta-resolve-todos/SKILL.md +206 -0
  28. data/skills/jsx-rosetta-resolve-todos/data/design_tokens.template.yml +71 -0
  29. data/skills/jsx-rosetta-resolve-todos/data/target_app_conventions.template.yml +107 -0
  30. data/skills/jsx-rosetta-resolve-todos/examples/design_tokens.ant_design_v5.yml +190 -0
  31. data/skills/jsx-rosetta-resolve-todos/recipes/01_design_tokens.md +74 -0
  32. data/skills/jsx-rosetta-resolve-todos/recipes/02_promoted_ivar.md +49 -0
  33. data/skills/jsx-rosetta-resolve-todos/recipes/03_react_hooks.md +34 -0
  34. data/skills/jsx-rosetta-resolve-todos/recipes/04_apollo_hooks.md +34 -0
  35. data/skills/jsx-rosetta-resolve-todos/recipes/05_event_handlers.md +45 -0
  36. data/skills/jsx-rosetta-resolve-todos/recipes/06_module_constants.md +29 -0
  37. data/skills/jsx-rosetta-resolve-todos/recipes/07_nextjs_navigation.md +44 -0
  38. data/skills/jsx-rosetta-resolve-todos/recipes/08_generic_js_bailouts.md +55 -0
  39. data/skills/jsx-rosetta-resolve-todos/tools/apply_promoted_ivar.rb +189 -0
  40. data/skills/jsx-rosetta-resolve-todos/tools/apply_substitutions.rb +292 -0
  41. data/skills/jsx-rosetta-resolve-todos/tools/diff_corpus.rb +161 -0
  42. data/skills/jsx-rosetta-resolve-todos/tools/discover_bailouts.rb +211 -0
  43. metadata +29 -1
@@ -15,7 +15,7 @@ module JsxRosetta
15
15
  # source JSX includes inline event handlers, a Stimulus controller is
16
16
  # still emitted as a sibling file alongside the .html.erb.
17
17
  class RailsView < ViewComponent
18
- def emit(component)
18
+ def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
19
19
  prop_names = component.props.map(&:name)
20
20
  prop_names << component.rest_prop_name if component.rest_prop_name
21
21
  translator = ExpressionTranslator.new(
@@ -22,6 +22,11 @@ module JsxRosetta
22
22
  # lowering time but not modeled in IR) translate to a `nil`
23
23
  # placeholder with an inline `# TODO: local 'name'` marker — the
24
24
  # file still loads, but the reviewer sees what to fill in.
25
+ # * Names in `imported_names` (top-level `import` declarations) are
26
+ # treated the same as local bindings — `nil` at leaf position, bail
27
+ # at member-chain root. Without this, `styles.listContainer` from
28
+ # `import styles from "./X.module.css"` snake-cases to a bare
29
+ # `styles` reference that NameErrors at render time.
25
30
  # * Anything else translates to the bare snake_case identifier and
26
31
  # is recorded as unresolved.
27
32
  #
@@ -71,16 +76,22 @@ module JsxRosetta
71
76
  OPEN_BRACKETS = ["(", "[", "{"].freeze
72
77
  CLOSE_BRACKETS = [")", "]", "}"].freeze
73
78
 
74
- Result = Data.define(:ruby, :unresolved_identifiers)
79
+ # `promoted_locals` lists names that the condition-mode translator
80
+ # rendered as `@ivar` despite being known-but-unresolvable locals or
81
+ # imports (bucket 4 in the resolution rules). The caller is expected
82
+ # to surface this list in a TODO so a reviewer knows which bindings
83
+ # need to become controller-passed props.
84
+ Result = Data.define(:ruby, :unresolved_identifiers, :promoted_locals)
75
85
 
76
86
  # prop_aliases maps a local-binding name (the alias) to the
77
87
  # underlying prop name. `"data-testid": dataTestId` records
78
88
  # `{ "dataTestId" => "data-testid" }` so the use site of
79
89
  # `dataTestId` resolves to the prop's `@data_testid` ivar.
80
- def initialize(prop_names:, local_binding_names: [], prop_aliases: {})
90
+ def initialize(prop_names:, local_binding_names: [], prop_aliases: {}, imported_names: [])
81
91
  @prop_names = prop_names.to_set
82
92
  @local_binding_names = local_binding_names.to_set
83
93
  @prop_aliases = prop_aliases.dup
94
+ @imported_names = imported_names.to_set
84
95
  @local_stack = []
85
96
  end
86
97
 
@@ -92,15 +103,48 @@ module JsxRosetta
92
103
  end
93
104
 
94
105
  def translate(source)
106
+ do_translate(source, condition_mode: false)
107
+ end
108
+
109
+ # Render-condition entry point. Same recursive translator as
110
+ # `translate`, but bucket-4 hits (known-but-unresolvable locals /
111
+ # imports) emit `@snake_case` instead of returning `nil` (member-
112
+ # chain root, unary/binary operand) or `"nil"` (leaf identifier).
113
+ # The promoted names come back via `Result#promoted_locals` so the
114
+ # caller can surface a TODO naming the bindings that need to become
115
+ # controller-passed props.
116
+ #
117
+ # Only safe here because driving an `if` with a known-but-nil value
118
+ # silently disables the branch — destroying the source's intent.
119
+ # Promoting to an ivar trades silence for a clear render-time error
120
+ # (NameError on @ivar if the user never threads the prop) that the
121
+ # accompanying TODO points the reviewer at.
122
+ def translate_condition(source)
123
+ do_translate(source, condition_mode: true)
124
+ end
125
+
126
+ private
127
+
128
+ def do_translate(source, condition_mode:)
95
129
  source = source.strip
96
130
  unresolved = []
131
+ promoted = []
132
+ previous_mode = @condition_mode
133
+ previous_promoted = @promoted_locals
134
+ @condition_mode = condition_mode
135
+ @promoted_locals = promoted
97
136
 
98
137
  ruby = translate_ruby(source, unresolved)
99
- ruby && Result.new(ruby: ruby, unresolved_identifiers: unresolved.uniq)
138
+ ruby && Result.new(
139
+ ruby: ruby,
140
+ unresolved_identifiers: unresolved.uniq,
141
+ promoted_locals: promoted.uniq
142
+ )
143
+ ensure
144
+ @condition_mode = previous_mode
145
+ @promoted_locals = previous_promoted
100
146
  end
101
147
 
102
- private
103
-
104
148
  def translate_ruby(source, unresolved)
105
149
  source = unwrap_outer_parens(source.strip)
106
150
  translate_simple_form(source, unresolved) || translate_compound_form(source, unresolved)
@@ -335,14 +379,19 @@ module JsxRosetta
335
379
  "@#{AST::Inflector.underscore(@prop_aliases[name])}"
336
380
  elsif @prop_names.include?(name)
337
381
  "@#{snake}"
338
- elsif @local_binding_names.include?(name)
339
- # We know this binding exists locally (destructure, hook tuple)
340
- # but can't model its value. As a leaf identifier, return `nil`
341
- # so the file loads (a bare snake_case ref would NameError).
342
- # As a member-chain root, `nil.member` would NoMethodError and
343
- # the bare-snake fallback would NameError both crash at render
344
- # time. Bail so the whole expression fails translation and the
345
- # caller emits a TODO comment with the verbatim source.
382
+ elsif @local_binding_names.include?(name) || @imported_names.include?(name)
383
+ # We know this binding exists (destructure, hook tuple, top-level
384
+ # import) but can't model its value. In `translate_condition`
385
+ # mode the test is load-bearing emitting `nil` would silently
386
+ # false-arm the branch so promote the binding to an `@ivar`
387
+ # and record it for the caller's TODO. In default mode, return
388
+ # `nil` so the file loads (leaf) / bail so the caller emits a
389
+ # TODO (member-chain root).
390
+ if @condition_mode
391
+ @promoted_locals << name
392
+ return "@#{snake}"
393
+ end
394
+
346
395
  return nil if member_chain_root
347
396
 
348
397
  "nil"
@@ -352,16 +401,20 @@ module JsxRosetta
352
401
  end
353
402
  end
354
403
 
355
- # An identifier that we know to be a local binding (e.g. destructured
356
- # from an untranslatable init) but whose value we can't model. The
357
- # leaf-translates-to-nil path is safe in value positions (attribute
358
- # kwargs, leaf interpolations) but compound contexts (unary, binary,
359
- # member chain) must bail so callers emit a TODO instead of silently
360
- # changing semantics.
404
+ # An identifier we know to be defined locally (destructure, hook
405
+ # tuple) or pulled in via a top-level `import`, but whose value we
406
+ # can't model. The leaf-translates-to-nil path is safe in value
407
+ # positions (attribute kwargs, leaf interpolations) but compound
408
+ # contexts (unary, binary, member chain) must bail so callers emit
409
+ # a TODO instead of silently changing semantics.
361
410
  def unresolvable_local?(source)
362
411
  return false unless source.match?(IDENTIFIER)
412
+ # In render-condition mode we promote bucket-4 hits to @ivars
413
+ # (see translate_identifier) — so they aren't unresolvable here.
414
+ # Skip the bail so unary/binary translation succeeds.
415
+ return false if @condition_mode
363
416
 
364
- @local_binding_names.include?(source) &&
417
+ (@local_binding_names.include?(source) || @imported_names.include?(source)) &&
365
418
  !in_local_scope?(source) &&
366
419
  !@prop_names.include?(source)
367
420
  end
@@ -82,7 +82,7 @@ module JsxRosetta
82
82
  @layout = layout
83
83
  end
84
84
 
85
- def emit(component)
85
+ def emit(component, source_filename: nil) # rubocop:disable Lint/UnusedMethodArgument
86
86
  translator = build_translator(component)
87
87
  base_name = "#{AST::Inflector.underscore(component.name)}_component"
88
88
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
@@ -239,7 +239,8 @@ module JsxRosetta
239
239
  ExpressionTranslator.new(
240
240
  prop_names: prop_names,
241
241
  local_binding_names: component.local_binding_names,
242
- prop_aliases: prop_aliases
242
+ prop_aliases: prop_aliases,
243
+ imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name)
243
244
  )
244
245
  end
245
246
 
@@ -911,6 +912,51 @@ module JsxRosetta
911
912
  when IR::Interpolation
912
913
  translated = translator.translate(value.expression)
913
914
  translated ? translated.ruby : "nil # TODO: translate #{value.expression.inspect}"
915
+ when IR::ComponentInvocation
916
+ component_invocation_inline_value(value, translator)
917
+ when IR::Element, IR::Fragment
918
+ "nil # TODO: couldn't inline JSX value: #{jsx_value_summary(value)}"
919
+ end
920
+ end
921
+
922
+ # JSX appearing as an attribute value — `icon={<Foo/>}` etc. — lowered
923
+ # in v0.5.x. Emit a component-instance value (`icon: FooComponent.new`)
924
+ # so the receiving ViewComponent can `render @icon`. With children we
925
+ # use Ruby block syntax (`FooComponent.new { ... }`) when the body
926
+ # fits one line; otherwise we drop with a TODO so the kwarg stays
927
+ # valid Ruby. Cases requiring a Phlex execution context (IR::Element,
928
+ # multi-element IR::Fragment) drop with a TODO too — out of MVP scope.
929
+ def component_invocation_inline_value(invocation, translator)
930
+ return inline_render_prop_todo if invocation.children.any?(IR::RenderProp)
931
+
932
+ kwargs = component_invocation_kwargs(invocation.props, translator)
933
+ class_name = component_class_name(invocation.name)
934
+ new_call = kwargs.empty? ? "#{class_name}.new" : "#{class_name}.new(#{kwargs})"
935
+ return new_call if invocation.children.empty?
936
+
937
+ block_body = inline_children_body(invocation.children, translator)
938
+ return "nil # TODO: couldn't inline JSX value: <#{invocation.name}...>" unless block_body
939
+
940
+ "#{new_call} { #{block_body} }"
941
+ end
942
+
943
+ def inline_render_prop_todo
944
+ "nil # TODO: couldn't inline render-prop value"
945
+ end
946
+
947
+ def inline_children_body(children, translator)
948
+ rendered = children.map { |c| render_ir_node(c, translator, indent: 0).strip }
949
+ return nil if rendered.any? { |line| line.include?("\n") }
950
+
951
+ joined = rendered.join("; ")
952
+ joined.empty? || joined.length > 100 ? nil : joined
953
+ end
954
+
955
+ def jsx_value_summary(value)
956
+ case value
957
+ when IR::Element then "<#{value.tag}...>"
958
+ when IR::Fragment then "<>...</>"
959
+ else "<JSX>"
914
960
  end
915
961
  end
916
962
 
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "open3"
6
+ require "pathname"
6
7
 
7
8
  require_relative "node_bridge"
8
9
 
@@ -15,6 +16,8 @@ module JsxRosetta
15
16
  # (default: current directory). TSX is detected
16
17
  # via the .tsx extension or --tsx.
17
18
  # parse FILE Print the parsed Babel AST as pretty JSON.
19
+ # pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory and emit a
20
+ # Rails config/routes.rb skeleton.
18
21
  # version Print the gem version.
19
22
  # help Show usage.
20
23
  class CLI
@@ -22,6 +25,43 @@ module JsxRosetta
22
25
  EXIT_USAGE = 64
23
26
  EXIT_FAILURE = 1
24
27
 
28
+ USAGE_TEXT = <<~USAGE
29
+ Usage: jsx_rosetta <command> [args]
30
+
31
+ Commands:
32
+ install Install the gem's Node sidecar dependencies (runs `npm install`).
33
+ translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
34
+ Pass --tsx to force TypeScript parsing if the input is .jsx.
35
+ Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
36
+ instead of a ViewComponent class + sidecar template — appropriate
37
+ for pages tied to a route.
38
+ Pass --as=phlex to emit a single-file Phlex 2.x view class
39
+ (`<snake>.rb`) instead of a ViewComponent. Configure the class
40
+ name with --phlex-suffix=Component or --phlex-namespace=Components
41
+ (mutually exclusive; default is bare class name).
42
+ Pass --rails-routes DIR (with --as=phlex) to place the output
43
+ at <controller>/<action>.rb with class
44
+ Views::<Controller>::<Action> < Views::Base, derived from a
45
+ route table scanned out of DIR (a Next.js pages directory).
46
+ routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
47
+ and emit a reviewable Ruby script that calls `rails generate
48
+ controller` and prints suggested config/routes.rb additions.
49
+ pages-routes DIR [-o PATH] Walk a Next.js `pages/` directory tree and emit a
50
+ Rails config/routes.rb skeleton derived from the file
51
+ layout. Use --ext .tsx,.jsx,.ts,.js to override the
52
+ default `.tsx,.jsx` filter, and --allow-any-dir to
53
+ skip the `basename == 'pages'` safety check.
54
+ Pass --controllers DIR to also emit one
55
+ `<controller>_controller.rb` per controller in DIR
56
+ (existing files are not overwritten).
57
+ parse FILE Parse the input and print the Babel AST as JSON.
58
+ version Print the gem version.
59
+ help Show this help.
60
+
61
+ Environment:
62
+ JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
63
+ USAGE
64
+
25
65
  def initialize(argv = ARGV.dup, stdout: $stdout, stderr: $stderr)
26
66
  @argv = argv
27
67
  @stdout = stdout
@@ -34,6 +74,7 @@ module JsxRosetta
34
74
  when "install" then run_install
35
75
  when "translate" then run_translate
36
76
  when "routes" then run_routes
77
+ when "pages-routes" then run_pages_routes
37
78
  when "parse" then run_parse
38
79
  when "version", "-v", "--version" then run_version
39
80
  when nil, "help", "-h", "--help" then print_help(EXIT_OK)
@@ -61,6 +102,8 @@ module JsxRosetta
61
102
  input_path = positional.first
62
103
  return missing_argument("translate FILE", "translate") unless input_path
63
104
 
105
+ resolve_rails_view_route!(options, input_path)
106
+
64
107
  out_dir = options[:out] || "."
65
108
  typescript = options[:tsx] || input_path.end_with?(".tsx")
66
109
  backend = backend_for_as(options[:as])
@@ -72,7 +115,8 @@ module JsxRosetta
72
115
  backend: backend,
73
116
  backend_options: backend_options,
74
117
  typescript: typescript,
75
- source_filename: input_path
118
+ source_filename: input_path,
119
+ keep_slot: options[:keep_slot] || false
76
120
  )
77
121
 
78
122
  write_emitted_files(files, out_dir)
@@ -93,7 +137,40 @@ module JsxRosetta
93
137
  def backend_options_for(backend, options)
94
138
  return {} unless backend == :phlex
95
139
 
96
- { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
140
+ base = { suffix: options[:phlex_suffix], namespace: options[:phlex_namespace] }.compact
141
+ base[:rails_view] = options[:rails_view_route] if options[:rails_view_route]
142
+ base[:route_table] = options[:route_table] if options[:route_table]
143
+ base
144
+ end
145
+
146
+ def resolve_rails_view_route!(options, input_path)
147
+ pages_dir = options[:rails_routes]
148
+ return unless pages_dir
149
+
150
+ raise ArgumentError, "--rails-routes requires --as=phlex" unless options[:as] == "phlex"
151
+ if options[:phlex_suffix] || options[:phlex_namespace]
152
+ raise ArgumentError, "--rails-routes cannot be combined with --phlex-suffix or --phlex-namespace"
153
+ end
154
+
155
+ ensure_pages_dir!(pages_dir, allow_any: options[:allow_any_dir])
156
+ rel = relative_path_under(input_path, pages_dir)
157
+ raise ArgumentError, "#{input_path} is not under #{pages_dir}" unless rel
158
+
159
+ routes, _skipped = PagesRouting.scan(pages_dir, extensions: options[:ext] || PagesRouting::DEFAULT_EXTENSIONS)
160
+ route = routes.find { |r| r.source_path == rel }
161
+ raise ArgumentError, "#{rel} has no route in #{pages_dir} (skipped or non-page file?)" unless route
162
+
163
+ options[:rails_view_route] = route
164
+ options[:route_table] = routes
165
+ end
166
+
167
+ def relative_path_under(file_path, dir)
168
+ file = Pathname.new(File.expand_path(file_path))
169
+ base = Pathname.new(File.expand_path(dir))
170
+ rel = file.relative_path_from(base).to_s
171
+ rel unless rel.start_with?("..")
172
+ rescue ArgumentError
173
+ nil
97
174
  end
98
175
 
99
176
  def write_emitted_files(files, out_dir)
@@ -129,6 +206,53 @@ module JsxRosetta
129
206
  EXIT_FAILURE
130
207
  end
131
208
 
209
+ def run_pages_routes
210
+ options, positional = parse_translate_options
211
+ input_dir = positional.first
212
+ return missing_argument("pages-routes DIR [-o OUT.rb]", "pages-routes") unless input_dir
213
+
214
+ ensure_pages_dir!(input_dir, allow_any: options[:allow_any_dir])
215
+ extensions = options[:ext] || PagesRouting::DEFAULT_EXTENSIONS
216
+ routes, skipped = PagesRouting.scan(input_dir, extensions: extensions)
217
+ contents = PagesRouting.emit(routes: routes, skipped: skipped, source_dir: input_dir)
218
+
219
+ if options[:out]
220
+ File.write(options[:out], contents)
221
+ @stdout.puts "wrote #{options[:out]}"
222
+ else
223
+ @stdout.print(contents)
224
+ end
225
+
226
+ write_controllers(routes, options[:controllers]) if options[:controllers]
227
+ EXIT_OK
228
+ rescue ArgumentError => e
229
+ @stderr.puts "jsx_rosetta pages-routes: #{e.message}"
230
+ EXIT_FAILURE
231
+ end
232
+
233
+ def write_controllers(routes, dir)
234
+ FileUtils.mkdir_p(dir)
235
+ PagesRouting.emit_controllers(routes: routes).each do |file|
236
+ target = File.join(dir, file.path)
237
+ if File.exist?(target)
238
+ @stdout.puts "skipped #{target} (exists)"
239
+ else
240
+ File.write(target, file.contents)
241
+ @stdout.puts "wrote #{target}"
242
+ end
243
+ end
244
+ end
245
+
246
+ def ensure_pages_dir!(dir, allow_any:)
247
+ return if allow_any
248
+ return if File.basename(dir) == "pages"
249
+ return if File.directory?(File.join(dir, "pages"))
250
+
251
+ raise ArgumentError,
252
+ "#{dir.inspect} does not look like a Next.js pages directory " \
253
+ "(basename != 'pages' and no nested 'pages/'). Pass --allow-any-dir to override."
254
+ end
255
+
132
256
  def run_parse
133
257
  options, positional = parse_translate_options
134
258
  input_path = positional.first
@@ -157,22 +281,59 @@ module JsxRosetta
157
281
 
158
282
  until @argv.empty?
159
283
  arg = @argv.shift
160
- case arg
161
- when "-o", "--out" then options[:out] = @argv.shift
162
- when "--tsx", "--typescript" then options[:tsx] = true
163
- when "--as" then options[:as] = @argv.shift
164
- when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
165
- when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
166
- when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
167
- when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
168
- when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
169
- else positional << arg
170
- end
284
+ positional << arg unless option_consumed?(arg, options)
171
285
  end
172
286
 
173
287
  [options, positional]
174
288
  end
175
289
 
290
+ def option_consumed?(arg, options)
291
+ consume_translate_option?(arg, options) ||
292
+ consume_phlex_option?(arg, options) ||
293
+ consume_pages_routes_option?(arg, options)
294
+ end
295
+
296
+ def consume_translate_option?(arg, options)
297
+ case arg
298
+ when "-o", "--out" then options[:out] = @argv.shift
299
+ when "--tsx", "--typescript" then options[:tsx] = true
300
+ when "--as" then options[:as] = @argv.shift
301
+ when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
302
+ when "--keep-slot" then options[:keep_slot] = true
303
+ else return false
304
+ end
305
+ true
306
+ end
307
+
308
+ def consume_phlex_option?(arg, options)
309
+ case arg
310
+ when "--phlex-suffix" then options[:phlex_suffix] = @argv.shift
311
+ when /\A--phlex-suffix=(.*)\z/ then options[:phlex_suffix] = ::Regexp.last_match(1)
312
+ when "--phlex-namespace" then options[:phlex_namespace] = @argv.shift
313
+ when /\A--phlex-namespace=(.+)\z/ then options[:phlex_namespace] = ::Regexp.last_match(1)
314
+ when "--rails-routes" then options[:rails_routes] = @argv.shift
315
+ when /\A--rails-routes=(.+)\z/ then options[:rails_routes] = ::Regexp.last_match(1)
316
+ else return false
317
+ end
318
+ true
319
+ end
320
+
321
+ def consume_pages_routes_option?(arg, options)
322
+ case arg
323
+ when "--ext" then options[:ext] = parse_ext_list(@argv.shift)
324
+ when /\A--ext=(.+)\z/ then options[:ext] = parse_ext_list(::Regexp.last_match(1))
325
+ when "--allow-any-dir" then options[:allow_any_dir] = true
326
+ when "--controllers" then options[:controllers] = @argv.shift
327
+ when /\A--controllers=(.+)\z/ then options[:controllers] = ::Regexp.last_match(1)
328
+ else return false
329
+ end
330
+ true
331
+ end
332
+
333
+ def parse_ext_list(value)
334
+ value.to_s.split(",").map(&:strip).reject(&:empty?).map { |ext| ext.start_with?(".") ? ext : ".#{ext}" }
335
+ end
336
+
176
337
  def missing_argument(usage, command)
177
338
  @stderr.puts "jsx_rosetta #{command}: missing required argument."
178
339
  @stderr.puts " usage: jsx_rosetta #{usage}"
@@ -180,30 +341,7 @@ module JsxRosetta
180
341
  end
181
342
 
182
343
  def print_help(exit_code)
183
- @stdout.puts <<~USAGE
184
- Usage: jsx_rosetta <command> [args]
185
-
186
- Commands:
187
- install Install the gem's Node sidecar dependencies (runs `npm install`).
188
- translate FILE [-o DIR] Translate JSX/TSX into ViewComponent files in DIR (default: ".").
189
- Pass --tsx to force TypeScript parsing if the input is .jsx.
190
- Pass --as=view to emit a Rails view template (`<snake>.html.erb`)
191
- instead of a ViewComponent class + sidecar template — appropriate
192
- for pages tied to a route.
193
- Pass --as=phlex to emit a single-file Phlex 2.x view class
194
- (`<snake>.rb`) instead of a ViewComponent. Configure the class
195
- name with --phlex-suffix=Component or --phlex-namespace=Components
196
- (mutually exclusive; default is bare class name).
197
- routes FILE [-o OUT.rb] Parse <Route path=... element={<X/>} /> patterns from FILE
198
- and emit a reviewable Ruby script that calls `rails generate
199
- controller` and prints suggested config/routes.rb additions.
200
- parse FILE Parse the input and print the Babel AST as JSON.
201
- version Print the gem version.
202
- help Show this help.
203
-
204
- Environment:
205
- JSX_ROSETTA_NODE Absolute path to a node executable (default: PATH lookup).
206
- USAGE
344
+ @stdout.puts USAGE_TEXT
207
345
  exit_code
208
346
  end
209
347
  end
@@ -0,0 +1,37 @@
1
+ {
2
+ "AlertCircle": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\"/><line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\"/>",
3
+ "AlertTriangle": "<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/>",
4
+ "ArrowDown": "<path d=\"M12 5v14\"/><path d=\"m19 12-7 7-7-7\"/>",
5
+ "ArrowLeft": "<path d=\"m12 19-7-7 7-7\"/><path d=\"M19 12H5\"/>",
6
+ "ArrowRight": "<path d=\"M5 12h14\"/><path d=\"m12 5 7 7-7 7\"/>",
7
+ "ArrowUp": "<path d=\"m5 12 7-7 7 7\"/><path d=\"M12 19V5\"/>",
8
+ "Bell": "<path d=\"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9\"/><path d=\"M10.3 21a1.94 1.94 0 0 0 3.4 0\"/>",
9
+ "Calendar": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" ry=\"2\"/><line x1=\"16\" x2=\"16\" y1=\"2\" y2=\"6\"/><line x1=\"8\" x2=\"8\" y1=\"2\" y2=\"6\"/><line x1=\"3\" x2=\"21\" y1=\"10\" y2=\"10\"/>",
10
+ "Check": "<path d=\"M20 6 9 17l-5-5\"/>",
11
+ "ChevronDown": "<path d=\"m6 9 6 6 6-6\"/>",
12
+ "ChevronLeft": "<path d=\"m15 18-6-6 6-6\"/>",
13
+ "ChevronRight": "<path d=\"m9 18 6-6-6-6\"/>",
14
+ "ChevronUp": "<path d=\"m18 15-6-6-6 6\"/>",
15
+ "ChevronsUpDown": "<path d=\"m7 15 5 5 5-5\"/><path d=\"m7 9 5-5 5 5\"/>",
16
+ "Circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"/>",
17
+ "Copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
18
+ "Dot": "<circle cx=\"12.1\" cy=\"12.1\" r=\"1\"/>",
19
+ "Eye": "<path d=\"M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
20
+ "EyeOff": "<path d=\"M9.88 9.88a3 3 0 1 0 4.24 4.24\"/><path d=\"M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68\"/><path d=\"M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61\"/><line x1=\"2\" x2=\"22\" y1=\"2\" y2=\"22\"/>",
21
+ "GripVertical": "<circle cx=\"9\" cy=\"12\" r=\"1\"/><circle cx=\"9\" cy=\"5\" r=\"1\"/><circle cx=\"9\" cy=\"19\" r=\"1\"/><circle cx=\"15\" cy=\"12\" r=\"1\"/><circle cx=\"15\" cy=\"5\" r=\"1\"/><circle cx=\"15\" cy=\"19\" r=\"1\"/>",
22
+ "Info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/>",
23
+ "Loader2": "<path d=\"M21 12a9 9 0 1 1-6.219-8.56\"/>",
24
+ "Mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
25
+ "Menu": "<line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\"/><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\"/><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\"/>",
26
+ "Minus": "<path d=\"M5 12h14\"/>",
27
+ "MoreHorizontal": "<circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/><circle cx=\"5\" cy=\"12\" r=\"1\"/>",
28
+ "MoreVertical": "<circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"5\" r=\"1\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/>",
29
+ "PanelLeft": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/><path d=\"M9 3v18\"/>",
30
+ "Plus": "<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>",
31
+ "Search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.3-4.3\"/>",
32
+ "Settings": "<path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
33
+ "Star": "<polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"/>",
34
+ "Trash2": "<path d=\"M3 6h18\"/><path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"/><path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"/><line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"/><line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"/>",
35
+ "User": "<path d=\"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2\"/><circle cx=\"12\" cy=\"7\" r=\"4\"/>",
36
+ "X": "<path d=\"M18 6 6 18\"/><path d=\"m6 6 12 12\"/>"
37
+ }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JsxRosetta
6
+ # Vendored SVG path data for the most common icons referenced by shadcn-shaped
7
+ # JSX. Backends look up icons here to emit standalone Phlex classes alongside
8
+ # the translated component .rb file, so consumers don't have to write icon
9
+ # shims by hand.
10
+ #
11
+ # Refresh `lib/jsx_rosetta/icons/lucide.json` from the upstream Lucide package
12
+ # if you need names that aren't here yet — but verify the path data, since
13
+ # Lucide occasionally tweaks icon shapes between releases.
14
+ module Icons
15
+ LUCIDE_DATA_PATH = File.expand_path("icons/lucide.json", __dir__)
16
+
17
+ # Source specifiers that import from a Lucide-shaped icon package.
18
+ # Includes both the React-flavored `lucide-react` and the bare `lucide`
19
+ # package (some shadcn forks use it directly).
20
+ LUCIDE_SOURCE_PATTERN = /\Alucide(-react)?\z/
21
+
22
+ def self.lucide_data
23
+ @lucide_data ||= JSON.parse(File.read(LUCIDE_DATA_PATH))
24
+ end
25
+
26
+ # Look up the inner-SVG for a Lucide icon. Tolerates both canonical
27
+ # (`ChevronRight`) and legacy `*Icon` (`ChevronRightIcon`) names, since
28
+ # shadcn varies which it imports across components. Returns nil for
29
+ # the degenerate name `"Icon"` (and any empty result after stripping)
30
+ # so callers don't get a misleading data[""] miss.
31
+ def self.lucide_for(name)
32
+ name = name.to_s
33
+ return nil if name.empty? || name == "Icon"
34
+
35
+ data = lucide_data
36
+ data[name] || data[name.sub(/Icon\z/, "")]
37
+ end
38
+
39
+ # True iff the given `IR::ModuleImport#source` is a known Lucide package.
40
+ def self.lucide_source?(source)
41
+ LUCIDE_SOURCE_PATTERN.match?(source.to_s)
42
+ end
43
+ end
44
+ end