Class: JsxRosetta::Backend::Phlex
- Defined in:
- lib/jsx_rosetta/backend/phlex.rb
Overview
Emits a Phlex 2.x view class (one Ruby file per component) from an IR::Component. Single-file output by design — the JSX ‘<h1>…` template lives as Ruby inside `view_template`, not in a sibling .erb. When the source uses `onClick`/`onChange` etc., a sibling Stimulus controller `_controller.js` is emitted alongside (same convention as the ViewComponent backend).
Naming strategies (mutually exclusive):
default class FlashyHeader < Phlex::HTML
suffix: "Component" class FlashyHeaderComponent < Phlex::HTML
namespace: "Components" module Components
class FlashyHeader < Phlex::HTML
Hyphenated attributes (‘data-testid`, `aria-label`, etc.) emit as string-keyed hash entries inside a splat — `**{ “data-testid” => @x }` — since Ruby kwargs can’t carry hyphens. Snake_case-friendly attrs emit as regular keyword arguments.
Defined Under Namespace
Classes: EventDescriptor
Constant Summary collapse
- DEFAULT_SLOT_NAME =
"children"- DEFAULT_SUFFIX =
"Component"- PHLEX_BASE_CLASS =
"Phlex::HTML"- VALID_IDENTIFIER =
/\A[a-z_][a-z0-9_]*\z/i- VOID_ELEMENTS =
%w[area base br col embed hr img input link meta param source track wbr].freeze
- LITERAL_INLINE_BUDGET =
Inline budget for object/array literal rendering. When the single-line rendering of a literal exceeds this width — measured from the opening bracket — it switches to a multi-line layout with one entry per line, indented two spaces past the parent’s line indent. Closing bracket re-aligns to the parent indent. Chosen to keep typical attr lines under ~120 chars after the kwarg name and surrounding ‘render Foo.new(…)` wrapper.
80- HOOK_TODO_HEADERS =
Per-library TODO header lines surfaced above the verbatim hook source. Each library has a different Rails analog, so we don’t collapse them into a single generic block. Keys must mirror the ‘:library` values produced by IR::Lowering.
{ react: [ "TODO: React hooks detected. None translate automatically.", "Hotwire/Stimulus handles behavior; controllers/views handle state;", "turbo-frames handle async loading. Original source:" ].freeze, apollo: [ "TODO: Apollo data-fetching hooks detected. None translate automatically.", "Move the fetch to the Rails controller (or a model/service); pass the", "result in as a prop. For useMutation, use a form POST + redirect or a", "Turbo Stream response. Original source:" ].freeze, next_js: [ "TODO: Next.js navigation hooks detected. None translate automatically.", "Rails analogs: useRouter -> redirect_to / form actions;", "usePathname -> request.path; useSearchParams / useParams -> params;", "useSelectedLayoutSegment(s) -> match against request.path in the view.", "Original source:" ].freeze }.freeze
- RAILS_VIEW_BASE_CLASS =
"Views::Base"- LINK_TAG_ATTRS =
Per-tag map of which attribute names carry a URL that the href rewriter should attempt to rewrite. Most link tags use ‘href`/`to`; `<form action=“…”>` uses `action`. Slice C1 added the `form` entry — non-GET forms are skipped at format_attributes time before they reach try_rewrite_href.
{ "a" => %w[href].freeze, "Link" => %w[href to].freeze, "NavLink" => %w[href to].freeze, "RouterLink" => %w[href to].freeze, "form" => %w[action].freeze }.freeze
- ROUTER_PUSH_PATTERN =
Matches ‘router.push(“…”)` / `router.push(’…‘)` / `router.push(`…`)` in verbatim hook source, capturing the quoted argument (including the surrounding quote/backtick). A3: only fires for sync hook bodies —event-handler bodies live in IR::EventHandler / IR::StimulusMethod sources, not in react_hooks, so they’re naturally out of scope.
/router\.push\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)\s*[,)]/- HOC_WRAPPER_TODO_LINES =
Per-wrapper TODO header text. Each HOC that lowering peeled off the source (recorded on IR::Component#hoc_wrappers) gets one short line above the class explaining the Rails analog. Falls back to a generic line for wrappers not in this map.
{ "memo" => "TODO: original component was wrapped in memo(...). React's memo memoizes " \ "by shallow prop equality. Rails analog: fragment caching (`cache @model do …`) " \ "when the view is expensive.", "forwardRef" => "TODO: original component was wrapped in forwardRef(...). The second " \ "arg (`ref`) was dropped — Rails has no view-side ref-forwarding analog; " \ "use Stimulus targets / DOM ids if the host needs a handle.", "observer" => "TODO: original component was wrapped in observer(...) (mobx). React's " \ "observer auto-subscribes to observable state. Rails analog: the controller " \ "loads data and sets @ivars; reactivity moves to Turbo Streams / Hotwire.", "connect" => "TODO: original component was wrapped in connect(...)(X) (redux). Props " \ "injected by mapState/mapDispatch in the source — in Rails, expect those as " \ "controller-passed instance variables instead.", "withRouter" => "TODO: original component was wrapped in withRouter(...) (React Router). " \ "Injected router props (location, history, match) map to Rails: request.path, " \ "redirect_to / form actions, and params.", "withTranslation" => "TODO: original component was wrapped in withTranslation()(X) (i18n). " \ "Injected `t` translator function maps to Rails I18n: `I18n.t(\"…\")` " \ "or the `t` view helper." }.freeze
Instance Method Summary collapse
- #build_translator(component) ⇒ Object
-
#clean_output(source) ⇒ Object
Strip trailing whitespace from each emitted line — easier than threading rstrip through every formatting helper, and a single source of truth keeps Layout/TrailingWhitespace at zero.
- #emit(component, source_filename: nil) ⇒ Object
-
#first_emit_for_module_bindings?(component) ⇒ Boolean
When a source file lowers to multiple sibling components, lower_all attaches the same module_bindings array to every sibling.
-
#initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil) ⇒ Phlex
constructor
A new instance of Phlex.
-
#suppress_intentional_if_false_cops(source) ⇒ Object
When the file contains any ‘if false` / `elsif false` branch (the fallback we emit when a JSX condition can’t be translated to Ruby), disable the cops that flag those at file scope.
Constructor Details
#initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil) ⇒ Phlex
Returns a new instance of Phlex.
119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 119 def initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil) super() raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace if rails_view && (suffix || namespace) raise ArgumentError, "Phlex backend: rails_view: cannot be combined with suffix: or namespace:" end @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true) @namespace = namespace @rails_view = rails_view @href_rewriter = route_table && PagesRouting::HrefRewriter.new(route_table) end |
Instance Method Details
#build_translator(component) ⇒ Object
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 174 def build_translator(component) prop_names = component.props.map(&:name) prop_names << component.rest_prop_name if component.rest_prop_name prop_aliases = component.props.each_with_object({}) do |prop, hash| hash[prop.alias_name] = prop.name if prop.alias_name end # `imported_names` covers both top-level `import` declarations AND # top-level helper bindings (`function onError(){}`, `const FOO = …`). # They behave identically at the use site — the translator bails out # to `nil` rather than emitting a bare snake_case ref that NameErrors. ViewComponent::ExpressionTranslator.new( prop_names: prop_names, local_binding_names: component.local_binding_names, prop_aliases: prop_aliases, imported_names: component.module_imports.map(&:name) + component.module_bindings.map(&:name) ) end |
#clean_output(source) ⇒ Object
Strip trailing whitespace from each emitted line — easier than threading rstrip through every formatting helper, and a single source of truth keeps Layout/TrailingWhitespace at zero. Preserve the trailing newline of the file as-is. Also suppresses the intentional-‘if false` cops file-wide so the user’s rubocop doesn’t drown out actionable findings.
198 199 200 201 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 198 def clean_output(source) cleaned = "#{source.split("\n").map(&:rstrip).join("\n")}\n".sub(/\n\n+\z/, "\n") suppress_intentional_if_false_cops(cleaned) end |
#emit(component, source_filename: nil) ⇒ Object
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 132 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 = [] @lambda_method_counts = {} @event_handler_methods = [] @emit_module_prefix = first_emit_for_module_bindings?(component) @source_filename = source_filename files = [File.new(path: ruby_path(component), contents: clean_output(render_ruby_class(component, translator)))] if component.stimulus_methods.any? files << File.new( path: stimulus_path(component), 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 |
#first_emit_for_module_bindings?(component) ⇒ Boolean
When a source file lowers to multiple sibling components, lower_all attaches the same module_bindings array to every sibling. Emitting the constants TODO block on each one duplicates 40-line GraphQL blocks across every emitted .rb file. Track the array identities we’ve seen and only emit the prefix the first time.
163 164 165 166 167 168 169 170 171 172 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 163 def first_emit_for_module_bindings?(component) return true if component.module_bindings.empty? @seen_module_bindings ||= Set.new key = component.module_bindings.object_id return false if @seen_module_bindings.include?(key) @seen_module_bindings << key true end |
#suppress_intentional_if_false_cops(source) ⇒ Object
When the file contains any ‘if false` / `elsif false` branch (the fallback we emit when a JSX condition can’t be translated to Ruby), disable the cops that flag those at file scope. The corresponding ‘# TODO: translate condition:` comment already names the issue, so the cop’s report is redundant noise. Only emits when needed.
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/jsx_rosetta/backend/phlex.rb', line 208 def suppress_intentional_if_false_cops(source) cops = [] cops << "Lint/LiteralAsCondition" if source.match?(/^\s*(?:if|elsif) false$/m) # An elsif-false adjacent to its leading `if false` is the only # configuration that triggers DuplicateElsifCondition — multiple # `if false`s in separate scopes don't qualify. The pattern below # matches an `if false` directly followed (after consequent lines) # by an `elsif false` at the same indent. cops << "Lint/DuplicateElsifCondition" if source.match?(/^(\s*)if false\b[\s\S]*?^\1elsif false\b/m) return source if cops.empty? disable = "# rubocop:disable #{cops.join(", ")}\n\n" enable = "# rubocop:enable #{cops.join(", ")}\n" # Magic comment must be followed by a blank line before any other # comment (Layout/EmptyLineAfterMagicComment), and every file-level # disable needs a matching enable (Lint/MissingCopEnableDirective). with_disable = source.sub(/^# frozen_string_literal: true\n\n/, "# frozen_string_literal: true\n\n#{disable}") "#{with_disable.chomp}\n#{enable}" end |