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

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of Phlex.

Raises:

  • (ArgumentError)


72
73
74
75
76
77
78
# File 'lib/jsx_rosetta/backend/phlex.rb', line 72

def initialize(suffix: nil, namespace: nil)
  super()
  raise ArgumentError, "Phlex backend: pass either suffix: or namespace:, not both" if suffix && namespace

  @suffix = suffix.is_a?(String) ? suffix : (DEFAULT_SUFFIX if suffix == true)
  @namespace = namespace
end

Instance Method Details

#build_translator(component) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/jsx_rosetta/backend/phlex.rb', line 96

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
  ViewComponent::ExpressionTranslator.new(
    prop_names: prop_names,
    local_binding_names: component.local_binding_names,
    prop_aliases: prop_aliases
  )
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.



115
116
117
118
# File 'lib/jsx_rosetta/backend/phlex.rb', line 115

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) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/jsx_rosetta/backend/phlex.rb', line 80

def emit(component)
  translator = build_translator(component)
  @stimulus_identifier = component.stimulus_methods.any? ? stimulus_identifier(component) : nil
  @lambda_methods = []
  @lambda_method_counts = {}

  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
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.



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/jsx_rosetta/backend/phlex.rb', line 125

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