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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -11
- data/CLAUDE.md +70 -0
- data/README.md +50 -0
- data/agents/jsx-rosetta-resolve-todo-file.md +90 -0
- data/lib/jsx_rosetta/ast/inflector.rb +17 -0
- data/lib/jsx_rosetta/backend/phlex.rb +1078 -77
- data/lib/jsx_rosetta/backend/rails_view.rb +1 -1
- data/lib/jsx_rosetta/backend/view_component/expression_translator.rb +73 -20
- data/lib/jsx_rosetta/backend/view_component.rb +48 -2
- 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 +720 -31
- data/lib/jsx_rosetta/ir/radix_registry.rb +84 -0
- data/lib/jsx_rosetta/ir/types.rb +187 -3
- 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 +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
|
data/lib/jsx_rosetta/version.rb
CHANGED
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"
|