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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsxRosetta
4
+ module IR
5
+ # When a shadcn TSX wraps a Radix primitive like
6
+ # `<SeparatorPrimitive.Root orientation="horizontal" />`, the translator
7
+ # has historically lowered it to a `ComponentInvocation` whose name is
8
+ # the member chain (`"SeparatorPrimitive.Root"`) — which emits as
9
+ # `render SeparatorPrimitive::Root.new(...)`, an undefined-constant
10
+ # NameError at render time.
11
+ #
12
+ # The shapes underneath are stable enough across Radix that we can map
13
+ # the common (LocalImportName, MemberName) pairs to plain HTML elements
14
+ # plus any always-applied attributes. The mapping is keyed on the LOCAL
15
+ # binding name shadcn uses by convention (e.g. `SeparatorPrimitive`),
16
+ # which matches the import-aliasing pattern in every shadcn fork I've
17
+ # surveyed. Unknown pairs fall through to the existing ComponentInvocation
18
+ # / TODO behavior so consumers can hand-shim primitives we don't cover.
19
+ #
20
+ # Source specifiers that count as "Radix" — both the `radix-ui` umbrella
21
+ # package and the older `@radix-ui/react-*` per-primitive packages.
22
+ RADIX_SOURCE_PATTERN = %r{\A(?:radix-ui|@radix-ui/react-[\w-]+)\z}
23
+
24
+ # Local binding names that resolve to Radix's `Slot` primitive.
25
+ # Matches `Slot` (shadcn-v4 umbrella) and `SlotPrimitive` (older
26
+ # convention). Anything looser would silently drop user-defined
27
+ # components whose names happen to start with "Slot".
28
+ SLOT_LOCAL_NAME_PATTERN = /\ASlot(?:Primitive)?\z/
29
+
30
+ module RadixRegistry
31
+ # Each entry maps [PrimitiveBaseName, MemberName] →
32
+ # { tag: <html-tag>, attrs: { <fixed kwarg name> => <value> } }
33
+ # The `attrs` are merged into the element's lowered attributes (the
34
+ # consumer's own kwargs win on conflict — see `merge_radix_attrs`).
35
+ #
36
+ # `PrimitiveBaseName` is the *canonical* Radix primitive name
37
+ # (e.g. `Separator`); the lookup strips an optional `Primitive` suffix
38
+ # from the consumer's local binding before keying in, so both shadcn-v3
39
+ # (`import { Separator as SeparatorPrimitive } from "radix-ui"`) and
40
+ # shadcn-v4 (`import { Separator } from "radix-ui"`) umbrella idioms
41
+ # resolve. Namespace imports (`import * as SeparatorPrimitive from
42
+ # "@radix-ui/react-separator"`) also strip the suffix.
43
+ MAP = {
44
+ # Separator / Label / Avatar / Switch primitives — the shapes we hit
45
+ # most often in the bulk shadcn translation pass.
46
+ %w[Separator Root] => { tag: "div", attrs: { role: "separator" } },
47
+ %w[Label Root] => { tag: "label", attrs: {} },
48
+ %w[Avatar Root] => { tag: "span", attrs: {} },
49
+ %w[Avatar Image] => { tag: "img", attrs: {} },
50
+ %w[Avatar Fallback] => { tag: "span", attrs: {} },
51
+ %w[Switch Root] => { tag: "button", attrs: { type: "button", role: "switch" } },
52
+ %w[Switch Thumb] => { tag: "span", attrs: {} },
53
+ %w[Progress Root] => { tag: "div", attrs: { role: "progressbar" } },
54
+ %w[Progress Indicator] => { tag: "div", attrs: {} },
55
+ # Aspect-ratio + scroll-area roots are presentational containers.
56
+ %w[AspectRatio Root] => { tag: "div", attrs: {} },
57
+ %w[ScrollArea Root] => { tag: "div", attrs: {} },
58
+ %w[ScrollArea Viewport] => { tag: "div", attrs: {} },
59
+ # Tabs primitives — `data-orientation` comes from the JSX attrs;
60
+ # role=tablist on List + role=tab on Trigger + role=tabpanel on Content.
61
+ %w[Tabs Root] => { tag: "div", attrs: {} },
62
+ %w[Tabs List] => { tag: "div", attrs: { role: "tablist" } },
63
+ %w[Tabs Trigger] => { tag: "button", attrs: { type: "button", role: "tab" } },
64
+ %w[Tabs Content] => { tag: "div", attrs: { role: "tabpanel" } },
65
+ # Toggle / ToggleGroup — pressable buttons.
66
+ %w[Toggle Root] => { tag: "button", attrs: { type: "button" } },
67
+ %w[ToggleGroup Root] => { tag: "div", attrs: { role: "group" } },
68
+ %w[ToggleGroup Item] => { tag: "button", attrs: { type: "button" } },
69
+ # Collapsible — presentational containers; the open/closed state is
70
+ # data-state driven by the consumer (Stimulus or otherwise).
71
+ %w[Collapsible Root] => { tag: "div", attrs: {} },
72
+ %w[Collapsible Trigger] => { tag: "button", attrs: { type: "button" } },
73
+ %w[Collapsible Content] => { tag: "div", attrs: {} }
74
+ }.freeze
75
+
76
+ # Strip an optional `Primitive` suffix from the local binding so the
77
+ # registry's canonical names match both umbrella imports (`Separator`)
78
+ # and the older convention (`SeparatorPrimitive`).
79
+ def self.lookup(local_name, member)
80
+ MAP[[local_name, member]] || MAP[[local_name.sub(/Primitive\z/, ""), member]]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -54,6 +54,14 @@ module JsxRosetta
54
54
  # Without this capture, references to module-level
