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
@@ -0,0 +1,640 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "ast/inflector"
6
+ require_relative "version"
7
+
8
+ module JsxRosetta
9
+ # Walks a Next.js-style `pages/` directory tree and produces a Rails
10
+ # `config/routes.rb` skeleton.
11
+ #
12
+ # The route table is derived from path shape alone — no JS parsing.
13
+ # Next.js filesystem routing is fully encoded in directory layout, so
14
+ # the input here is `Dir.glob` plus the file extension filter.
15
+ #
16
+ # Slice 1 of plans/nextjs_pages_to_rails.md: routes only. No file
17
+ # moves, no controller skeletons, no class renames.
18
+ module PagesRouting
19
+ # A single route resolved from the pages tree.
20
+ #
21
+ # `namespace` is `[]` for top-level routes, otherwise an ordered list of
22
+ # Rails namespace segments (slice-4 B3 nested dirs + B5 route groups).
23
+ # Both shapes flow into the same array — Naming + Emitter wrap routes in
24
+ # nested `namespace :foo do` blocks regardless of which mechanism added
25
+ # the segment.
26
+ #
27
+ # `kind` is `:standard` for regular GET routes, `:error_page` for
28
+ # `_error.tsx` / `404.tsx` / `500.tsx` (emitted via `config.exceptions_app`
29
+ # rather than the regular draw block), or `:layout` for `_app.tsx`
30
+ # (emitted as a view-placement directive only, not a route line).
31
+ Route = Data.define(:rails_path, :controller, :action, :source_path, :namespace, :kind) do
32
+ def initialize(rails_path:, controller:, action:, source_path:, namespace: [], kind: :standard)
33
+ super
34
+ end
35
+
36
+ # Extracts the named URL params from `rails_path` in order. Catches
37
+ # `:foo`, `*rest`, and `(/*extra)`-style optional catch-alls.
38
+ def url_params
39
+ return [] if rails_path.nil?
40
+
41
+ rails_path.scan(/[:*]([a-z_][a-z0-9_]*)/i).flatten
42
+ end
43
+ end
44
+
45
+ Skipped = Data.define(:source_path, :reason)
46
+ ControllerFile = Data.define(:path, :contents)
47
+
48
+ SKIPPED_LEAVES = {
49
+ "_document" => "Next.js HTML document — typically subsumed by Rails layout"
50
+ }.freeze
51
+
52
+ # Next.js error pages — leaf names that map to an `errors` controller
53
+ # with a standard action name. Routed via `config.exceptions_app` in
54
+ # Rails, not via the regular draw block.
55
+ ERROR_PAGE_LEAVES = {
56
+ "_error" => "fallback",
57
+ "404" => "not_found",
58
+ "500" => "internal_server_error"
59
+ }.freeze
60
+
61
+ # Leaf names that resolve to a Rails application layout, not a page.
62
+ # `_app.tsx` lands as `app/views/layouts/<action>.rb`. `_document.tsx`
63
+ # stays in SKIPPED_LEAVES — Rails owns the surrounding HTML scaffold.
64
+ LAYOUT_LEAVES = {
65
+ "_app" => "application"
66
+ }.freeze
67
+
68
+ DEFAULT_EXTENSIONS = %w[.tsx .jsx].freeze
69
+
70
+ def self.scan(dir, extensions: DEFAULT_EXTENSIONS)
71
+ Scanner.scan(dir, extensions: extensions)
72
+ end
73
+
74
+ def self.emit(routes:, skipped:, source_dir:, generated_at: nil)
75
+ Emitter.emit(routes: routes, skipped: skipped, source_dir: source_dir, generated_at: generated_at)
76
+ end
77
+
78
+ def self.emit_controllers(routes:)
79
+ ControllerEmitter.emit(routes: routes)
80
+ end
81
+
82
+ # Derives Rails route names and URL helper names from a Route. Used
83
+ # by both the routes.rb emitter (slice 1's `as:` lines) and the
84
+ # Phlex backend's href rewriter (slice 3) so the names stay paired.
85
+ module Naming
86
+ module_function
87
+
88
+ def route_name(route)
89
+ return "root" if route.rails_path == "/" && route.controller == "pages" &&
90
+ route.action == "index" && route.namespace.empty?
91
+
92
+ base = base_route_name(route)
93
+ return base if route.namespace.empty?
94
+
95
+ "#{route.namespace.join("_")}_#{base}"
96
+ end
97
+
98
+ def url_helper_name(route)
99
+ "#{route_name(route)}_path"
100
+ end
101
+
102
+ def base_route_name(route)
103
+ case route.action
104
+ when "index" then route.controller
105
+ when "show" then AST::Inflector.singularize(route.controller)
106
+ when "new" then "new_#{AST::Inflector.singularize(route.controller)}"
107
+ when "edit" then "edit_#{AST::Inflector.singularize(route.controller)}"
108
+ else "#{route.controller}_#{route.action}"
109
+ end
110
+ end
111
+ end
112
+
113
+ # Matches `href`/`to` paths against the route table and emits a
114
+ # Rails URL helper invocation. The caller pre-translates any
115
+ # template-literal hole expressions into Ruby; this class itself
116
+ # does no JS-to-Ruby translation.
117
+ class HrefRewriter
118
+ Token = Data.define(:kind, :value)
119
+
120
+ def initialize(routes)
121
+ @routes = routes
122
+ end
123
+
124
+ # Try to rewrite a literal path. Returns Ruby source string or nil.
125
+ def rewrite_literal(path)
126
+ return nil unless rewritable_path?(path)
127
+
128
+ tokens = path.split("/").reject(&:empty?).map { |seg| Token.new(kind: :literal, value: seg) }
129
+ rewrite_tokens(tokens)
130
+ end
131
+
132
+ # Try to rewrite a parsed template literal. `segments` is an array
133
+ # of `[:literal, "..."]` / `[:hole, "ruby_expr"]` pairs — the output
134
+ # of `.parse_template_source` after the caller translates each hole.
135
+ # Returns Ruby source or nil.
136
+ def rewrite_template(segments)
137
+ tokens = template_tokens(segments)
138
+ return nil unless tokens
139
+
140
+ rewrite_tokens(tokens)
141
+ end
142
+
143
+ # Parse a verbatim JS template literal source like
144
+ # `` `/foo/${bar}` `` into
145
+ # `[[:literal, "/foo/"], [:hole, "bar"], [:literal, ""]]`. Returns
146
+ # nil for malformed input or nested-brace interpolations.
147
+ def self.parse_template_source(js_source)
148
+ return nil unless js_source.is_a?(String) && js_source.start_with?("`") && js_source.end_with?("`")
149
+ return nil if js_source.length < 2
150
+
151
+ body = js_source[1..-2]
152
+ return nil if body.include?("`")
153
+
154
+ parts = []
155
+ pos = 0
156
+ hole_count = 0
157
+ body.to_enum(:scan, /\$\{([^{}]+)\}/).each do |_|
158
+ match = ::Regexp.last_match
159
+ parts << [:literal, body[pos...match.begin(0)]]
160
+ parts << [:hole, match[1].strip]
161
+ pos = match.end(0)
162
+ hole_count += 1
163
+ end
164
+ parts << [:literal, body[pos..]]
165
+ # `${...}` left in the trailing literal means an interpolation
166
+ # had nested braces and we can't safely match it.
167
+ return nil if body.scan("${").size != hole_count
168
+
169
+ parts
170
+ end
171
+
172
+ private
173
+
174
+ def rewritable_path?(path)
175
+ return false unless path.is_a?(String)
176
+ return false unless path.start_with?("/")
177
+ return false if path.start_with?("//")
178
+ return false if path.include?("?") || path.include?("#")
179
+
180
+ true
181
+ end
182
+
183
+ # Convert template segments into per-path-segment tokens by
184
+ # joining them with a sentinel marker then splitting on `/`. A
185
+ # hole must occupy a full path segment — `/foo${bar}/baz` fails
186
+ # because `foo${bar}` is a mixed literal+hole segment.
187
+ def template_tokens(segments)
188
+ joined = +""
189
+ holes = []
190
+ segments.each do |kind, value|
191
+ case kind
192
+ when :literal
193
+ return nil if value.include?("?") || value.include?("#")
194
+
195
+ joined << value
196
+ when :hole
197
+ joined << "\x01#{holes.length}\x01"
198
+ holes << value
199
+ end
200
+ end
201
+ return nil unless joined.start_with?("/")
202
+
203
+ joined.split("/").reject(&:empty?).map do |segment|
204
+ if (m = /\A\x01(\d+)\x01\z/.match(segment))
205
+ Token.new(kind: :hole, value: holes[Integer(m[1])])
206
+ elsif segment.include?("\x01")
207
+ return nil
208
+ else
209
+ Token.new(kind: :literal, value: segment)
210
+ end
211
+ end
212
+ end
213
+
214
+ def rewrite_tokens(tokens)
215
+ matches = @routes.filter_map { |route| match_route(route, tokens) }
216
+ return nil if matches.size != 1
217
+
218
+ route, ruby_args = matches.first
219
+ helper = Naming.url_helper_name(route)
220
+ ruby_args.empty? ? helper : "#{helper}(#{ruby_args.join(", ")})"
221
+ end
222
+
223
+ def match_route(route, tokens)
224
+ route_segments = route.rails_path.split("/").reject(&:empty?)
225
+ return nil if route_segments.any? { |s| s.start_with?("*") || s.start_with?("(") }
226
+ return nil unless route_segments.size == tokens.size
227
+
228
+ ruby_args = match_segments(route_segments, tokens)
229
+ ruby_args && [route, ruby_args]
230
+ end
231
+
232
+ def match_segments(route_segments, tokens)
233
+ ruby_args = []
234
+ route_segments.zip(tokens).each do |route_seg, token|
235
+ if route_seg.start_with?(":")
236
+ ruby_args << (token.kind == :literal ? literal_arg_to_ruby(token.value) : token.value)
237
+ elsif token.kind == :literal && token.value == route_seg
238
+ next
239
+ else
240
+ return nil
241
+ end
242
+ end
243
+ ruby_args
244
+ end
245
+
246
+ def literal_arg_to_ruby(value)
247
+ value.match?(/\A-?\d+\z/) ? value : AST::Inflector.ruby_string_literal(value)
248
+ end
249
+ end
250
+
251
+ # Scans a directory and classifies each file as a Route or Skipped.
252
+ module Scanner
253
+ class << self
254
+ def scan(dir, extensions: DEFAULT_EXTENSIONS)
255
+ raise ArgumentError, "pages-routes: #{dir.inspect} is not a directory" unless File.directory?(dir)
256
+
257
+ routes = []
258
+ skipped = []
259
+ collect_files(dir, extensions).each do |rel_path|
260
+ segments = path_segments(rel_path)
261
+ leaf = segments.last
262
+ if (reason = SKIPPED_LEAVES[leaf])
263
+ skipped << Skipped.new(source_path: rel_path, reason: reason)
264
+ elsif ERROR_PAGE_LEAVES.key?(leaf)
265
+ routes << build_error_route(leaf, rel_path)
266
+ elsif LAYOUT_LEAVES.key?(leaf)
267
+ routes << build_layout_route(leaf, rel_path)
268
+ else
269
+ routes << build_route(segments, rel_path)
270
+ end
271
+ end
272
+ [routes, skipped]
273
+ end
274
+
275
+ private
276
+
277
+ def collect_files(dir, extensions)
278
+ base = Pathname.new(dir)
279
+ Dir.glob(File.join(dir, "**", "*")).filter_map do |abs_path|
280
+ next unless File.file?(abs_path) && extensions.include?(File.extname(abs_path))
281
+
282
+ Pathname.new(abs_path).relative_path_from(base).to_s
283
+ end.sort
284
+ end
285
+
286
+ def path_segments(rel_path)
287
+ parts = rel_path.split(File::SEPARATOR)
288
+ parts[-1] = parts[-1].sub(/\.[^.]+\z/, "")
289
+ parts
290
+ end
291
+
292
+ # `_error.tsx` / `404.tsx` / `500.tsx` get a synthetic ErrorsController
293
+ # route. `rails_path` records the URL Rails should match (`/<status>`)
294
+ # so HrefRewriter and emitter share the same shape, but the emitter
295
+ # ignores it for the `get` block (it's listed in the `config.exceptions_app`
296
+ # comment header instead).
297
+ def build_error_route(leaf, source_path)
298
+ action = ERROR_PAGE_LEAVES.fetch(leaf)
299
+ Route.new(
300
+ rails_path: "/#{leaf}",
301
+ controller: "errors",
302
+ action: action,
303
+ source_path: source_path,
304
+ kind: :error_page
305
+ )
306
+ end
307
+
308
+ # `_app.tsx` lands as a Rails application layout. It does NOT
309
+ # produce a route line in routes.rb (rails_path is nil) — the
310
+ # emitter calls it out in a dedicated comment block instead.
311
+ # `controller` reads "layouts" so the Phlex view-placement path
312
+ # falls out naturally (`app/views/layouts/application.rb`).
313
+ def build_layout_route(leaf, source_path)
314
+ action = LAYOUT_LEAVES.fetch(leaf)
315
+ Route.new(
316
+ rails_path: nil,
317
+ controller: "layouts",
318
+ action: action,
319
+ source_path: source_path,
320
+ kind: :layout
321
+ )
322
+ end
323
+
324
+ def build_route(segments, source_path)
325
+ leaf = segments.last
326
+ dir_segments = segments[0..-2]
327
+ inside_bracket_dir = dir_segments.any? { |s| bracket_segment?(s) }
328
+ controller, namespace = controller_and_namespace_for(dir_segments)
329
+
330
+ Route.new(
331
+ rails_path: rails_path_for(segments),
332
+ controller: controller,
333
+ action: action_for(leaf, inside_bracket_dir: inside_bracket_dir),
334
+ source_path: source_path,
335
+ namespace: namespace
336
+ )
337
+ end
338
+
339
+ def action_for(leaf, inside_bracket_dir:)
340
+ return inside_bracket_dir ? "show" : "index" if leaf == "index"
341
+ return "show" if bracket_segment?(leaf)
342
+
343
+ AST::Inflector.underscore(leaf)
344
+ end
345
+
346
+ # Returns [controller_name, namespace_array]. Splits dir_segments
347
+ # into three buckets: route_groups (paren-wrapped) feed entirely
348
+ # into namespace; named dirs feed into namespace except for the
349
+ # last one which becomes the controller; bracket dirs (URL params)
350
+ # are URL-only and don't participate in either.
351
+ def controller_and_namespace_for(dir_segments)
352
+ named = []
353
+ groups = []
354
+ dir_segments.each do |segment|
355
+ if route_group_segment?(segment)
356
+ groups << route_group_name(segment)
357
+ elsif !bracket_segment?(segment)
358
+ named << segment
359
+ end
360
+ end
361
+ controller = named.empty? ? "pages" : named.pop
362
+ namespace = (groups + named).map { |n| AST::Inflector.underscore(n) }
363
+ [AST::Inflector.underscore(controller), namespace]
364
+ end
365
+
366
+ def rails_path_for(segments)
367
+ parts = segments.filter_map { |segment| segment_to_path_part(segment) }
368
+ parts.pop if parts.last == [:literal, "index"]
369
+ build_path(parts)
370
+ end
371
+
372
+ def segment_to_path_part(segment)
373
+ case segment
374
+ when /\A\[\[\.\.\.([^\]]+)\]\]\z/
375
+ [:optional_catch_all, AST::Inflector.underscore(Regexp.last_match(1))]
376
+ when /\A\[\.\.\.([^\]]+)\]\z/
377
+ [:catch_all, AST::Inflector.underscore(Regexp.last_match(1))]
378
+ when /\A\[([^\]]+)\]\z/
379
+ [:param, AST::Inflector.underscore(Regexp.last_match(1))]
380
+ when /\A\(([^)]+)\)\z/
381
+ # Route groups are URL-invisible — they only affect controller
382
+ # namespace (handled in controller_and_namespace_for).
383
+ nil
384
+ else
385
+ [:literal, segment]
386
+ end
387
+ end
388
+
389
+ def bracket_segment?(segment)
390
+ segment.start_with?("[") && segment.end_with?("]")
391
+ end
392
+
393
+ def route_group_segment?(segment)
394
+ segment.start_with?("(") && segment.end_with?(")") && segment.length > 2
395
+ end
396
+
397
+ def route_group_name(segment)
398
+ segment[1..-2]
399
+ end
400
+
401
+ def build_path(parts)
402
+ return "/" if parts.empty?
403
+
404
+ parts.each_with_object(+"") do |(kind, name), result|
405
+ case kind
406
+ when :literal then result << "/#{name}"
407
+ when :param then result << "/:#{name}"
408
+ when :catch_all then result << "/*#{name}"
409
+ when :optional_catch_all then result << "(/*#{name})"
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
415
+
416
+ # Renders a Scanner result as a full `config/routes.rb` file.
417
+ module Emitter
418
+ class << self
419
+ def emit(routes:, skipped:, source_dir:, generated_at: nil)
420
+ generated_at ||= Time.now.utc.strftime("%Y-%m-%d")
421
+ page_routes = routes.select { |r| r.kind == :standard }
422
+ error_routes = routes.select { |r| r.kind == :error_page }
423
+ layout_routes = routes.select { |r| r.kind == :layout }
424
+ sections = [header(source_dir, generated_at, routes, skipped)]
425
+ sections << skipped_block(skipped) unless skipped.empty?
426
+ sections << layouts_block(layout_routes) unless layout_routes.empty?
427
+ sections << error_pages_block(error_routes) unless error_routes.empty?
428
+ sections << draw_block(page_routes, error_routes)
429
+ sections << generator_hints(page_routes) unless page_routes.empty?
430
+ "#{sections.join("\n\n")}\n"
431
+ end
432
+
433
+ private
434
+
435
+ def header(source_dir, generated_at, routes, skipped)
436
+ [
437
+ "# frozen_string_literal: true",
438
+ "#",
439
+ "# Generated by jsx_rosetta pages-routes (#{JsxRosetta::VERSION}) from #{source_dir} on #{generated_at}.",
440
+ "# #{routes.size} route(s), #{skipped.size} skipped file(s). Review before committing."
441
+ ].join("\n")
442
+ end
443
+
444
+ def skipped_block(skipped)
445
+ lines = ["# Skipped (non-page files — wire up Rails counterparts separately):"]
446
+ skipped.each { |entry| lines << "# - #{entry.source_path} → #{entry.reason}" }
447
+ lines.join("\n")
448
+ end
449
+
450
+ # Header for application-layout files (`_app.tsx`). Layouts don't
451
+ # produce route lines — they land in `app/views/layouts/<action>.rb`
452
+ # via the Phlex view-placement path. Listed in the header so a
453
+ # human reading routes.rb can see where _app.tsx went.
454
+ def layouts_block(layout_routes)
455
+ lines = ["# Layouts — translated to app/views/layouts/<action>.rb. " \
456
+ "No route lines are emitted; Rails resolves layouts by name."]
457
+ layout_routes.sort_by(&:action).each do |route|
458
+ lines << "# - #{route.source_path} → app/views/layouts/#{route.action}.rb"
459
+ end
460
+ lines.join("\n")
461
+ end
462
+
463
+ # Wiring header for error pages. Listed above the draw block since
464
+ # Rails matches these via `config.exceptions_app`, not via the regular
465
+ # router. The comment block names each detected error page + the
466
+ # corresponding ErrorsController action, plus the two standard wiring
467
+ # approaches (exceptions_app vs. public/<status>.html).
468
+ def error_pages_block(error_routes)
469
+ lines = [
470
+ "# Error pages — Next.js _error / 404 / 500 detected. Wire one of:",
471
+ "#",
472
+ "# (1) config.exceptions_app — in config/application.rb:",
473
+ "# config.exceptions_app = self.routes",
474
+ "# Then declare them as ordinary routes inside the draw block:"
475
+ ]
476
+ error_routes.sort_by(&:rails_path).each do |route|
477
+ lines << "# match #{route.rails_path.inspect}, " \
478
+ "to: \"errors##{route.action}\", via: :all"
479
+ end
480
+ lines += [
481
+ "#",
482
+ "# (2) Static fallbacks — drop the rendered templates at",
483
+ "# public/404.html / public/500.html and let Rails serve them",
484
+ "# directly without hitting the app."
485
+ ]
486
+ lines.join("\n")
487
+ end
488
+
489
+ def draw_block(routes, error_routes = [])
490
+ return "Rails.application.routes.draw do\nend" if routes.empty? && error_routes.empty?
491
+
492
+ unique, duplicates = dedupe(routes)
493
+ body = grouped_body(unique, duplicates)
494
+ body += error_routes_draw_lines(error_routes) unless error_routes.empty?
495
+ (["Rails.application.routes.draw do"] + body + ["end"]).join("\n")
496
+ end
497
+
498
+ # The error-page routes themselves still go in the draw block so
499
+ # `match "/404", to: "errors#not_found"` is part of routes.rb — the
500
+ # header comment explains the `config.exceptions_app` wiring needed
501
+ # to make Rails actually invoke them. Sorted with a blank line above
502
+ # for visual separation.
503
+ def error_routes_draw_lines(error_routes)
504
+ lines = ["", " # == errors (config.exceptions_app) =="]
505
+ error_routes.sort_by(&:action).each do |route|
506
+ lines << (%( match #{route.rails_path.inspect}, to: "errors##{route.action}", ) +
507
+ %(via: :all, as: :#{Naming.route_name(route)}))
508
+ end
509
+ lines
510
+ end
511
+
512
+ def dedupe(routes)
513
+ unique = {}
514
+ duplicates = Hash.new { |h, k| h[k] = [] }
515
+ routes.each do |route|
516
+ key = [route.controller, route.action, route.rails_path]
517
+ if unique.key?(key)
518
+ duplicates[key] << route
519
+ else
520
+ unique[key] = route
521
+ end
522
+ end
523
+ [unique.values, duplicates]
524
+ end
525
+
526
+ def grouped_body(routes, duplicates)
527
+ by_controller = routes.group_by { |r| group_key(r) }.sort.to_h
528
+ lines = []
529
+ by_controller.each_with_index do |(_, group_routes), idx|
530
+ lines << "" if idx.positive?
531
+ lines << " # == #{controller_label(group_routes.first)} =="
532
+ group_routes.sort_by { |r| sort_key(r) }.each do |route|
533
+ lines << route_line(route)
534
+ dup_key = [route.controller, route.action, route.rails_path]
535
+ next unless duplicates.key?(dup_key)
536
+
537
+ dup_sources = duplicates[dup_key].map(&:source_path).join(", ")
538
+ lines << " # ↑ also produced by: #{dup_sources}"
539
+ end
540
+ end
541
+ lines
542
+ end
543
+
544
+ def group_key(route)
545
+ [route.namespace, route.controller]
546
+ end
547
+
548
+ def controller_label(route)
549
+ qualified_controller(route)
550
+ end
551
+
552
+ def qualified_controller(route)
553
+ (route.namespace + [route.controller]).join("/")
554
+ end
555
+
556
+ def sort_key(route)
557
+ # `root to: ...` first within its group, then alpha by path.
558
+ [route.rails_path == "/" ? 0 : 1, route.rails_path]
559
+ end
560
+
561
+ def route_line(route)
562
+ target = qualified_controller(route)
563
+ if route.rails_path == "/" && target == "pages" && route.action == "index"
564
+ %( root to: "pages#index")
565
+ elsif route.rails_path.start_with?("*")
566
+ %( match #{route.rails_path.inspect}, to: "#{target}##{route.action}", ) +
567
+ %(via: :all, as: :#{Naming.route_name(route)})
568
+ else
569
+ %( get #{route.rails_path.inspect}, to: "#{target}##{route.action}", ) +
570
+ %(as: :#{Naming.route_name(route)})
571
+ end
572
+ end
573
+
574
+ def generator_hints(routes)
575
+ unique = routes.uniq { |r| [r.namespace, r.controller, r.action] }
576
+ by_controller = unique.group_by { |r| [r.namespace, r.controller] }.sort.to_h
577
+ lines = ["# Suggested controller scaffolds (uncomment to run with `ruby`):"]
578
+ by_controller.each_value do |group_routes|
579
+ target = qualified_controller(group_routes.first)
580
+ actions = group_routes.map(&:action).uniq.sort
581
+ args = ([target] + actions).map(&:inspect).join(", ")
582
+ lines << %(# system "rails", "generate", "controller", #{args}, "--skip-routes")
583
+ end
584
+ lines.join("\n")
585
+ end
586
+ end
587
+ end
588
+
589
+ # Renders ApplicationController-inheriting skeletons, one per
590
+ # controller in the route table. Each action body is empty; URL
591
+ # params for that action are listed in a comment above the def.
592
+ module ControllerEmitter
593
+ class << self
594
+ def emit(routes:)
595
+ unique = routes.uniq { |r| [r.namespace, r.controller, r.action] }
596
+ unique.group_by { |r| [r.namespace, r.controller] }.sort.map do |(namespace, controller), group_routes|
597
+ ControllerFile.new(
598
+ path: controller_path(namespace, controller),
599
+ contents: render(namespace, controller, group_routes.sort_by(&:action))
600
+ )
601
+ end
602
+ end
603
+
604
+ private
605
+
606
+ def controller_path(namespace, controller)
607
+ ((namespace || []) + ["#{controller}_controller.rb"]).join("/")
608
+ end
609
+
610
+ def render(namespace, controller, group_routes)
611
+ qualified_class = qualified_controller_class(namespace, controller)
612
+ view_dir = ((namespace || []) + [controller]).join("/")
613
+ actions = group_routes.map { |route| action_section(route) }.join("\n\n")
614
+ <<~RUBY
615
+ # frozen_string_literal: true
616
+
617
+ # Generated by jsx_rosetta pages-routes. Wire up `before_action`
618
+ # filters and load instance variables for the matching Phlex view
619
+ # (app/views/#{view_dir}/<action>.rb).
620
+ class #{qualified_class} < ApplicationController
621
+ #{actions}
622
+ end
623
+ RUBY
624
+ end
625
+
626
+ def qualified_controller_class(namespace, controller)
627
+ parts = (namespace || []).map { |ns| AST::Inflector.upper_camelize(ns) }
628
+ parts << "#{AST::Inflector.upper_camelize(controller)}Controller"
629
+ parts.join("::")
630
+ end
631
+
632
+ def action_section(route)
633
+ params = route.url_params
634
+ comment = params.empty? ? "" : " # params: #{params.map { |p| ":#{p}" }.join(", ")}\n"
635
+ "#{comment} def #{route.action}\n end"
636
+ end
637
+ end
638
+ end
639
+ end
640
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JsxRosetta
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/jsx_rosetta.rb CHANGED
@@ -9,24 +9,24 @@ module JsxRosetta
9
9
  Parser.new.parse(source, typescript: typescript, source_filename: source_filename)
10
10
  end
11
11
 
12
- def self.lower(source, typescript: false, source_filename: nil)
12
+ def self.lower(source, typescript: false, source_filename: nil, keep_slot: false)
13
13
  ast = parse(source, typescript: typescript, source_filename: source_filename)
14
- IR.lower(ast, source: source)
14
+ IR.lower(ast, source: source, keep_slot: keep_slot)
15
15
  end
16
16
 
17
17
  def self.translate(source, backend: :view_component, backend_options: {},
18
- typescript: false, source_filename: nil, **legacy_options)
18
+ typescript: false, source_filename: nil, keep_slot: false, **legacy_options)
19
19
  ast = parse(source, typescript: typescript, source_filename: source_filename)
20
- components = IR.lower_all(ast, source: source)
20
+ components = IR.lower_all(ast, source: source, keep_slot: keep_slot)
21
21
  backend_instance = backend_for(backend, **legacy_options, **backend_options)
22
- components.flat_map { |component| backend_instance.emit(component) }
22
+ components.flat_map { |component| backend_instance.emit(component, source_filename: source_filename) }
23
23
  end
24
24
 
25
25
  def self.backend_for(name, **options)
26
26
  case name
27
27
  when :view_component then Backend::ViewComponent.new(**options.slice(:helpers, :layout))
28
28
  when :rails_view then Backend::RailsView.new(**options.slice(:helpers, :layout))
29
- when :phlex then Backend::Phlex.new(**options.slice(:suffix, :namespace))
29
+ when :phlex then Backend::Phlex.new(**options.slice(:suffix, :namespace, :rails_view, :route_table))
30
30
  else
31
31
  raise Error, "unknown backend: #{name.inspect}"
32
32
  end
@@ -38,5 +38,7 @@ require_relative "jsx_rosetta/node_bridge"
38
38
  require_relative "jsx_rosetta/parser"
39
39
  require_relative "jsx_rosetta/ir"
40
40
  require_relative "jsx_rosetta/routes"
41
+ require_relative "jsx_rosetta/icons"
42
+ require_relative "jsx_rosetta/pages_routing"
41
43
  require_relative "jsx_rosetta/backend"
42
44
  require_relative "jsx_rosetta/cli"