diff --git a/.rubocop.yml b/.rubocop.yml
index d20d73e..1f1a4f3 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -27,7 +27,7 @@ Metrics/AbcSize:
Max: 27
Metrics/ClassLength:
- Max: 1200
+ Max: 1400
Metrics/CyclomaticComplexity:
Max: 12
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 326f683..0eca350 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,129 @@
# Changelog
+## [Unreleased]
+
+### Added — translate cva() variant builders into Ruby constants
+
+- **`const fooVariants = cva(base, { variants, defaultVariants })`** —
+ the dominant variant-builder pattern in shadcn/ui-shaped components —
+ used to land as a 40-line TODO comment block above the class, and
+ its use-site `cn(fooVariants({ variant }), className)` emitted the
+ verbatim JS as a literal string into the `class:` attribute. Both
+ paths are now translated end-to-end.
+ - Lowering recognizes the `cva(, { variants, defaultVariants,
+ compoundVariants })` call shape and records it as a new
+ `IR::CvaBinding` node (parallel to `IR::LocalBinding`).
+ - Phlex backend renders each `CvaBinding` as three module-level
+ Ruby constants alongside the class — `FOO_BASE_CLASS`,
+ `FOO_VARIANT_CLASSES` (a frozen hash of axis → option → class
+ string), and `FOO_DEFAULT_VARIANTS`.
+ - The use-site call `cn(fooVariants({ variant, size }), className)`
+ in a `className={...}` attribute now emits a Ruby string
+ interpolation against those constants:
+ `"#{FOO_BASE_CLASS} #{FOO_VARIANT_CLASSES["variant"][@variant]}
+ #{FOO_VARIANT_CLASSES["size"][@size]} #{@class_name}"`.
+ - `render_initializer` now uses `defaultVariants` as the Ruby kwarg
+ default for any prop name matching a cva axis (so `variant:
+ "default"` flows from the cva binding even though the React
+ function signature took the prop undefaulted).
+- `compoundVariants` (the rule-of-rules cva feature) is not yet
+ translated — the verbatim JS source surfaces as a `# TODO:
+ compoundVariants from FOO aren't translated` comment alongside the
+ emitted constants so the reviewer can hand-port the rules.
+- Other module-level constants (non-cva) still take the existing
+ "# TODO: module-level constants" pre-class comment path.
+
+### Added — drop Slot.Root branch from polymorphic asChild tags
+
+- **shadcn's `` no longer NameErrors at render.** Components
+ using `const Comp = asChild ? Slot : "div"` (or `Slot.Root`) routed
+ the truthy branch through Radix's `Slot`, which has no Ruby class on
+ the Phlex side. Lowering now detects this Slot-vs-tag conditional
+ (Slot rooted at a `radix-ui`/`@radix-ui/react-*` import) and emits
+ only the non-Slot branch — no conditional, no `as_child` kwarg,
+ just the underlying tag.
+- New CLI flag `--keep-slot` and `JsxRosetta.translate(..., keep_slot:
+ true)` preserves the full polymorphic conditional for consumers
+ that shim `Components::Slot::Root` themselves.
+- Detection respects the import source: a project-local `import
+ { Slot } from "./my-slot"` is untouched.
+
+### Added — map known Radix primitive tags to underlying HTML elements
+
+- **``, ``, etc.**
+ used to lower as `ComponentInvocation`s, producing
+ `render SeparatorPrimitive::Root.new(...)` — undefined constants,
+ NameError on render. Now: when the import source matches a Radix
+ package (`radix-ui`, `@radix-ui/react-*`) and the
+ `(LocalName, Member)` pair is in a small registry, lower as an
+ HTML Element with the underlying tag and any always-applied
+ attributes (`role`, `type`, etc.). Consumer attrs win on collision
+ with the registry defaults.
+- Registry lives at `lib/jsx_rosetta/ir/radix_registry.rb`. Covers
+ Separator / Label / Avatar (Root/Image/Fallback) / Switch
+ (Root/Thumb) / Progress (Root/Indicator) / AspectRatio (Root) /
+ ScrollArea (Root/Viewport). Unknown primitives fall through to
+ the existing `ComponentInvocation` / TODO behavior.
+- Works with both named-aliased imports
+ (`import { Separator as SeparatorPrimitive } from "radix-ui"`)
+ and namespace imports
+ (`import * as AvatarPrimitive from "@radix-ui/react-avatar"`).
+
+### Added — auto-emit Lucide icon shims as a translation sidecar
+
+- **`import { ChevronRight } from "lucide-react"` now lands a working
+ `ChevronRight` Phlex class.** Previously the translator carried the
+ React import through verbatim, so the generated component contained
+ `render ChevronRight.new(...)` referencing a non-existent Ruby class
+ — NameError at render time. The Phlex backend now detects Lucide
+ imports (`lucide-react`, `lucide`) that are actually used as JSX
+ component tags and emits two kinds of sidecar files:
+ - `lucide_icon.rb` — a shared `LucideIcon < Phlex::HTML` base.
+ - `.rb` — one file per referenced icon, defining a subclass
+ that renders the vendored SVG path data inline. One file per
+ icon means Zeitwerk autoloads each cleanly under the consumer's
+ `app/components/` (or wherever the output directory points).
+- Vendored path data lives at `lib/jsx_rosetta/icons/lucide.json`
+ (the ~35 icons most commonly imported by shadcn/ui sources). Icons
+ not in the vendored set emit a class whose `inner_svg` is empty
+ with a TODO comment pointing at the lucide.json refresh path.
+- Tolerates both canonical (`ChevronRight`) and legacy `*Icon`
+ (`ChevronRightIcon`) names — same path data either way.
+- `--phlex-namespace=Components` wraps every sidecar in the same
+ module as the main translation, so the consumer's autoloader
+ config doesn't need a special case.
+
+### Added — translate Stimulus controller bodies when safe
+
+- **Paste JSX handler bodies into the generated `_controller.js`.**
+ Auto-generated Stimulus controllers used to leave the handler body
+ as a TODO comment with the verbatim source above an empty
+ `clickHandler(event) { // ... }` stub. For DOM-driven handlers (the
+ common shape in shadcn-style UI), the JSX body is *already valid JS*
+ — we now paste it directly into the method, using the original
+ arrow's parameter name so identifier references in the body still
+ resolve.
+ - `IR::StimulusMethod` gains a `params:` field — the original arrow
+ parameter names — used as the Stimulus method's parameter signature.
+ - New `safe_to_paste_handler?` heuristic on the Phlex backend bails
+ out (falls back to the previous TODO behavior) when the body
+ references React state setters (`setX(`), React hooks (`useX(`),
+ or is just an identifier-bound `// originally bound to: …` comment.
+ - Collision markers are preserved across the new path.
+
+### Fixed — silent children loss on self-closing-with-spread tags
+
+- **Auto-yield on blockless spread-children tags.** The shadcn idiom
+ `` (self-closing JSX whose rest-spread carries React
+ `children`) translated to a Phlex `tag(..., **(@props || {}))` call
+ with no block — so callers' `Component.new { ... }` blocks were
+ silently dropped at render. Now: when an Element or
+ ComponentInvocation has no explicit IR children, a `SpreadAttribute`,
+ and a non-void tag, emit `tag(...) do; yield if block_given?; end`.
+ The `block_given?` guard preserves the no-block use case. Void HTML
+ elements (input, img, br, …) still emit blockless. Explicit-children
+ paths and `RenderProp` flows are untouched.
+
## [0.5.1] - 2026-05-11
A correctness pass on the v0.5.0 Phlex output. A random-sample review
diff --git a/lib/jsx_rosetta.rb b/lib/jsx_rosetta.rb
index ee7f808..5ac360b 100644
--- a/lib/jsx_rosetta.rb
+++ b/lib/jsx_rosetta.rb
@@ -9,15 +9,15 @@ def self.parse(source, typescript: false, source_filename: nil)
Parser.new.parse(source, typescript: typescript, source_filename: source_filename)
end
- def self.lower(source, typescript: false, source_filename: nil)
+ def self.lower(source, typescript: false, source_filename: nil, keep_slot: false)
ast = parse(source, typescript: typescript, source_filename: source_filename)
- IR.lower(ast, source: source)
+ IR.lower(ast, source: source, keep_slot: keep_slot)
end
def self.translate(source, backend: :view_component, backend_options: {},
- typescript: false, source_filename: nil, **legacy_options)
+ typescript: false, source_filename: nil, keep_slot: false, **legacy_options)
ast = parse(source, typescript: typescript, source_filename: source_filename)
- components = IR.lower_all(ast, source: source)
+ components = IR.lower_all(ast, source: source, keep_slot: keep_slot)
backend_instance = backend_for(backend, **legacy_options, **backend_options)
components.flat_map { |component| backend_instance.emit(component, source_filename: source_filename) }
end
@@ -38,6 +38,7 @@ def self.backend_for(name, **options)
require_relative "jsx_rosetta/parser"
require_relative "jsx_rosetta/ir"
require_relative "jsx_rosetta/routes"
+require_relative "jsx_rosetta/icons"
require_relative "jsx_rosetta/pages_routing"
require_relative "jsx_rosetta/backend"
require_relative "jsx_rosetta/cli"
diff --git a/lib/jsx_rosetta/backend/phlex.rb b/lib/jsx_rosetta/backend/phlex.rb
index 7837f6f..40b1b4a 100644
--- a/lib/jsx_rosetta/backend/phlex.rb
+++ b/lib/jsx_rosetta/backend/phlex.rb
@@ -89,6 +89,7 @@ def initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil)
end
def emit(component, source_filename: nil)
+ @current_component = component
translator = build_translator(component)
@stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
@lambda_methods = []
@@ -104,7 +105,13 @@ def emit(component, source_filename: nil)
contents: render_stimulus_controller_js(component)
)
end
+ files.concat(lucide_icon_files(component))
files
+ ensure
+ # Drop the per-emit IR reference so a long-running emitter
+ # instance doesn't pin the entire component tree until the
+ # next emit() call.
+ @current_component = nil
end
# When a source file lowers to multiple sibling components, lower_all
@@ -258,23 +265,80 @@ def render_ruby_class(component, translator)
end
# Top-level `const`/`let` declarations outside the component
- # function — captured at lowering time and surfaced here as a TODO
- # comment block above the class definition. We don't try to
- # translate the JS; the human reviewer either copies the value as
- # a Ruby constant or moves it to a Rails initializer.
+ # function — captured at lowering time. Cva-shaped bindings get
+ # emitted as real Ruby constants (FOO_BASE_CLASS, etc.); generic
+ # local bindings still surface as a TODO comment block.
def render_module_bindings_prefix(component)
return "" if component.module_bindings.empty?
- # Sibling components from the same source file share the same
- # module_bindings list; emit the prefix only on the first sibling
- # so a 40-line GraphQL TODO doesn't appear in every sibling file.
- return "" unless @emit_module_prefix
+ cva_bindings, other_bindings = component.module_bindings.partition { |b| b.is_a?(IR::CvaBinding) }
+ sections = []
+ # cva constants are *referenced* by every sibling's class body via
+ # FOO_BASE_CLASS / FOO_VARIANT_CLASSES — they have to land in every
+ # sibling's file or non-first siblings NameError at render. The
+ # non-cva TODO block is informational only, so it suppresses on
+ # later siblings to avoid duplicating 40-line GraphQL blocks.
+ sections << render_cva_constants(cva_bindings) unless cva_bindings.empty?
+ emit_todo_block = @emit_module_prefix && !other_bindings.empty?
+ sections << render_module_local_bindings_todo(other_bindings) if emit_todo_block
+ sections.compact.join("\n")
+ end
+
+ # Emit one cva binding as a triplet of Ruby constants —
+ # FOO_BASE_CLASS, FOO_VARIANT_CLASSES, FOO_DEFAULT_VARIANTS — that
+ # the call-site interpolation in the class body references.
+ def render_cva_constants(cva_bindings)
+ lines = []
+ cva_bindings.each do |cva|
+ prefix = cva_constant_prefix(cva.name)
+ lines << "#{prefix}_BASE_CLASS = #{cva.base_class.inspect}"
+ lines << "#{prefix}_VARIANT_CLASSES = #{format_variants_literal(cva.variants)}.freeze"
+ unless cva.default_variants.empty?
+ lines << "#{prefix}_DEFAULT_VARIANTS = #{cva.default_variants.inspect}.freeze"
+ end
+ if cva.compound_source
+ lines << "# TODO: compoundVariants from #{cva.name} aren't translated — port by hand:"
+ lines.concat(comment_lines(cva.compound_source))
+ end
+ lines << ""
+ end
+ "#{lines.join("\n").rstrip}\n"
+ end
+
+ # Non-cva module bindings — the original Gap E pre-class TODO block.
+ # Distinct from the body-level `render_local_bindings_todo` further
+ # below in this file.
+ def render_module_local_bindings_todo(bindings)
lines = ["# TODO: module-level constants — translate to Ruby constants " \
"or move to a Rails initializer:"]
- component.module_bindings.each { |b| lines.concat(comment_lines(b.source)) }
+ bindings.each { |b| lines.concat(comment_lines(b.source)) }
"#{lines.join("\n")}\n"
end
+ # buttonVariants → BUTTON, alertVariants → ALERT. The degenerate
+ # name `"Variants"` would strip to `""` (a Ruby SyntaxError when
+ # used as a `_BASE_CLASS` prefix) — fall back to the raw name in
+ # that case. Two cva bindings whose names collapse to the same
+ # prefix (`fooVariant` and `fooVariants` → `FOO`) keep both forms
+ # disambiguated by upcasing the unstripped name as the fallback.
+ def cva_constant_prefix(cva_name)
+ stripped = cva_name.sub(/Variants?\z/, "")
+ base = stripped.empty? ? cva_name : stripped
+ AST::Inflector.underscore(base).upcase
+ end
+
+ def format_variants_literal(variants)
+ return "{}" if variants.empty?
+
+ lines = ["{"]
+ variants.each do |axis, options|
+ opts_pairs = options.map { |k, v| "#{k.inspect} => #{v.inspect}" }.join(", ")
+ lines << " #{axis.inspect} => { #{opts_pairs} },"
+ end
+ lines << "}"
+ lines.join("\n")
+ end
+
def wrap_in_namespace(body)
return "# frozen_string_literal: true\n\n#{body}" unless @namespace
@@ -425,6 +489,15 @@ def ruby_kwarg(prop, translator)
end
def ruby_default_for(prop, translator)
+ # Use the cva defaultVariants entry as the initializer default when
+ # the prop name matches a cva axis and the JSX didn't already
+ # specify its own default. So `variant: 'default'` flows from the
+ # cva binding's defaultVariants, even though the React function
+ # signature took it as an undefaulted prop.
+ if prop.default.nil? && (cva_default = cva_axis_default_for(prop.name))
+ return cva_default.inspect
+ end
+
return "nil" if prop.default.nil?
case prop.default
@@ -444,6 +517,21 @@ def ruby_default_for(prop, translator)
end
end
+ # Look up `prop_name` (e.g. "variant") across every CvaBinding on the
+ # current component. Returns the cva default value (e.g. "default") or
+ # nil when no cva binding declares that axis with a default.
+ def cva_axis_default_for(prop_name)
+ return nil unless @current_component
+
+ @current_component.module_bindings.each do |b|
+ next unless b.is_a?(IR::CvaBinding)
+ next unless b.default_variants.key?(prop_name)
+
+ return b.default_variants[prop_name]
+ end
+ nil
+ end
+
def render_view_template(component, translator)
body = render_template_body(component, translator)
prefix = render_template_prefix(component)
@@ -552,15 +640,32 @@ def render_element(element, translator, indent:)
attrs_source = format_attributes(element.attributes, translator,
context: :html, tag: element.tag, todos: todos, indent: indent)
method_call = "#{element.tag}#{attrs_source}"
+ body = element_body(element, method_call, translator, indent)
+ prepend_attribute_todos(todos, indent, body)
+ end
- body = if VOID_ELEMENTS.include?(element.tag) || element.children.empty?
- "#{spaces(indent)}#{method_call}"
- else
- inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
- "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
- end
+ # Decide whether the HTML tag is blockless, yield-only (auto-yield for
+ # self-closing-with-spread), or full do/end (explicit children).
+ def element_body(element, method_call, translator, indent)
+ return "#{spaces(indent)}#{method_call}" if blockless_element?(element)
- prepend_attribute_todos(todos, indent, body)
+ if element.children.empty?
+ # `` — the rest-spread carries React `children`,
+ # but JSX self-closes so there are no explicit IR children. Yield
+ # to the Phlex caller's block so `Component.new { ... }` nesting
+ # actually renders; guard with `block_given?` so callers who pass
+ # no block don't blow up.
+ yield_only_block(method_call, indent)
+ else
+ inner = element.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
+ "#{spaces(indent)}#{method_call} do\n#{inner}\n#{spaces(indent)}end"
+ end
+ end
+
+ def blockless_element?(element)
+ return true if VOID_ELEMENTS.include?(element.tag)
+
+ element.children.empty? && !spreads_children?(element)
end
def render_component_invocation(invocation, translator, indent:)
@@ -569,18 +674,44 @@ def render_component_invocation(invocation, translator, indent:)
todos: todos, indent: indent, tag: invocation.name)
class_ref = component_class_reference(invocation.name)
new_call = kwargs.empty? ? "#{class_ref}.new" : "#{class_ref}.new(#{kwargs})"
+ body = component_invocation_body(invocation, new_call, translator, indent)
+ prepend_attribute_todos(todos, indent, body)
+ end
+ def component_invocation_body(invocation, new_call, translator, indent)
render_prop = invocation.children.find { |c| c.is_a?(IR::RenderProp) }
- body = if render_prop
- render_with_render_prop(new_call, render_prop, translator, indent)
- elsif invocation.children.empty?
- "#{spaces(indent)}render #{new_call}"
- else
- inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
- "#{spaces(indent)}render #{new_call} do\n#{inner}\n#{spaces(indent)}end"
- end
+ return render_with_render_prop(new_call, render_prop, translator, indent) if render_prop
- prepend_attribute_todos(todos, indent, body)
+ call = "render #{new_call}"
+ return "#{spaces(indent)}#{call}" if blockless_invocation?(invocation)
+
+ if invocation.children.empty?
+ # `` — same idiom as element_body. The
+ # spread carries `children`; yield to the caller's block.
+ yield_only_block(call, indent)
+ else
+ inner = invocation.children.map { |c| render_ir_node(c, translator, indent: indent + 2) }.join("\n")
+ "#{spaces(indent)}#{call} do\n#{inner}\n#{spaces(indent)}end"
+ end
+ end
+
+ def blockless_invocation?(invocation)
+ invocation.children.empty? && !spreads_children?(invocation)
+ end
+
+ def yield_only_block(call, indent)
+ outer = spaces(indent)
+ inner = spaces(indent + 2)
+ "#{outer}#{call} do\n#{inner}yield if block_given?\n#{outer}end"
+ end
+
+ # Does this Element/ComponentInvocation carry a JSX rest-spread
+ # (`{...props}`) that may transport React `children` we can't see in
+ # the IR? Used to decide whether a self-closing JSX tag should still
+ # yield to the Phlex caller's block.
+ def spreads_children?(node)
+ attrs = node.respond_to?(:attributes) ? node.attributes : node.props
+ attrs.any?(IR::SpreadAttribute)
end
# Emit a render-prop child as a Ruby block on the parent `render` call.
@@ -868,6 +999,7 @@ def build_attribute_list(parts, spreads, translator)
# element already describes what was lost.
def phlex_attribute_part(attribute, translator, context:, todos:, indent: 0, tag: nil)
case attribute
+ when IR::CvaCallSite then cva_call_site_attribute_part(attribute, translator)
when IR::StyleBinding then class_attribute_part(attribute.expression, translator)
when IR::ClassList then { string_key: false, source: "class: #{class_list_to_ruby_string(attribute, translator)}" }
when IR::Style then style_attribute_part(attribute, translator, todos: todos)
@@ -908,6 +1040,54 @@ def class_attribute_part(expression, translator)
{ string_key: false, source: "class: #{ruby}" }
end
+ # Render an IR::CvaCallSite as the `class:` kwarg. Always produces
+ # a single Ruby string-interpolation literal that references the
+ # backend-emitted constants for the cva binding. The detection
+ # happened at lowering time (in `try_lower_cva_call_site`), so the
+ # node already carries the binding name, axes, and optional
+ # class_arg — no regex over verbatim JS source here.
+ def cva_call_site_attribute_part(node, translator)
+ cva = find_cva_binding(node.binding_name)
+ return { string_key: false, source: "class: nil" } unless cva
+
+ parts = cva_call_site_parts(node, cva, translator)
+ { string_key: false, source: %(class: "#{parts.join(" ")}") }
+ end
+
+ def find_cva_binding(binding_name)
+ @current_component&.module_bindings&.find do |b|
+ b.is_a?(IR::CvaBinding) && b.name == binding_name
+ end
+ end
+
+ def cva_call_site_parts(node, cva, translator)
+ prefix = cva_constant_prefix(cva.name)
+ parts = ["\#{#{prefix}_BASE_CLASS}"]
+ node.axes.each do |pair|
+ next unless cva.variants.key?(pair.axis)
+
+ parts << "\#{#{prefix}_VARIANT_CLASSES[#{pair.axis.inspect}][#{cva_axis_ruby_value(pair)}]}"
+ end
+ parts << "\#{#{cva_class_arg_ruby(node.class_arg, translator)}}" if node.class_arg
+ parts
+ end
+
+ def cva_class_arg_ruby(class_arg, translator)
+ translated = translator.translate(class_arg.expression)
+ return translated.ruby if translated
+
+ "@#{AST::Inflector.underscore(class_arg.expression)}"
+ end
+
+ def cva_axis_ruby_value(pair)
+ case pair.kind
+ when :literal_string then pair.source.inspect
+ when :literal_other then pair.source
+ when :literal_nil then "nil"
+ when :prop_ref then "@#{AST::Inflector.underscore(pair.source)}"
+ end
+ end
+
# Map a JSX attribute name to its Ruby kwarg form. For HTML element
# attrs (`context: :html`), only hyphens convert to underscores —
# camelCase (`viewBox`, `preserveAspectRatio`) preserves verbatim
@@ -1365,15 +1545,77 @@ def render_stimulus_controller_js(component)
"#{lines.join("\n")}\n"
end
+ # Stimulus method emission. JSX inline arrow bodies are valid JS already;
+ # for DOM-driven handlers (the common shadcn shape) we just paste the body
+ # verbatim into the method, naming the JS parameter to match the original
+ # arrow's parameter so identifier references in the body still resolve.
+ #
+ # When the body references React-state setters or hooks we can't run in
+ # the browser, fall back to the previous TODO-comment behavior so the
+ # human reviewer ports it by hand.
def stimulus_method_lines(method)
- body_lines = method.body_source.strip.split("\n")
- commented = body_lines.map { |line| " // #{line}" }
- header = [" // TODO: translate from the original JSX handler:"]
+ lines = []
if method.name != method.original_name
- header.unshift(" // NOTE: method renamed from #{method.original_name.inspect} " \
- "to avoid collision with an earlier handler")
+ lines << " // NOTE: method renamed from #{method.original_name.inspect} " \
+ "to avoid collision with an earlier handler"
end
- header + commented + [
+
+ if safe_to_paste_handler?(method)
+ lines.concat(pasted_handler_lines(method))
+ else
+ lines.concat(todo_handler_lines(method))
+ end
+
+ lines
+ end
+
+ # Heuristic for "this JS body is safe to drop into a Stimulus method
+ # verbatim." Bails out when:
+ # - any arrow param wasn't a plain Identifier (destructured / rest) —
+ # pasting would reference an undefined local at runtime;
+ # - the body is the identifier-bound pseudo-comment we synthesize
+ # when an `onClick={onChange}` reference resolved to no arrow;
+ # - the body calls a top-level React state setter (`setX(`) or hook
+ # (`useX(`) — the negative lookbehind on `[.\w]` makes sure DOM
+ # methods like `e.setAttribute(` / `el.setPointerCapture(` don't
+ # trip the guard.
+ def safe_to_paste_handler?(method)
+ return false unless method.params.all?
+
+ body = method.body_source
+ return false if body.lstrip.start_with?("//")
+ return false if body =~ /(? { … }`), not an expression-form body like `(e) => ({ x: 1 })`
+ # which Babel hands back as `{ x: 1 }` already — stripping would yield
+ # `x: 1`, a JS label statement (no-op).
+ def pasted_handler_lines(method)
+ param = method.params.first || "event"
+ body = method.body_source.strip
+ body = body[1..-2].strip if method.body_is_block
+ inner_lines = body.split("\n").map { |l| " #{l.lstrip}" }
+ [
+ " #{method.name}(#{param}) {",
+ *inner_lines,
+ " }"
+ ]
+ end
+
+ # Fallback for handlers that aren't safe to paste verbatim — preserve
+ # the original body as a comment and emit an empty method body.
+ def todo_handler_lines(method)
+ body_lines = method.body_source.strip.split("\n")
+ commented = body_lines.map { |line| " // #{line}" }
+ [
+ " // TODO: translate from the original JSX handler:",
+ *commented,
" #{method.name}(event) {",
" // ...",
" }"
@@ -1383,6 +1625,159 @@ def stimulus_method_lines(method)
def spaces(count)
" " * count
end
+
+ # ----------------------------------------------------------------------
+ # Lucide icon sidecars
+ # ----------------------------------------------------------------------
+ #
+ # When a translated component references `` after
+ # `import { ChevronRight } from "lucide-react"`, the consumer ends up
+ # with a `render ChevronRight.new(...)` call against a Ruby class that
+ # doesn't exist. To close that NameError, we emit one Phlex class per
+ # referenced icon as a sidecar file (`chevron_right.rb` etc.) plus a
+ # shared `lucide_icon.rb` base. Each file follows Zeitwerk's
+ # one-constant-per-file convention so it drops straight into
+ # `app/components/` without further configuration.
+
+ def lucide_icon_files(component)
+ usages = referenced_lucide_icons(component)
+ return [] if usages.empty?
+
+ @seen_lucide_icons ||= Set.new
+ files = []
+ unless @seen_lucide_icons.include?(:base)
+ files << File.new(path: "lucide_icon.rb", contents: render_lucide_icon_base_rb)
+ @seen_lucide_icons << :base
+ end
+
+ usages.sort_by { |u| u[:local_name] }.each do |usage|
+ next if @seen_lucide_icons.include?(usage[:local_name])
+
+ path = "#{AST::Inflector.underscore(usage[:local_name])}.rb"
+ files << File.new(
+ path: path,
+ contents: render_lucide_icon_class_rb(usage[:local_name], canonical: usage[:canonical_name])
+ )
+ @seen_lucide_icons << usage[:local_name]
+ end
+ files
+ end
+
+ # Lucide imports used as JSX tags, each carrying both the local
+ # binding (what the emitted file/class is named after) and the
+ # canonical export (used to look up the vendored SVG path data).
+ # The two diverge under aliased imports — `import { ChevronRight as
+ # CR }` should still resolve `ChevronRight`'s SVG while emitting a
+ # `cr.rb` defining `class CR < LucideIcon`. Names not in the
+ # vendored data still emit, but the class falls back to a TODO
+ # `inner_svg` instead of NameError-ing at render.
+ def referenced_lucide_icons(component)
+ lucide_by_local = component.module_imports
+ .select { |i| Icons.lucide_source?(i.source) }
+ .to_h { |i| [i.name, i.imported_name || i.name] }
+ return [] if lucide_by_local.empty?
+
+ invocations = Set.new
+ collect_component_invocations(component.body, invocations)
+ invocations.intersection(lucide_by_local.keys).map do |local_name|
+ { local_name: local_name, canonical_name: lucide_by_local.fetch(local_name) }
+ end
+ end
+
+ # Walk an IR subtree and collect every ComponentInvocation tag name we
+ # see. Recurses through any field that holds an IR node or array of
+ # nodes. Conservative — visits every container; cost is proportional
+ # to IR size.
+ def collect_component_invocations(node, acc)
+ return if node.nil?
+
+ acc << node.name if node.is_a?(IR::ComponentInvocation)
+ return unless node.respond_to?(:members)
+
+ node.members.each do |field|
+ value = node.public_send(field)
+ if value.is_a?(Array)
+ value.each { |child| collect_component_invocations(child, acc) }
+ elsif value.respond_to?(:members)
+ collect_component_invocations(value, acc)
+ end
+ end
+ end
+
+ def render_lucide_icon_base_rb
+ mod_open, mod_close, indent = lucide_module_wrap
+ <<~RUBY
+ # frozen_string_literal: true
+
+ # Generated by jsx_rosetta. Don't edit by hand — re-translate the source
+ # to refresh. Shared SVG-wrapper base for Lucide icon shims; one subclass
+ # per icon (e.g. ChevronRight, Search) sits alongside this file.
+ #{mod_open}#{indent}class LucideIcon < Phlex::HTML
+ #{indent} BASE_ATTRS = %{xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"}.freeze
+
+ #{indent} def initialize(class_name: nil, **)
+ #{indent} super()
+ #{indent} @class_name = class_name
+ #{indent} end
+
+ #{indent} def view_template
+ #{indent} cls = @class_name.to_s.empty? ? "" : %{ class="\#{@class_name}"}
+ #{indent} raw safe("")
+ #{indent} end
+
+ #{indent} def inner_svg
+ #{indent} raise NotImplementedError
+ #{indent} end
+ #{indent}end
+ #{mod_close}
+ RUBY
+ end
+
+ # `name` is the local binding (the Ruby class name we emit), `canonical`
+ # is the original Lucide export — they diverge under aliased imports.
+ # SVG lookup keys on `canonical`; the class definition keys on `name`.
+ def render_lucide_icon_class_rb(name, canonical: name)
+ inner = Icons.lucide_for(canonical)
+ mod_open, mod_close, indent = lucide_module_wrap
+ body = if inner
+ "#{indent} def inner_svg = #{format_svg_string(inner)}"
+ else
+ "#{indent} # TODO: #{canonical.inspect} isn't in jsx_rosetta's vendored lucide.json.\n" \
+ "#{indent} # Fill in inner_svg with the SVG path data from lucide.dev, or refresh\n" \
+ "#{indent} # `lib/jsx_rosetta/icons/lucide.json`.\n" \
+ "#{indent} def inner_svg = \"\""
+ end
+ <<~RUBY
+ # frozen_string_literal: true
+
+ # Generated by jsx_rosetta from a "lucide-react" import. Refresh with
+ # a re-translate; don't edit by hand.
+ #{mod_open}#{indent}class #{name} < LucideIcon
+ #{body}
+ #{indent}end
+ #{mod_close}
+ RUBY
+ end
+
+ # Match the namespace wrapping we use for the main component class.
+ # Returns ["module Foo\n", "end\n", " "] when a namespace is set,
+ # or ["", "", ""] for the top-level case.
+ def lucide_module_wrap
+ return ["", "", ""] unless @namespace
+
+ ["module #{@namespace}\n", "end\n", " "]
+ end
+
+ # Wrap an SVG inner-markup snippet in a Ruby string literal that
+ # preserves its embedded double quotes. Single-quoted when the markup
+ # contains no single quotes (most cases), otherwise %q delimited.
+ def format_svg_string(svg)
+ if svg.include?("'")
+ %(%q{#{svg}})
+ else
+ "'#{svg}'"
+ end
+ end
end
end
end
diff --git a/lib/jsx_rosetta/cli.rb b/lib/jsx_rosetta/cli.rb
index 62b5047..55025e7 100644
--- a/lib/jsx_rosetta/cli.rb
+++ b/lib/jsx_rosetta/cli.rb
@@ -115,7 +115,8 @@ def run_translate
backend: backend,
backend_options: backend_options,
typescript: typescript,
- source_filename: input_path
+ source_filename: input_path,
+ keep_slot: options[:keep_slot] || false
)
write_emitted_files(files, out_dir)
@@ -298,6 +299,7 @@ def consume_translate_option?(arg, options)
when "--tsx", "--typescript" then options[:tsx] = true
when "--as" then options[:as] = @argv.shift
when /\A--as=(.+)\z/ then options[:as] = ::Regexp.last_match(1)
+ when "--keep-slot" then options[:keep_slot] = true
else return false
end
true
diff --git a/lib/jsx_rosetta/icons.rb b/lib/jsx_rosetta/icons.rb
new file mode 100644
index 0000000..77d3cc3
--- /dev/null
+++ b/lib/jsx_rosetta/icons.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "json"
+
+module JsxRosetta
+ # Vendored SVG path data for the most common icons referenced by shadcn-shaped
+ # JSX. Backends look up icons here to emit standalone Phlex classes alongside
+ # the translated component .rb file, so consumers don't have to write icon
+ # shims by hand.
+ #
+ # Refresh `lib/jsx_rosetta/icons/lucide.json` from the upstream Lucide package
+ # if you need names that aren't here yet — but verify the path data, since
+ # Lucide occasionally tweaks icon shapes between releases.
+ module Icons
+ LUCIDE_DATA_PATH = File.expand_path("icons/lucide.json", __dir__)
+
+ # Source specifiers that import from a Lucide-shaped icon package.
+ # Includes both the React-flavored `lucide-react` and the bare `lucide`
+ # package (some shadcn forks use it directly).
+ LUCIDE_SOURCE_PATTERN = /\Alucide(-react)?\z/
+
+ def self.lucide_data
+ @lucide_data ||= JSON.parse(File.read(LUCIDE_DATA_PATH))
+ end
+
+ # Look up the inner-SVG for a Lucide icon. Tolerates both canonical
+ # (`ChevronRight`) and legacy `*Icon` (`ChevronRightIcon`) names, since
+ # shadcn varies which it imports across components. Returns nil for
+ # the degenerate name `"Icon"` (and any empty result after stripping)
+ # so callers don't get a misleading data[""] miss.
+ def self.lucide_for(name)
+ name = name.to_s
+ return nil if name.empty? || name == "Icon"
+
+ data = lucide_data
+ data[name] || data[name.sub(/Icon\z/, "")]
+ end
+
+ # True iff the given `IR::ModuleImport#source` is a known Lucide package.
+ def self.lucide_source?(source)
+ LUCIDE_SOURCE_PATTERN.match?(source.to_s)
+ end
+ end
+end
diff --git a/lib/jsx_rosetta/icons/lucide.json b/lib/jsx_rosetta/icons/lucide.json
new file mode 100644
index 0000000..7474473
--- /dev/null
+++ b/lib/jsx_rosetta/icons/lucide.json
@@ -0,0 +1,37 @@
+{
+ "AlertCircle": "",
+ "AlertTriangle": "",
+ "ArrowDown": "",
+ "ArrowLeft": "",
+ "ArrowRight": "",
+ "ArrowUp": "",
+ "Bell": "",
+ "Calendar": "",
+ "Check": "",
+ "ChevronDown": "",
+ "ChevronLeft": "",
+ "ChevronRight": "",
+ "ChevronUp": "",
+ "ChevronsUpDown": "",
+ "Circle": "",
+ "Copy": "",
+ "Dot": "",
+ "Eye": "",
+ "EyeOff": "",
+ "GripVertical": "",
+ "Info": "",
+ "Loader2": "",
+ "Mail": "",
+ "Menu": "",
+ "Minus": "",
+ "MoreHorizontal": "",
+ "MoreVertical": "",
+ "PanelLeft": "",
+ "Plus": "",
+ "Search": "",
+ "Settings": "",
+ "Star": "",
+ "Trash2": "",
+ "User": "",
+ "X": ""
+}
diff --git a/lib/jsx_rosetta/ir.rb b/lib/jsx_rosetta/ir.rb
index db3774d..294e272 100644
--- a/lib/jsx_rosetta/ir.rb
+++ b/lib/jsx_rosetta/ir.rb
@@ -1,16 +1,17 @@
# frozen_string_literal: true
require_relative "ir/types"
+require_relative "ir/radix_registry"
require_relative "ir/lowering"
module JsxRosetta
module IR
- def self.lower(ast_file, source:)
- Lowering.lower(ast_file, source: source)
+ def self.lower(ast_file, source:, keep_slot: false)
+ Lowering.lower(ast_file, source: source, keep_slot: keep_slot)
end
- def self.lower_all(ast_file, source:)
- Lowering.lower_all(ast_file, source: source)
+ def self.lower_all(ast_file, source:, keep_slot: false)
+ Lowering.lower_all(ast_file, source: source, keep_slot: keep_slot)
end
end
end
diff --git a/lib/jsx_rosetta/ir/lowering.rb b/lib/jsx_rosetta/ir/lowering.rb
index 85bf89c..60d32d2 100644
--- a/lib/jsx_rosetta/ir/lowering.rb
+++ b/lib/jsx_rosetta/ir/lowering.rb
@@ -55,12 +55,12 @@ def compute_line_column(source, position)
end
end
- def self.lower(file, source:)
- new(source).lower_file(file)
+ def self.lower(file, source:, keep_slot: false)
+ new(source, keep_slot: keep_slot).lower_file(file)
end
- def self.lower_all(file, source:)
- new(source).lower_all_components(file)
+ def self.lower_all(file, source:, keep_slot: false)
+ new(source, keep_slot: keep_slot).lower_all_components(file)
end
REACT_HOOKS = %w[
@@ -146,8 +146,14 @@ def self.lower_all(file, source:)
unknown: nil
}.freeze
- def initialize(source)
+ def initialize(source, keep_slot: false)
@source = source
+ # When false (default), the shadcn `` pattern that
+ # routes through Radix's Slot.Root gets its Slot branch dropped at
+ # lowering time, leaving only the non-Slot HTML/component branch.
+ # When true, preserve the full polymorphic conditional (legacy
+ # behavior; useful if the consumer shims Components::Slot::Root).
+ @keep_slot = keep_slot
@prop_names = []
@local_jsx = {}
@local_bindings = []
@@ -160,6 +166,11 @@ def initialize(source)
@react_hooks = []
@render_methods = []
@render_method_seen = {}
+ # File-level imports; populated once at lower_file / lower_all_components
+ # entry and consulted by JSX lowering to decide whether a member-chain
+ # tag like `SeparatorPrimitive.Root` should resolve through the Radix
+ # registry into an HTML Element.
+ @module_imports = []
# Class-component non-render members (constructor, lifecycle hooks,
# custom handlers). Keyed by class name; populated by
# extract_class_component, drained by lower_component to surface
@@ -172,19 +183,19 @@ def lower_file(file)
raise no_component_error(file.program) if candidates.empty?
name, function = candidates.first
- module_bindings = capture_module_bindings(file.program, candidates)
- module_imports = capture_module_imports(file.program)
- attach_module_metadata(lower_component(name, function), module_bindings, module_imports)
+ @module_bindings = capture_module_bindings(file.program, candidates)
+ @module_imports = capture_module_imports(file.program)
+ attach_module_metadata(lower_component(name, function), @module_bindings, @module_imports)
end
def lower_all_components(file)
candidates = find_component_functions(file.program)
raise no_component_error(file.program) if candidates.empty?
- module_bindings = capture_module_bindings(file.program, candidates)
- module_imports = capture_module_imports(file.program)
+ @module_bindings = capture_module_bindings(file.program, candidates)
+ @module_imports = capture_module_imports(file.program)
candidates.map do |name, function|
- attach_module_metadata(lower_component(name, function), module_bindings, module_imports)
+ attach_module_metadata(lower_component(name, function), @module_bindings, @module_imports)
end
end
@@ -244,9 +255,120 @@ def record_module_binding(stmt, declarator, component_names, bindings)
name = declarator[:id]&.[](:name)
return unless name
+ # shadcn-style `const fooVariants = cva(base, { variants, ... })` gets
+ # recognized at lowering and stored as a CvaBinding — the backend
+ # turns it into real Ruby constants and the use-site call collapses
+ # to a string interpolation. Falls through to the generic LocalBinding
+ # path when the cva shape doesn't match exactly.
+ if (cva = parse_cva_binding(init, name))
+ bindings << cva
+ return
+ end
+
bindings << LocalBinding.new(name: name, source: source_of(stmt).strip)
end
+ # Returns a CvaBinding when `init` is a `cva(base, options)` call we
+ # know how to parse, or nil to fall through to LocalBinding.
+ def parse_cva_binding(init, name)
+ return nil unless cva_call?(init)
+
+ args = init[:arguments] || []
+ base_class = extract_cva_string(args[0])
+ return nil unless base_class
+
+ options = args[1]
+ return nil unless options.is_a?(AST::Node) && options.type == "ObjectExpression"
+
+ CvaBinding.new(
+ name: name,
+ base_class: base_class,
+ variants: extract_cva_variants(options),
+ default_variants: extract_cva_default_variants(options),
+ compound_source: extract_cva_compound_source(options)
+ )
+ end
+
+ def cva_call?(node)
+ return false unless node.is_a?(AST::Node) && node.type == "CallExpression"
+
+ callee = node[:callee]
+ callee.is_a?(AST::Node) && callee.type == "Identifier" && callee[:name] == "cva"
+ end
+
+ def extract_cva_string(node)
+ return nil unless node.is_a?(AST::Node)
+
+ case node.type
+ when "StringLiteral"
+ node[:value]
+ when "TemplateLiteral"
+ # Only handle templates with no interpolations — they're effectively
+ # a string literal (shadcn's cva bases sometimes use a template for
+ # multi-line readability).
+ return nil unless (node[:expressions] || []).empty?
+
+ (node[:quasis] || []).map { |q| q[:value][:cooked] }.join
+ end
+ end
+
+ def extract_cva_variants(options_node)
+ prop = find_object_property(options_node, "variants")
+ return {} unless object_expression?(prop&.[](:value))
+
+ prop[:value][:properties].each_with_object({}) do |axis, hash|
+ axis_name = property_key(axis)
+ options = extract_cva_axis_options(axis[:value])
+ hash[axis_name] = options if axis_name && !options.empty?
+ end
+ end
+
+ def extract_cva_axis_options(axis_value_node)
+ return {} unless object_expression?(axis_value_node)
+
+ axis_value_node[:properties].each_with_object({}) do |opt, hash|
+ opt_name = property_key(opt)
+ opt_value = extract_cva_string(opt[:value])
+ hash[opt_name] = opt_value if opt_name && opt_value
+ end
+ end
+
+ def object_expression?(node)
+ node.is_a?(AST::Node) && node.type == "ObjectExpression"
+ end
+
+ def extract_cva_default_variants(options_node)
+ prop = find_object_property(options_node, "defaultVariants")
+ return {} unless prop && prop[:value].is_a?(AST::Node) && prop[:value].type == "ObjectExpression"
+
+ prop[:value][:properties].each_with_object({}) do |p, hash|
+ key = property_key(p)
+ val = extract_cva_string(p[:value])
+ hash[key] = val if key && val
+ end
+ end
+
+ def extract_cva_compound_source(options_node)
+ prop = find_object_property(options_node, "compoundVariants")
+ return nil unless prop
+
+ source_of(prop[:value]).strip
+ end
+
+ def find_object_property(obj_node, name)
+ (obj_node[:properties] || []).find { |p| property_key(p) == name }
+ end
+
+ def property_key(prop)
+ return nil unless prop.is_a?(AST::Node) && prop[:key].is_a?(AST::Node)
+
+ key = prop[:key]
+ case key.type
+ when "Identifier" then key[:name]
+ when "StringLiteral" then key[:value]
+ end
+ end
+
def attach_module_metadata(component, module_bindings, module_imports)
component.with(module_bindings: module_bindings, module_imports: module_imports)
end
@@ -266,7 +388,12 @@ def capture_module_imports(program)
name = spec[:local]&.[](:name)
next unless name
- imports << ModuleImport.new(name: name, source: source, kind: import_specifier_kind(spec))
+ imports << ModuleImport.new(
+ name: name,
+ source: source,
+ kind: import_specifier_kind(spec),
+ imported_name: spec[:imported]&.[](:name)
+ )
end
end
imports
@@ -1165,12 +1292,74 @@ def lower_jsx_element(element)
ComponentInvocation.new(name: "#{parent}.#{tag}", props: attributes, children: children)
elsif html_element?(tag)
Element.new(tag: tag, attributes: attributes, children: children)
+ elsif (radix = radix_primitive_for(tag))
+ # `` (imported from radix-ui) lowers
+ # to a plain `
` so the consumer doesn't have
+ # to define a Components::SeparatorPrimitive::Root shim.
+ Element.new(
+ tag: radix[:tag],
+ attributes: merge_radix_attrs(radix[:attrs], attributes),
+ children: children
+ )
else
ComponentInvocation.new(name: tag, props: attributes, children: children)
end
end
+ # Returns the Radix registry entry for `` when:
+ # - the tag is a two-segment member chain
+ # - the root segment was imported from a Radix-shaped package
+ # - the (LocalName, Member) pair is in the registry
+ # Otherwise nil — the caller falls through to a ComponentInvocation.
+ def radix_primitive_for(tag)
+ segments = tag.split(".")
+ return nil if segments.length != 2
+
+ local, member = segments
+ return nil unless imported_from_radix?(local)
+
+ RadixRegistry.lookup(local, member)
+ end
+
+ def imported_from_radix?(local_name)
+ @module_imports.any? do |imp|
+ imp.name == local_name && RADIX_SOURCE_PATTERN.match?(imp.source)
+ end
+ end
+
+ # Combine the registry's fixed attrs (role, type, etc.) with the
+ # consumer's own JSX attributes. Consumer attrs win on collision — the
+ # JSX is the source of truth; the registry just supplies safe defaults.
+ # Collision keys normalize away case + hyphens/underscores so future
+ # registry entries like `data-state` don't slip past a consumer's
+ # `dataState`.
+ def merge_radix_attrs(fixed_attrs, jsx_attrs)
+ user_keys = jsx_attrs.filter_map do |a|
+ a.respond_to?(:name) ? normalize_attr_key(a.name) : nil
+ end.to_set
+ injected = fixed_attrs.filter_map do |name, value|
+ attr_name = name.to_s
+ next if user_keys.include?(normalize_attr_key(attr_name))
+
+ Attribute.new(name: attr_name, value: value.to_s)
+ end
+ injected + jsx_attrs
+ end
+
+ def normalize_attr_key(name)
+ name.to_s.downcase.tr("-_", "")
+ end
+
def lower_polymorphic_tag_use(poly, attributes, children)
+ if (chosen = drop_slot_branch(poly))
+ # The shadcn `` pattern routes through Radix's
+ # Slot.Root, which has no Ruby class on the Phlex side. Drop the
+ # Slot branch and render the underlying HTML/component branch
+ # directly. Pass `--keep-slot` to preserve the conditional if
+ # the consumer is shimming Slot::Root themselves.
+ return build_polymorphic_branch(chosen, attributes, children)
+ end
+
Conditional.new(
test: Interpolation.new(expression: source_of(poly[:test])),
consequent: build_polymorphic_branch(poly[:true_branch], attributes, children),
@@ -1187,6 +1376,41 @@ def build_polymorphic_branch(branch, attributes, children)
end
end
+ # Returns the non-Slot branch when exactly one of the polymorphic
+ # branches resolves to a Radix Slot reference (`Slot` or `Slot.Root`
+ # rooted at a `radix-ui` import). Returns nil otherwise — including
+ # when `--keep-slot` is in effect — so the caller emits the full
+ # conditional unchanged.
+ def drop_slot_branch(poly)
+ return nil if @keep_slot
+
+ t = poly[:true_branch]
+ f = poly[:false_branch]
+ t_is_slot = radix_slot_branch?(t)
+ f_is_slot = radix_slot_branch?(f)
+ return f if t_is_slot && !f_is_slot
+ return t if f_is_slot && !t_is_slot
+
+ nil
+ end
+
+ # True iff `branch` references a Slot import from a Radix-shaped
+ # package. The local binding is one of {`Slot`, `SlotPrimitive`} —
+ # both correspond to the canonical "import from radix-ui / @radix-ui/
+ # react-slot" pattern. Anything else (e.g. a user-defined
+ # `SlotMachine` from a random package whose path happens to contain
+ # "radix") falls through and renders the conditional unchanged.
+ def radix_slot_branch?(branch)
+ return false unless branch[:kind] == :component
+
+ root = branch[:tag].split(".").first
+ return false unless root && SLOT_LOCAL_NAME_PATTERN.match?(root)
+
+ @module_imports.any? do |imp|
+ imp.name == root && RADIX_SOURCE_PATTERN.match?(imp.source)
+ end
+ end
+
def lower_jsx_fragment(fragment)
Fragment.new(children: lower_children(fragment.jsx_children))
end
@@ -1536,10 +1760,116 @@ def lower_class_name(value)
if value.is_a?(AST::JSXExpressionContainer)
decomposed = try_lower_class_helper(value.expression)
return decomposed if decomposed
+
+ cva_call = try_lower_cva_call_site(value.expression)
+ return cva_call if cva_call
end
StyleBinding.new(expression: style_binding_expression(value))
end
+ # Recognize the cva call shape — `cn(({ axes }), )`
+ # or the bare `({ axes })` direct form — against a CvaBinding
+ # captured during module-level lowering. Returns an IR::CvaCallSite,
+ # or nil so the caller falls through to the generic StyleBinding.
+ # AST-driven instead of regexing over verbatim source, which lets us
+ # handle reversed-arg `cn(, (...))`, the no-cn
+ # direct form, and literal-pinned axes naturally.
+ def try_lower_cva_call_site(expression)
+ return nil unless expression.respond_to?(:type)
+ return nil unless expression.type == "CallExpression"
+
+ callee = expression.child(:callee)
+ return nil unless callee
+
+ if callee.of_type?("Identifier") && %w[cn clsx classnames].include?(callee[:name])
+ build_cva_call_site_from_class_helper(expression[:arguments] || [])
+ else
+ build_cva_call_site_from_direct(expression)
+ end
+ end
+
+ # `cn(, )` or `cn(, )` — accept
+ # the first argument that resolves to a known cva call; the remaining
+ # argument (if any) becomes the optional `class_arg`. Anything more
+ # complex (3+ args, nested cn, multiple cva calls) bails to nil.
+ def build_cva_call_site_from_class_helper(args)
+ return nil unless args.length.between?(1, 2)
+
+ cva_arg_index = args.find_index { |a| cva_call_against_known_binding?(a) }
+ return nil unless cva_arg_index
+
+ cva_arg = args[cva_arg_index]
+ class_arg = args.length == 2 ? args[1 - cva_arg_index] : nil
+ build_cva_call_site_node(cva_arg, class_arg)
+ end
+
+ # Bare `({ axes })` — same shape with no class_arg.
+ def build_cva_call_site_from_direct(expression)
+ return nil unless cva_call_against_known_binding?(expression)
+
+ build_cva_call_site_node(expression, nil)
+ end
+
+ def build_cva_call_site_node(cva_call, class_arg_node)
+ callee_name = cva_call[:callee][:name]
+ options = cva_call[:arguments]&.first
+ return nil unless options && options.type == "ObjectExpression"
+
+ axes = options[:properties].filter_map { |prop| build_cva_axis_pair(prop) }
+ class_arg = class_arg_node && Interpolation.new(expression: source_of(class_arg_node))
+ CvaCallSite.new(binding_name: callee_name, axes: axes, class_arg: class_arg)
+ end
+
+ # Pull one axis-value pair off the cva options object. Shorthand
+ # (`{ variant }`) and explicit (`{ variant: someExpr }`) both work;
+ # spread (`{ ...rest }`) and computed keys bail to nil so the call
+ # site falls through to the generic translator with a TODO.
+ def build_cva_axis_pair(prop)
+ return nil unless prop.type == "ObjectProperty"
+
+ axis = property_key_name(prop)
+ return nil unless axis
+
+ value_node = prop[:value]
+ kind, source = classify_cva_axis_value(value_node)
+ CvaAxisPair.new(axis: axis, kind: kind, source: source)
+ end
+
+ def property_key_name(prop)
+ case prop[:key].type
+ when "Identifier" then prop[:key][:name]
+ when "StringLiteral" then prop[:key][:value]
+ end
+ end
+
+ def classify_cva_axis_value(node)
+ case node.type
+ when "StringLiteral" then [:literal_string, node[:value]]
+ when "NumericLiteral", "BooleanLiteral" then [:literal_other, source_of(node)]
+ when "NullLiteral" then [:literal_nil, nil]
+ when "Identifier"
+ # Shorthand `{ variant }` and explicit `{ variant: ident }` both
+ # land here; the source is the identifier name itself.
+ node[:name] == "undefined" ? [:literal_nil, nil] : [:prop_ref, node[:name]]
+ else
+ # Member chains, calls, etc. — pass the source through as a
+ # raw expression. The backend re-translates it through
+ # ExpressionTranslator like any other prop reference.
+ [:prop_ref, source_of(node)]
+ end
+ end
+
+ def cva_call_against_known_binding?(node)
+ return false unless node.respond_to?(:type)
+ return false unless node.type == "CallExpression"
+
+ callee = node.child(:callee)
+ return false unless callee&.of_type?("Identifier")
+
+ binding_name = callee[:name]
+ @module_bindings.any? { |b| b.is_a?(CvaBinding) && b.name == binding_name }
+ end
+
def try_lower_class_helper(expression)
return nil unless AST::Node.matches?(expression, "CallExpression")
@@ -1620,9 +1950,18 @@ def try_promote_to_stimulus(attr_name, event, expression)
def promote_arrow_to_stimulus(attr_name, event, arrow_node, name_hint:)
base = name_hint || default_stimulus_method_name(attr_name)
method_name = stimulus_method_name(base)
- body_source = source_of(arrow_node[:body])
+ body_node = arrow_node[:body]
+ body_source = source_of(body_node)
+ # Preserve nil entries for non-Identifier params (ObjectPattern,
+ # ArrayPattern, RestElement) so emit-time bails to TODO rather
+ # than pasting a body that references undefined locals.
+ params = Array(arrow_node[:params]).map { |p| p.type == "Identifier" ? p[:name] : nil }
@stimulus_methods << StimulusMethod.new(
- name: method_name, body_source: body_source, original_name: base
+ name: method_name,
+ body_source: body_source,
+ original_name: base,
+ params: params,
+ body_is_block: body_node.type == "BlockStatement"
)
@local_arrows.delete(name_hint) if name_hint
StimulusBinding.new(event: event, method_name: method_name)
@@ -1636,7 +1975,11 @@ def promote_identifier_event(attr_name, event, identifier_name)
method_name = stimulus_method_name(identifier_name)
body_source = "// originally bound to: #{identifier_name}"
@stimulus_methods << StimulusMethod.new(
- name: method_name, body_source: body_source, original_name: identifier_name
+ name: method_name,
+ body_source: body_source,
+ original_name: identifier_name,
+ params: [],
+ body_is_block: false
)
StimulusBinding.new(event: event, method_name: method_name)
end
diff --git a/lib/jsx_rosetta/ir/radix_registry.rb b/lib/jsx_rosetta/ir/radix_registry.rb
new file mode 100644
index 0000000..8f9971e
--- /dev/null
+++ b/lib/jsx_rosetta/ir/radix_registry.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module JsxRosetta
+ module IR
+ # When a shadcn TSX wraps a Radix primitive like
+ # ``, the translator
+ # has historically lowered it to a `ComponentInvocation` whose name is
+ # the member chain (`"SeparatorPrimitive.Root"`) — which emits as
+ # `render SeparatorPrimitive::Root.new(...)`, an undefined-constant
+ # NameError at render time.
+ #
+ # The shapes underneath are stable enough across Radix that we can map
+ # the common (LocalImportName, MemberName) pairs to plain HTML elements
+ # plus any always-applied attributes. The mapping is keyed on the LOCAL
+ # binding name shadcn uses by convention (e.g. `SeparatorPrimitive`),
+ # which matches the import-aliasing pattern in every shadcn fork I've
+ # surveyed. Unknown pairs fall through to the existing ComponentInvocation
+ # / TODO behavior so consumers can hand-shim primitives we don't cover.
+ #
+ # Source specifiers that count as "Radix" — both the `radix-ui` umbrella
+ # package and the older `@radix-ui/react-*` per-primitive packages.
+ RADIX_SOURCE_PATTERN = %r{\A(?:radix-ui|@radix-ui/react-[\w-]+)\z}
+
+ # Local binding names that resolve to Radix's `Slot` primitive.
+ # Matches `Slot` (shadcn-v4 umbrella) and `SlotPrimitive` (older
+ # convention). Anything looser would silently drop user-defined
+ # components whose names happen to start with "Slot".
+ SLOT_LOCAL_NAME_PATTERN = /\ASlot(?:Primitive)?\z/
+
+ module RadixRegistry
+ # Each entry maps [PrimitiveBaseName, MemberName] →
+ # { tag: , attrs: { => } }
+ # The `attrs` are merged into the element's lowered attributes (the
+ # consumer's own kwargs win on conflict — see `merge_radix_attrs`).
+ #
+ # `PrimitiveBaseName` is the *canonical* Radix primitive name
+ # (e.g. `Separator`); the lookup strips an optional `Primitive` suffix
+ # from the consumer's local binding before keying in, so both shadcn-v3
+ # (`import { Separator as SeparatorPrimitive } from "radix-ui"`) and
+ # shadcn-v4 (`import { Separator } from "radix-ui"`) umbrella idioms
+ # resolve. Namespace imports (`import * as SeparatorPrimitive from
+ # "@radix-ui/react-separator"`) also strip the suffix.
+ MAP = {
+ # Separator / Label / Avatar / Switch primitives — the shapes we hit
+ # most often in the bulk shadcn translation pass.
+ %w[Separator Root] => { tag: "div", attrs: { role: "separator" } },
+ %w[Label Root] => { tag: "label", attrs: {} },
+ %w[Avatar Root] => { tag: "span", attrs: {} },
+ %w[Avatar Image] => { tag: "img", attrs: {} },
+ %w[Avatar Fallback] => { tag: "span", attrs: {} },
+ %w[Switch Root] => { tag: "button", attrs: { type: "button", role: "switch" } },
+ %w[Switch Thumb] => { tag: "span", attrs: {} },
+ %w[Progress Root] => { tag: "div", attrs: { role: "progressbar" } },
+ %w[Progress Indicator] => { tag: "div", attrs: {} },
+ # Aspect-ratio + scroll-area roots are presentational containers.
+ %w[AspectRatio Root] => { tag: "div", attrs: {} },
+ %w[ScrollArea Root] => { tag: "div", attrs: {} },
+ %w[ScrollArea Viewport] => { tag: "div", attrs: {} },
+ # Tabs primitives — `data-orientation` comes from the JSX attrs;
+ # role=tablist on List + role=tab on Trigger + role=tabpanel on Content.
+ %w[Tabs Root] => { tag: "div", attrs: {} },
+ %w[Tabs List] => { tag: "div", attrs: { role: "tablist" } },
+ %w[Tabs Trigger] => { tag: "button", attrs: { type: "button", role: "tab" } },
+ %w[Tabs Content] => { tag: "div", attrs: { role: "tabpanel" } },
+ # Toggle / ToggleGroup — pressable buttons.
+ %w[Toggle Root] => { tag: "button", attrs: { type: "button" } },
+ %w[ToggleGroup Root] => { tag: "div", attrs: { role: "group" } },
+ %w[ToggleGroup Item] => { tag: "button", attrs: { type: "button" } },
+ # Collapsible — presentational containers; the open/closed state is
+ # data-state driven by the consumer (Stimulus or otherwise).
+ %w[Collapsible Root] => { tag: "div", attrs: {} },
+ %w[Collapsible Trigger] => { tag: "button", attrs: { type: "button" } },
+ %w[Collapsible Content] => { tag: "div", attrs: {} }
+ }.freeze
+
+ # Strip an optional `Primitive` suffix from the local binding so the
+ # registry's canonical names match both umbrella imports (`Separator`)
+ # and the older convention (`SeparatorPrimitive`).
+ def self.lookup(local_name, member)
+ MAP[[local_name, member]] || MAP[[local_name.sub(/Primitive\z/, ""), member]]
+ end
+ end
+ end
+end
diff --git a/lib/jsx_rosetta/ir/types.rb b/lib/jsx_rosetta/ir/types.rb
index 317474f..def4bf3 100644
--- a/lib/jsx_rosetta/ir/types.rb
+++ b/lib/jsx_rosetta/ir/types.rb
@@ -92,11 +92,19 @@ module Node
# to reference the imported value). For `import { foo as bar }`
# this is "bar"; for `import * as styles` this is "styles";
# for `import Default` this is "Default".
- # source : String — the module specifier verbatim (e.g. "./styles.module.css",
- # "@apollo/client", "react"). Lets backends apply per-source
- # policy later (e.g. always strip `*.module.css` references).
- # kind : Symbol — :default | :named | :namespace.
- ModuleImport = Data.define(:name, :source, :kind) do
+ # source : String — the module specifier verbatim
+ # (e.g. "./styles.module.css", "@apollo/client", "react").
+ # Lets backends apply per-source policy later (e.g. always
+ # strip `*.module.css` references).
+ # kind : Symbol — :default | :named | :namespace.
+ # imported_name : String? — original exported name from the source module.
+ # For `import { ChevronRight as CR } from "lucide-react"`,
+ # `name` is `"CR"` and `imported_name` is `"ChevronRight"`.
+ # nil for default / namespace imports where there's no
+ # distinct exported name. Backends that look up vendored
+ # data by canonical name (icons) need the imported name;
+ # most callers want the local binding.
+ ModuleImport = Data.define(:name, :source, :kind, :imported_name) do
include Node
end
@@ -110,6 +118,64 @@ module Node
include Node
end
+ # A module-level call to `cva()` from class-variance-authority. shadcn
+ # components ubiquitously use this builder to attach a base class string
+ # plus per-axis variant maps to a JSX component. The translator
+ # recognizes the pattern at lowering time so backends can emit real
+ # Ruby constants (`FOO_BASE_CLASS`, `FOO_VARIANT_CLASSES`) instead of
+ # leaving the call as a TODO comment, and so the use-site
+ # `cn(fooVariants({ variant }), className)` translates to a proper
+ # Ruby string interpolation.
+ #
+ # name : String — the const binding name (e.g. "buttonVariants").
+ # base_class : String — the first string argument to cva().
+ # variants : Hash[String => Hash[String => String]]
+ # — { "variant" => { "default" => "...", "outline" => "..." } }
+ # default_variants : Hash[String => String] — per-axis default value name
+ # (matched against the variant axis keys).
+ # compound_source : String | nil — INTENTIONALLY UNPARSED verbatim JS
+ # source of any `compoundVariants` entry. Field name
+ # reads structural; it isn't — backends only print
+ # it as a TODO comment alongside the constants since
+ # compoundVariants semantics aren't supported in the
+ # first cut.
+ CvaBinding = Data.define(:name, :base_class, :variants, :default_variants, :compound_source) do
+ include Node
+ end
+
+ # A className attribute value that resolves to a known cva binding's
+ # call shape — `className={cn(buttonVariants({ variant, size }),
+ # className)}` or the no-cn direct form `className={buttonVariants({
+ # variant })}`. The translator recognizes the AST shape at lowering
+ # so the backend never has to regex over verbatim JS source; this
+ # naturally handles literal-pinned axes, reversed arg order, and
+ # the cn-vs-no-cn forms.
+ #
+ # binding_name : String — referenced cva binding's const name.
+ # axes : [CvaAxisPair] — preserved in JSX source order.
+ # class_arg : Interpolation | nil — the optional trailing className
+ # arg from `cn(, )`. Nil for the
+ # single-arg `cn()` and the no-cn direct
+ # forms.
+ CvaCallSite = Data.define(:binding_name, :axes, :class_arg) do
+ include Node
+ end
+
+ # One axis-value pair inside a cva call's options object. The
+ # discriminator `kind` tells the backend how to render the value:
+ #
+ # :prop_ref — JS identifier referencing a prop (`{ variant }` or
+ # `{ variant: someProp }`). Backends render as
+ # `@snake_case` against the receiving Phlex component.
+ # :literal_string — `{ variant: "default" }` — the literal value
+ # is the variant-table key.
+ # :literal_other — `{ size: 42 }` / `{ active: true }` — Ruby
+ # literal passed through to the bracket key.
+ # :literal_nil — `{ variant: null }` or `undefined`.
+ CvaAxisPair = Data.define(:axis, :kind, :source) do
+ include Node
+ end
+
# A hook invocation detected in the component body. Covers React's
# built-in hooks plus framework hooks we recognize (Apollo's `useQuery`/
# `useMutation`/etc., Next.js's `useRouter`/`usePathname`/etc.).
@@ -340,7 +406,17 @@ module Node
# `name != original_name`, backends emit a collision
# marker comment in the generated controller JS so the
# reviewer can see the silent rename.
- StimulusMethod = Data.define(:name, :body_source, :original_name) do
+ # params : [String | nil] — original arrow/function parameter
+ # names (e.g. `["e"]`, `["event"]`, or `[]` for
+ # `() => …`). A `nil` entry signals a non-identifier
+ # param (destructured `({target}) =>`, rest `(...args) =>`)
+ # that the pasted body can't safely reference — backends
+ # bail to the TODO form when any entry is nil.
+ # body_is_block : Boolean — true when the arrow body was a BlockStatement
+ # (`(e) => { … }`), false for an expression-form body
+ # (`(e) => doX(e)`). Backends use this to decide whether
+ # to strip outer braces when pasting verbatim.
+ StimulusMethod = Data.define(:name, :body_source, :original_name, :params, :body_is_block) do
include Node
end
diff --git a/spec/backend/phlex_spec.rb b/spec/backend/phlex_spec.rb
index be41699..f4aad25 100644
--- a/spec/backend/phlex_spec.rb
+++ b/spec/backend/phlex_spec.rb
@@ -363,11 +363,55 @@ def make_route(rails_path:, controller:, action:)
expect(content).to include("clickHandler(event) {")
end
- it "preserves the original handler body as a TODO comment" do
+ it "pastes the JSX handler body into the generated Stimulus method" do
content = file_contents(source, "x_controller.js")
- expect(content).to include("// TODO: translate from the original JSX handler:")
+ # The DOM-driven body `doThing()` is valid JS, so we drop it straight
+ # into the method instead of leaving the human reviewer with a TODO
+ # comment to translate.
+ expect(content).to include("clickHandler(event) {")
expect(content).to include("doThing()")
+ expect(content).not_to include("// TODO: translate from the original JSX handler:")
+ end
+
+ it "falls back to a TODO comment when the body uses a React state setter" do
+ # `setOpen(!open)` references a hook return; we can't run the setter
+ # in the browser, so preserve the body as a comment and leave the
+ # method body empty for the reviewer to port.
+ state_source = "function X() { return ; }"
+ content = file_contents(state_source, "x_controller.js")
+
+ expect(content).to include("// TODO: translate from the original JSX handler:")
+ expect(content).to include("setOpen(!open)")
+ expect(content).to match(%r{clickHandler\(event\) \{\s+// \.\.\.\s+\}})
+ end
+
+ it "uses the arrow's parameter name so the pasted body's references resolve" do
+ # `(e) => e.currentTarget...` pastes verbatim AND the method's
+ # parameter is named `e` to match — body references resolve at runtime.
+ param_source = <<~JSX
+ function X() {
+ return (
+
+ );
+ }
+ JSX
+ content = file_contents(param_source, "x_controller.js")
+
+ expect(content).to include("clickHandler(e) {")
+ expect(content).to include('e.currentTarget.dataset.x = "y"')
+ end
+
+ it "leaves identifier-bound handlers (no inline arrow body) as a TODO" do
+ # `onClick={handleClick}` with `handleClick` not declared locally has
+ # no body to paste; the existing identifier-bound TODO behavior stays.
+ ident_source = "function X({ handleClick }) { return ; }"
+ content = file_contents(ident_source, "x_controller.js")
+
+ expect(content).to include("// TODO: translate from the original JSX handler:")
+ expect(content).to include("// originally bound to: handleClick")
end
it "emits a collision marker when a handler name was uniquified" do
@@ -390,6 +434,55 @@ def make_route(rails_path:, controller:, action:)
expect(content).to include("handleReset2(event) {")
expect(content).to include('// NOTE: method renamed from "handleReset"')
end
+
+ it "bails to TODO when the arrow has a destructured parameter" do
+ # `({ target }) => …` — the body references `target` but pasting
+ # without the destructuring would NameError. Bail to TODO so the
+ # reviewer translates the destructure intentionally.
+ destructured = "function X() { return ; }"
+ content = file_contents(destructured, "x_controller.js")
+
+ expect(content).to include("// TODO: translate from the original JSX handler:")
+ end
+
+ it "bails to TODO when the arrow has a rest parameter" do
+ rest = "function X() { return ; }"
+ content = file_contents(rest, "x_controller.js")
+
+ expect(content).to include("// TODO: translate from the original JSX handler:")
+ end
+
+ it "still pastes when the body calls a DOM method whose name starts with `set`" do
+ # `e.setAttribute(` / `el.setPointerCapture(` look like top-level
+ # state setters under a `\\bset[A-Z]` match because `\\b` matches at
+ # the `.`. The tightened regex (negative lookbehind on `[.\\w]`)
+ # only fires on bare `setX(`, not `obj.setX(`.
+ dom_source = <<~JSX
+ function X() {
+ return ;
+ }
+ JSX
+ content = file_contents(dom_source, "x_controller.js")
+
+ expect(content).to include('e.target.setAttribute("data-x", "1")')
+ expect(content).not_to include("// TODO: translate from the original JSX handler:")
+ end
+
+ it "does NOT strip outer braces on an expression-form arrow body" do
+ # `() => ({ x: 1 })` — Babel hands back `{ x: 1 }` as the body
+ # source. Stripping braces would yield `x: 1`, a JS label
+ # statement (no-op). With the AST-aware check we only strip
+ # when the body was a BlockStatement.
+ expr_source = <<~JSX
+ function X() {
+ return ;
+ }
+ JSX
+ content = file_contents(expr_source, "x_controller.js")
+
+ expect(content).to include("{ x: 1 }")
+ expect(content).not_to match(/^\s*x: 1\s*$/)
+ end
end
describe "TODO markers" do
@@ -1446,6 +1539,391 @@ def make_route(rails_path:, controller:, action:)
end
end
+ describe "Slot / asChild branch drop" do
+ # The shadcn `` pattern routes through Radix's Slot.Root:
+ # const Comp = asChild ? Slot : "div"
+ # return
+ # That used to lower as a polymorphic conditional whose true-branch
+ # rendered `Slot::Root.new(...)` — a non-existent Ruby class. By default
+ # the Slot branch is dropped at lowering time, leaving only the non-Slot
+ # render path. Pass `--keep-slot` / `keep_slot: true` to preserve the
+ # conditional when the consumer shims Slot::Root.
+ def keep_slot_files(source, **opts)
+ backend = described_class.new(**opts)
+ component = JsxRosetta.lower(source, keep_slot: true)
+ backend.emit(component).to_h { |file| [file.path, file.contents] }
+ end
+
+ it "drops the Slot branch and renders only the non-Slot tag by default" do
+ source = <<~JSX
+ import { Slot } from "radix-ui";
+ function X({ asChild, ...props }) {
+ const Comp = asChild ? Slot : "div";
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("div(")
+ expect(content).not_to include("Slot")
+ end
+
+ it "drops Slot.Root (member-chain form) the same way" do
+ source = <<~JSX
+ import { Slot } from "radix-ui";
+ function X({ asChild, ...props }) {
+ const Comp = asChild ? Slot.Root : "span";
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("span(")
+ expect(content).not_to include("Slot")
+ end
+
+ it "leaves the conditional intact under keep_slot: true" do
+ source = <<~JSX
+ import { Slot } from "radix-ui";
+ function X({ asChild, ...props }) {
+ const Comp = asChild ? Slot : "div";
+ return ;
+ }
+ JSX
+ content = keep_slot_files(source).fetch("x.rb")
+
+ expect(content).to include("Slot")
+ end
+
+ it "does NOT drop when neither branch references a Radix Slot" do
+ source = <<~JSX
+ function X({ withSection, ...props }) {
+ const Comp = withSection ? "section" : "div";
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("section(")
+ expect(content).to include("div(")
+ end
+
+ it "does NOT drop when the Slot import isn't from a Radix package" do
+ source = <<~JSX
+ import { Slot } from "./my-slot";
+ function X({ asChild, ...props }) {
+ const Comp = asChild ? Slot : "div";
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("Slot")
+ end
+
+ it "does NOT drop a user-defined Slot-prefixed name even from a radix-shaped source" do
+ # `radix_slot_branch?` only matches `Slot` / `SlotPrimitive` exactly.
+ # A user-defined `SlotMachine` should not be silently dropped, even
+ # if (improbably) imported from `@radix-ui/react-slot-machine`.
+ source = <<~JSX
+ import { SlotMachine } from "@radix-ui/react-slot-machine";
+ function X({ asChild, ...props }) {
+ const Comp = asChild ? SlotMachine : "div";
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("SlotMachine")
+ end
+ end
+
+ describe "Radix primitive → HTML element registry" do
+ # Shadcn-style components wrap Radix UI primitives like
+ # `` (after `import { Separator as
+ # SeparatorPrimitive } from "radix-ui"`). Without a registry, the
+ # translator emits `render SeparatorPrimitive::Root.new(...)` which
+ # references a non-existent Ruby class — NameError at render. With
+ # the registry, known primitives lower as plain HTML elements with
+ # always-applied attributes.
+ it "lowers to a
" do
+ source = <<~JSX
+ import { Separator as SeparatorPrimitive } from "radix-ui";
+ function X() {
+ return ;
+ }
+ JSX
+ content = file_contents(source, "x.rb")
+
+ expect(content).to include("div(role: 'separator', orientation: 'horizontal')")
+ expect(content).not_to include("SeparatorPrimitive::Root")
+ end
+
+ it "lowers to a