55
55
  # constants from inside the JSX silently drop and
56
56
  # produce unbacked snake_case references at render time.
57
+ # module_imports : [ModuleImport] — top-level `import` declarations.
58
+ # Backends thread the imported names into the
59
+ # ExpressionTranslator so any expression-context
60
+ # reference to an import (e.g. `styles.listContainer`
61
+ # from `import styles from "./X.module.css"`, or
62
+ # `AlertStatusEnum.Pending` from a TS enum import)
63
+ # bails out to a TODO instead of snake-casing to a
64
+ # bare identifier that NameErrors at render time.
57
65
  # render_methods : [RenderMethod] — local arrow bindings that return JSX
58
66
  # and are invoked from the JSX body (`const renderHeader
59
67
  # = () => <div/>; ... {renderHeader()}`). Backends emit
@@ -67,11 +75,69 @@ module JsxRosetta
67
75
  # that returns the translated data, instead of `view_template`.
68
76
  # JSX inside object properties still extracts to private
69
77
  # methods on the class via the IR::Lambda path.
78
+ # server_data_source : ServerDataSource | nil — capture of a
79
+ # Next.js `getServerSideProps` / `getStaticProps`
80
+ # export from the same source file as this component.
81
+ # Body preserved verbatim so the Phlex backend can
82
+ # emit it as a TODO comment block above the class
83
+ # (the matching Rails controller action is where the
84
+ # user ports the data-loading logic). Attached only
85
+ # to the first component when a file contains
86
+ # multiple — Next.js page files have exactly one
87
+ # default-export component, so collisions are rare.
88
+ # hoc_wrappers : [String] — Higher-Order Component wrapper names
89
+ # that the lowering peeled off when finding this
90
+ # component (e.g. `memo`, `forwardRef`, `observer`,
91
+ # `connect`, `withRouter`). Recorded in
92
+ # outside-in order so a `memo(forwardRef(...))`
93
+ # emits as ["memo", "forwardRef"]. Backends surface
94
+ # these as a TODO comment block above the class
95
+ # explaining each wrapper's Rails analog (or
96
+ # lack of one). Empty for components without
97
+ # wrappers — the common case.
70
98
  Component = Data.define(:name, :props, :body, :rest_prop_name,
71
99
  :local_bindings, :local_binding_names,
72
- :module_bindings,
100
+ :module_bindings, :module_imports,
73
101
  :stimulus_methods, :react_hooks,
74
- :render_methods, :mode) do
102
+ :render_methods, :mode, :server_data_source,
103
+ :hoc_wrappers) do
104
+ include Node
105
+ end
106
+
107
+ # A Next.js server data-loading export (`getServerSideProps` /
108
+ # `getStaticProps`). Captured at lowering time so the Phlex backend can
109
+ # surface the body as a TODO comment block pointed at the matching
110
+ # Rails controller action. Body is preserved verbatim — no JS-to-Ruby
111
+ # translation is attempted per project rules.
112
+ #
113
+ # hook_name : String — "getServerSideProps" or "getStaticProps".
114
+ # source : String — verbatim JS of the entire export statement.
115
+ ServerDataSource = Data.define(:hook_name, :source) do
116
+ include Node
117
+ end
118
+
119
+ # A top-level `import` declaration. Captured at lowering time so the
120
+ # ExpressionTranslator can recognize use-site references and bail out
121
+ # to a TODO instead of emitting a bare snake_case identifier that
122
+ # NameErrors at render time.
123
+ #
124
+ # name : String — the local binding name (the side the source uses
125
+ # to reference the imported value). For `import { foo as bar }`
126
+ # this is "bar"; for `import * as styles` this is "styles";
127
+ # for `import Default` this is "Default".
128
+ # source : String — the module specifier verbatim
129
+ # (e.g. "./styles.module.css", "@apollo/client", "react").
130
+ # Lets backends apply per-source policy later (e.g. always
131
+ # strip `*.module.css` references).
132
+ # kind : Symbol — :default | :named | :namespace.
133
+ # imported_name : String? — original exported name from the source module.
134
+ # For `import { ChevronRight as CR } from "lucide-react"`,
135
+ # `name` is `"CR"` and `imported_name` is `"ChevronRight"`.
136
+ # nil for default / namespace imports where there's no
137
+ # distinct exported name. Backends that look up vendored
138
+ # data by canonical name (icons) need the imported name;
139
+ # most callers want the local binding.
140
+ ModuleImport = Data.define(:name, :source, :kind, :imported_name) do
75
141
  include Node
