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
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
340
|
-
# but can't model its value.
|
|
341
|
-
#
|
|
342
|
-
#
|
|
343
|
-
#
|
|
344
|
-
#
|
|
345
|
-
#
|
|
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
|
|
356
|
-
#
|
|
357
|
-
# leaf-translates-to-nil path is safe in value
|
|
358
|
-
# kwargs, leaf interpolations) but compound
|
|
359
|
-
# member chain) must bail so callers emit
|
|
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
|
|
data/lib/jsx_rosetta/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|