Class: JsxRosetta::Backend::Phlex

Inherits:
Base
  • Object
show all
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"
{
  "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

Constructor Details

#initialize(suffix: nil, namespace: nil, rails_view: nil, route_table: nil) ⇒ Phlex

Returns a new instance of Phlex.

Raises:

  • (ArgumentError)


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.

Returns:

  • (Boolean)


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