76
142
  end
77
143
 
@@ -85,6 +151,85 @@ module JsxRosetta
85
151
  include Node
86
152
  end
87
153
 
154
+ # A module-level call to `cva()` from class-variance-authority. shadcn
155
+ # components ubiquitously use this builder to attach a base class string
156
+ # plus per-axis variant maps to a JSX component. The translator
157
+ # recognizes the pattern at lowering time so backends can emit real
158
+ # Ruby constants (`FOO_BASE_CLASS`, `FOO_VARIANT_CLASSES`) instead of
159
+ # leaving the call as a TODO comment, and so the use-site
160
+ # `cn(fooVariants({ variant }), className)` translates to a proper
161
+ # Ruby string interpolation.
162
+ #
163
+ # name : String — the const binding name (e.g. "buttonVariants").
164
+ # base_class : String — the first string argument to cva().
165
+ # variants : Hash[String => Hash[String => String]]
166
+ # — { "variant" => { "default" => "...", "outline" => "..." } }
167
+ # default_variants : Hash[String => String] — per-axis default value name
168
+ # (matched against the variant axis keys).
169
+ # compound_source : String | nil — INTENTIONALLY UNPARSED verbatim JS
170
+ # source of any `compoundVariants` entry. Field name
171
+ # reads structural; it isn't — backends only print
172
+ # it as a TODO comment alongside the constants since
173
+ # compoundVariants semantics aren't supported in the
174
+ # first cut.
175
+ CvaBinding = Data.define(:name, :base_class, :variants, :default_variants, :compound_source) do
176
+ include Node
177
+ end
178
+
179
+ # A literal-shaped module-level `const` declaration that lowers to a real
180
+ # Ruby constant. Distinct from `LocalBinding` (verbatim TODO block) and
181
+ # `CvaBinding` (structured cva variants). The detector accepts initializers
182
+ # whose value reduces to a Ruby-literal-friendly object — strings, numbers,
183
+ # booleans, null, arrays/hashes of the same. Non-literal initializers
184
+ # (call expressions, identifier references, JSX) still fall through to
185
+ # the `LocalBinding` path so their verbatim source surfaces in the
186
+ # module-bindings TODO block.
187
+ #
188
+ # name : String — original JS identifier (e.g. "TAGS", "COLUMNS").
189
+ # constant_name : String — Ruby constant identifier emitted above the class
190
+ # (`AST::Inflector.underscore(name).upcase`). Stored on the
191
+ # IR so future collision-detection has somewhere to bind.
192
+ # value : Object — Ruby-literal-friendly value (String, Integer,
193
+ # Float, true, false, nil, Array of the same, Hash with
194
+ # String keys mapping to the same). Backends call `.inspect`
195
+ # to emit the literal text.
196
+ ModuleConstant = Data.define(:name, :constant_name, :value) do
197
+ include Node
198
+ end
199
+
200
+ # A className attribute value that resolves to a known cva binding's
201
+ # call shape — `className={cn(buttonVariants({ variant, size }),
202
+ # className)}` or the no-cn direct form `className={buttonVariants({
203
+ # variant })}`. The translator recognizes the AST shape at lowering
204
+ # so the backend never has to regex over verbatim JS source; this
205
+ # naturally handles literal-pinned axes, reversed arg order, and
206
+ # the cn-vs-no-cn forms.
207
+ #
208
+ # binding_name : String — referenced cva binding's const name.
209
+ # axes : [CvaAxisPair] — preserved in JSX source order.
210
+ # class_arg : Interpolation | nil — the optional trailing className
211
+ # arg from `cn(<cvaCall>, <classArg>)`. Nil for the
212
+ # single-arg `cn(<cvaCall>)` and the no-cn direct
213
+ # forms.
214
+ CvaCallSite = Data.define(:binding_name, :axes, :class_arg) do
215
+ include Node
216
+ end
217
+
218
+ # One axis-value pair inside a cva call's options object. The
219
+ # discriminator `kind` tells the backend how to render the value:
220
+ #
221
+ # :prop_ref — JS identifier referencing a prop (`{ variant }` or
222
+ # `{ variant: someProp }`). Backends render as
223
+ # `@snake_case` against the receiving Phlex component.
224
+ # :literal_string — `{ variant: "default" }` — the literal value
225
+ # is the variant-table key.
226
+ # :literal_other — `{ size: 42 }` / `{ active: true }` — Ruby
227
+ # literal passed through to the bracket key.
228
+ # :literal_nil — `{ variant: null }` or `undefined`.
229
+ CvaAxisPair = Data.define(:axis, :kind, :source) do
230
+ include Node
231
+ end
232
+
88
233
  # A hook invocation detected in the component body. Covers React's
