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("\#{inner_svg}") + #{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