89
234
  # built-in hooks plus framework hooks we recognize (Apollo's `useQuery`/
90
235
  # `useMutation`/etc., Next.js's `useRouter`/`usePathname`/etc.).
@@ -315,7 +460,17 @@ module JsxRosetta
315
460
  # `name != original_name`, backends emit a collision
316
461
  # marker comment in the generated controller JS so the
317
462
  # reviewer can see the silent rename.
318
- StimulusMethod = Data.define(:name, :body_source, :original_name) do
463
+ # params : [String | nil] — original arrow/function parameter
464
+ # names (e.g. `["e"]`, `["event"]`, or `[]` for
465
+ # `() => …`). A `nil` entry signals a non-identifier
466
+ # param (destructured `({target}) =>`, rest `(...args) =>`)
467
+ # that the pasted body can't safely reference — backends
468
+ # bail to the TODO form when any entry is nil.
469
+ # body_is_block : Boolean — true when the arrow body was a BlockStatement
470
+ # (`(e) => { … }`), false for an expression-form body
471
+ # (`(e) => doX(e)`). Backends use this to decide whether
472
+ # to strip outer braces when pasting verbatim.
473
+ StimulusMethod = Data.define(:name, :body_source, :original_name, :params, :body_is_block) do
319
474
  include Node
320
475
  end
321
476
 
@@ -369,6 +524,25 @@ module JsxRosetta
369
524
  include Node
370
525
  end
371
526
 
527
+ # An arrow/function expression whose body isn't JSX — typically a
528
+ # JSX event handler on a PascalCase component (`onClick={() => doX()}`)
529
+ # or a non-JSX-returning callback prop. We can't translate arbitrary
530
+ # JS bodies to Ruby procedurally, so backends emit a stub method on
531
+ # the class with the verbatim JS body preserved as a TODO comment;
532
+ # the kwarg at the use site becomes `method(:method_name)` so the
533
+ # receiving component has a callable reference and the structural
534
+ # attachment is preserved end-to-end.
535
+ #
536
+ # params : [String]
537
+ # body_source : String — verbatim JS of the arrow/function body
538
+ # (the part after `=>` for arrows, or the full block
539
+ # body for FunctionExpressions). Preserved as a
540
+ # comment in the emitted method so the reviewer can
541
+ # translate the behavior to Ruby.
542
+ EventHandler = Data.define(:params, :body_source) do
543
+ include Node
544
+ end
545
+
372
546
  # A render-prop child: `<Form.List>{(fields) => <div>{fields}</div>}</Form.List>`.
373
547
  # Backends emit this as a Ruby block on the render call, with the params
374
548
  # bound as block arguments. Distinct from Loop (which iterates an
@@ -380,6 +554,16 @@ module JsxRosetta
380
554
  include Node
381
555
  end
382
556
 
557
+ # The Next.js `_app.tsx` content slot — what JSX writes as
558
+ # `<Component {...pageProps} />`. Lowered to a distinguished node so
559
+ # the Phlex backend can emit `yield` (the Rails layout convention) in
560
+ # its place when the host file is a layout. Outside the layout
561
+ # rendering path the node still emits a yield, since the only way to
562
+ # produce one in our IR is to hit this exact source shape.
563
+ LayoutYield = Data.define do
564
+ include Node
565
+ end
566
+
383
567
  # A locally-declared JSX-returning arrow that's invoked inside the
384
568
  # render body: `const renderHeader = (count) => <h1>{count}</h1>;
385
569
  # ... {renderHeader(headerCount)}`. Backends emit one private method
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ir/types"
4
+ require_relative "ir/radix_registry"
4
5
  require_relative "ir/lowering"
5
6
 
6
7
  module JsxRosetta
7
8
  module IR
8
- def self.lower(ast_file, source:)
9
- Lowering.lower(ast_file, source: source)
9
+ def self.lower(ast_file, source:, keep_slot: false)
10
+ Lowering.lower(ast_file, source: source, keep_slot: keep_slot)
10
11
  end
11
12
 
12
- def self.lower_all(ast_file, source:)
13
- Lowering.lower_all(ast_file, source: source)
13
+ def self.lower_all(ast_file, source:, keep_slot: false)
14
+ Lowering.lower_all(ast_file, source: source, keep_slot: keep_slot)
14
15
  end
15
16
  end
16
17
  end