ruby_ui_converter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module RubyUIConverter
6
+ # Walks the AST and writes Phlex/RubyUI Ruby source into a CodeBuilder.
7
+ class Transformer
8
+ attr_reader :config, :template
9
+
10
+ def initialize(config:, template: nil)
11
+ @config = config
12
+ @template = template
13
+ @form_stack = []
14
+ end
15
+
16
+ # The form scope ({var:, model:, param:}) currently being emitted, if any.
17
+ # Set while inside a mapped form_with/form_for block; used by FormBuilder.
18
+ def current_form
19
+ @form_stack.last
20
+ end
21
+
22
+ # Entry point: emits the body of the view_template method.
23
+ def emit(document, builder)
24
+ emit_children(meaningful(document.children), builder)
25
+ end
26
+
27
+ # --- public helpers (also used by ComponentMap emitters) ---------------
28
+
29
+ def emit_children(nodes, builder)
30
+ nodes.each { |node| emit_node(node, builder) }
31
+ end
32
+
33
+ def meaningful(nodes)
34
+ nodes.reject do |node|
35
+ (node.is_a?(Nodes::Text) && node.content.strip.empty?) ||
36
+ (node.is_a?(Nodes::RawText) && node.content.strip.empty?)
37
+ end
38
+ end
39
+
40
+ def render_attrs(attributes, except: [])
41
+ attributes.reject { |name, _| except.include?(name) }
42
+ .map { |name, parts| attr_pair(name, parts) }
43
+ .join(", ")
44
+ end
45
+
46
+ def base_namespace
47
+ config.base_namespace
48
+ end
49
+
50
+ def current_namespace_parts
51
+ template ? template.namespace_parts : []
52
+ end
53
+
54
+ # Convenience for component emitters: render a component wrapping children.
55
+ def wrap_component(const, element, builder)
56
+ attrs = render_attrs(element.attributes)
57
+ call = attrs.empty? ? "#{const}.new" : "#{const}.new(#{attrs})"
58
+ kids = meaningful(element.children)
59
+
60
+ if kids.empty?
61
+ builder.line("render #{call}")
62
+ else
63
+ builder.line("render #{call} do")
64
+ builder.indent
65
+ emit_children(kids, builder)
66
+ builder.dedent
67
+ builder.line("end")
68
+ end
69
+ end
70
+
71
+ # Emits a Phlex::Kit-style component call (`Link(href: x) { "Home" }`).
72
+ # Parens are always kept — a bare capitalized name would be a constant.
73
+ # `void: true` components (Input, Checkbox...) never take a block.
74
+ # `extra:` prepends literal arguments (e.g. "variant: :destructive").
75
+ def kit_component(name, element, builder, except: [], void: false, extra: nil)
76
+ attrs = [extra, render_attrs(element.attributes, except: except)]
77
+ .compact.reject(&:empty?).join(", ")
78
+ call = "#{name}(#{attrs})"
79
+ kids = void ? [] : meaningful(element.children)
80
+
81
+ if kids.empty?
82
+ builder.line(call)
83
+ elsif kids.length == 1 && inlineable?(kids.first)
84
+ builder.line("#{call} { #{inline_value(kids.first)} }")
85
+ else
86
+ builder.line("#{call} do")
87
+ builder.indent
88
+ emit_children(kids, builder)
89
+ builder.dedent
90
+ builder.line("end")
91
+ end
92
+ end
93
+
94
+ # Emits a component that wraps the given children with no attributes:
95
+ # `Name { inline }` for a single inlineable child, else a do/end block.
96
+ # Handy for ComponentMap emitters that nest content components (e.g.
97
+ # `AlertDescription { notice }`).
98
+ def component_block(name, children, builder)
99
+ kids = meaningful(children)
100
+
101
+ if kids.empty?
102
+ builder.line(name)
103
+ elsif kids.length == 1 && inlineable?(kids.first)
104
+ builder.line("#{name} { #{inline_value(kids.first)} }")
105
+ else
106
+ builder.line("#{name} do")
107
+ builder.indent
108
+ emit_children(kids, builder)
109
+ builder.dedent
110
+ builder.line("end")
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def emit_node(node, builder)
117
+ case node
118
+ when Nodes::Element then emit_element_or_component(node, builder)
119
+ when Nodes::Text then emit_text(node, builder)
120
+ when Nodes::RawText then emit_raw_text(node, builder)
121
+ when Nodes::Output then emit_output(node, builder)
122
+ when Nodes::Statement then emit_statement(node, builder)
123
+ when Nodes::Control then emit_control(node, builder)
124
+ when Nodes::Comment then emit_comment(node, builder)
125
+ when Nodes::Doctype then emit_doctype(node, builder)
126
+ end
127
+ end
128
+
129
+ def emit_element_or_component(node, builder)
130
+ emitter = config.component_map.lookup(node)
131
+ return emitter.call(node, self, builder) if emitter
132
+
133
+ emit_element(node, builder)
134
+ end
135
+
136
+ def emit_element(node, builder)
137
+ method = element_method(node.name)
138
+ attrs = render_attrs(node.attributes)
139
+ call = attrs.empty? ? method : "#{method}(#{attrs})"
140
+ kids = meaningful(node.children)
141
+
142
+ if kids.empty?
143
+ builder.line(call)
144
+ elsif kids.length == 1 && inlineable?(kids.first)
145
+ builder.line("#{call} { #{inline_value(kids.first)} }")
146
+ else
147
+ builder.line("#{call} do")
148
+ builder.indent
149
+ emit_children(kids, builder)
150
+ builder.dedent
151
+ builder.line("end")
152
+ end
153
+ end
154
+
155
+ def emit_text(node, builder)
156
+ # Collapse runs of whitespace to a single space but keep meaningful
157
+ # leading/trailing spaces so inline text ("Hello, ") stays readable.
158
+ content = node.content.gsub(/\s+/, " ")
159
+ return if content.strip.empty?
160
+
161
+ builder.line("plain #{ruby_string(content)}")
162
+ end
163
+
164
+ def emit_raw_text(node, builder)
165
+ content = node.content.strip
166
+ return if content.empty?
167
+
168
+ builder.line("# TODO: move inline script/style to an asset or helper")
169
+ builder.line(config.raw_call(ruby_string(node.content)))
170
+ end
171
+
172
+ def emit_output(node, builder)
173
+ code = sanitize_code(node.code)
174
+ return if FormBuilder.transform(code, self, builder)
175
+ return if RailsHelpers.transform(code, node, self, builder)
176
+
177
+ if node.raw
178
+ builder.line(config.raw_call(code))
179
+ else
180
+ builder.line("plain(#{code})")
181
+ end
182
+ end
183
+
184
+ def emit_statement(node, builder)
185
+ builder.line(sanitize_code(node.code))
186
+ end
187
+
188
+ def emit_control(node, builder)
189
+ if config.ruby_ui? && node.branches.length == 1 &&
190
+ (form = FormBuilder.form_scope(sanitize_code(node.branches.first.header)))
191
+ return emit_form_control(node, form, builder)
192
+ end
193
+
194
+ node.branches.each do |branch|
195
+ builder.line(sanitize_code(branch.header))
196
+ builder.indent
197
+ emit_children(meaningful(branch.children), builder)
198
+ builder.dedent
199
+ end
200
+ builder.line("end")
201
+ end
202
+
203
+ # A form_with/form_for block whose fields map to RubyUI components. The
204
+ # block variable (`|form|`) is dropped unless an unmapped `form.*` call needs it.
205
+ def emit_form_control(node, form, builder)
206
+ branch = node.branches.first
207
+ header = sanitize_code(branch.header)
208
+ header = strip_block_var(header) unless FormBuilder.needs_block_var?(form[:var], collect_codes(branch.children))
209
+
210
+ @form_stack.push(form)
211
+ builder.line(header)
212
+ builder.indent
213
+ emit_children(meaningful(branch.children), builder)
214
+ builder.dedent
215
+ builder.line("end")
216
+ ensure
217
+ @form_stack.pop
218
+ end
219
+
220
+ def strip_block_var(header)
221
+ header.sub(/(\bdo\b)\s*\|[^|]*\|/, '\1')
222
+ end
223
+
224
+ # All Output/Statement codes anywhere under these nodes (used to decide
225
+ # whether the form block variable is still referenced).
226
+ def collect_codes(nodes, acc = [])
227
+ nodes.each do |node|
228
+ case node
229
+ when Nodes::Output, Nodes::Statement then acc << node.code
230
+ when Nodes::Control then node.branches.each { |b| collect_codes(b.children, acc) }
231
+ when Nodes::Element then collect_codes(node.children, acc)
232
+ end
233
+ end
234
+ acc
235
+ end
236
+
237
+ def emit_comment(node, builder)
238
+ if node.html
239
+ text = node.text.strip
240
+ builder.line("comment { #{ruby_string(text)} }") unless text.empty?
241
+ else
242
+ node.text.to_s.each_line { |line| builder.line("# #{line.chomp}") }
243
+ end
244
+ end
245
+
246
+ def emit_doctype(node, builder)
247
+ builder.line("doctype") if node.value =~ /doctype/i
248
+ end
249
+
250
+ # --- attribute helpers -------------------------------------------------
251
+
252
+ def attr_pair(name, parts)
253
+ if name == :__splat__
254
+ code = parts.find { |kind, _| kind == :erb }&.dig(1)&.value
255
+ return "**(#{code})"
256
+ end
257
+
258
+ "#{attr_key(name)}: #{attr_value(parts)}"
259
+ end
260
+
261
+ def attr_key(name)
262
+ name =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ ? name : name.inspect
263
+ end
264
+
265
+ def attr_value(parts)
266
+ return "true" if parts.nil?
267
+
268
+ if parts.length == 1 && parts[0][0] == :erb && parts[0][1].type == :output
269
+ bare_expression(sanitize_code(parts[0][1].value))
270
+ elsif parts.all? { |kind, _| kind == :text }
271
+ ruby_string(parts.map { |_, value| value }.join)
272
+ else
273
+ interpolated(parts)
274
+ end
275
+ end
276
+
277
+ # Expressions with whitespace (`dom_id user`, `a ? b : c`) would parse
278
+ # incorrectly inside the attribute argument list, so wrap them in parens.
279
+ def bare_expression(code)
280
+ code =~ /\s/ ? "(#{code})" : code
281
+ end
282
+
283
+ def interpolated(parts)
284
+ buffer = +'"'
285
+ parts.each do |kind, value|
286
+ if kind == :text
287
+ buffer << escape_inner(value)
288
+ else
289
+ buffer << "\#{#{sanitize_code(value.value)}}"
290
+ end
291
+ end
292
+ buffer << '"'
293
+ buffer
294
+ end
295
+
296
+ # --- misc helpers ------------------------------------------------------
297
+
298
+ def element_method(name)
299
+ name.to_s.downcase
300
+ end
301
+
302
+ def inlineable?(node)
303
+ node.is_a?(Nodes::Text) ||
304
+ (node.is_a?(Nodes::Output) && !node.raw && !special_output?(node.code))
305
+ end
306
+
307
+ def inline_value(node)
308
+ if node.is_a?(Nodes::Text)
309
+ ruby_string(node.content.strip)
310
+ else
311
+ sanitize_code(node.code)
312
+ end
313
+ end
314
+
315
+ def special_output?(code)
316
+ stripped = code.strip
317
+ return true if stripped.start_with?("render", "yield")
318
+ return true if FormBuilder.form_field?(stripped, current_form)
319
+
320
+ RailsHelpers.html_helper?(stripped)
321
+ end
322
+
323
+ def sanitize_code(code)
324
+ code = code.to_s
325
+ .gsub(/local_assigns\.fetch\(:(\w+)[^)]*\)/, '\1')
326
+ .gsub(/local_assigns\[:(\w+)\]/, '\1')
327
+ .strip
328
+ code = rewrite_locals_to_ivars(code) if literal_locals.any?
329
+ code
330
+ end
331
+
332
+ # With --literal, props set ivars and generate no readers, so every bare
333
+ # reference to a detected local must become `@local`. Safe by design:
334
+ # LocalsDetector only reports names that are never assigned or shadowed by
335
+ # block params anywhere in the template (read-only identifiers).
336
+ def literal_locals
337
+ @literal_locals ||=
338
+ if config.literal? && template&.partial?
339
+ template.locals
340
+ else
341
+ []
342
+ end
343
+ end
344
+
345
+ # Token-level rewrite via Ripper (lossless lexer): hash keys (`user:`) lex
346
+ # as :on_label, symbols are preceded by :on_symbeg, string contents are
347
+ # :on_tstring_content — none are :on_ident, so they're naturally skipped.
348
+ # Interpolated code inside strings lexes as regular idents and is rewritten.
349
+ def rewrite_locals_to_ivars(code)
350
+ tokens = Ripper.lex(code)
351
+ return code if tokens.nil? || tokens.empty?
352
+
353
+ tokens.each_with_index.map do |(_, type, tok, _), index|
354
+ next tok unless type == :on_ident && literal_locals.include?(tok)
355
+ next tok if method_call_token?(tokens, index)
356
+
357
+ "@#{tok}"
358
+ end.join
359
+ rescue StandardError
360
+ code
361
+ end
362
+
363
+ # True when the ident is a method call (`x.user`, `x&.user`, `user(...)`)
364
+ # or a symbol (`:user`) rather than a bare local reference.
365
+ def method_call_token?(tokens, index)
366
+ prev = significant_token(tokens, index, -1)
367
+ return true if prev && (prev[1] == :on_period || prev[1] == :on_symbeg ||
368
+ (prev[1] == :on_op && ["&.", "::"].include?(prev[2])))
369
+
370
+ following = significant_token(tokens, index, 1)
371
+ following && following[1] == :on_lparen
372
+ end
373
+
374
+ def significant_token(tokens, index, step)
375
+ index += step
376
+ while index >= 0 && index < tokens.length
377
+ return tokens[index] unless %i[on_sp on_ignored_nl on_nl].include?(tokens[index][1])
378
+
379
+ index += step
380
+ end
381
+ nil
382
+ end
383
+
384
+ def ruby_string(string)
385
+ "\"#{escape_inner(string)}\""
386
+ end
387
+
388
+ def escape_inner(string)
389
+ string.to_s.gsub(/[\\"\n\t\r]|\#\{/) do |match|
390
+ {
391
+ "\\" => "\\\\",
392
+ "\"" => "\\\"",
393
+ "\n" => "\\n",
394
+ "\t" => "\\t",
395
+ "\r" => "\\r",
396
+ "\#{" => "\\\#{"
397
+ }[match]
398
+ end
399
+ end
400
+ end
401
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "strscan"
5
+
6
+ require_relative "ruby_ui_converter/version"
7
+ require_relative "ruby_ui_converter/nodes"
8
+ require_relative "ruby_ui_converter/lexer"
9
+ require_relative "ruby_ui_converter/html_tokenizer"
10
+ require_relative "ruby_ui_converter/parser"
11
+ require_relative "ruby_ui_converter/code_builder"
12
+ require_relative "ruby_ui_converter/naming"
13
+ require_relative "ruby_ui_converter/rails_helpers"
14
+ require_relative "ruby_ui_converter/form_builder"
15
+ require_relative "ruby_ui_converter/locals_detector"
16
+ require_relative "ruby_ui_converter/component_map"
17
+ require_relative "ruby_ui_converter/configuration"
18
+ require_relative "ruby_ui_converter/transformer"
19
+ require_relative "ruby_ui_converter/template"
20
+ require_relative "ruby_ui_converter/file_walker"
21
+ require_relative "ruby_ui_converter/converter"
22
+ require_relative "ruby_ui_converter/doctor"
23
+
24
+ module RubyUIConverter
25
+ class Error < StandardError; end
26
+
27
+ # Convert a single .erb string into Ruby/Phlex source (no file IO).
28
+ #
29
+ # RubyUIConverter.convert_string("<h1><%= @title %></h1>")
30
+ def self.convert_string(source, class_name: "Component", base_namespace: "",
31
+ base_class: "Phlex::HTML", **opts)
32
+ config = Configuration.new(base_namespace: base_namespace, base_class: base_class, **opts)
33
+ document = Parser.new(source).parse
34
+ builder = CodeBuilder.new(indent: config.indent)
35
+ builder.line("class #{class_name} < #{config.base_class}")
36
+ builder.indent
37
+ builder.line("def #{config.template_method}")
38
+ builder.indent
39
+ Transformer.new(config: config).emit(document, builder)
40
+ builder.dedent
41
+ builder.line("end")
42
+ builder.dedent
43
+ builder.line("end")
44
+ builder.to_s
45
+ end
46
+
47
+ # Convert files under a path, writing .rb files. Returns Converter::Result[].
48
+ #
49
+ # RubyUIConverter.convert("app/views/users")
50
+ def self.convert(path, **opts)
51
+ config = Configuration.new(**opts)
52
+ Converter.new(path, config: config).run
53
+ end
54
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_ui_converter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jackson Pires
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ description: |
55
+ ruby_ui_converter walks a Rails views directory recursively and converts each
56
+ .erb template into an equivalent .rb file written with Phlex (and RubyUI when
57
+ configured). Traditional Rails partials (_partial.html.erb) are converted into
58
+ their own Phlex component classes.
59
+ email:
60
+ - jackson@linkana.com
61
+ executables:
62
+ - ruby_ui_converter
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - LICENSE.txt
68
+ - README.md
69
+ - exe/ruby_ui_converter
70
+ - lib/ruby_ui_converter.rb
71
+ - lib/ruby_ui_converter/cli.rb
72
+ - lib/ruby_ui_converter/code_builder.rb
73
+ - lib/ruby_ui_converter/component_map.rb
74
+ - lib/ruby_ui_converter/configuration.rb
75
+ - lib/ruby_ui_converter/converter.rb
76
+ - lib/ruby_ui_converter/doctor.rb
77
+ - lib/ruby_ui_converter/file_walker.rb
78
+ - lib/ruby_ui_converter/form_builder.rb
79
+ - lib/ruby_ui_converter/html_tokenizer.rb
80
+ - lib/ruby_ui_converter/lexer.rb
81
+ - lib/ruby_ui_converter/locals_detector.rb
82
+ - lib/ruby_ui_converter/naming.rb
83
+ - lib/ruby_ui_converter/nodes.rb
84
+ - lib/ruby_ui_converter/parser.rb
85
+ - lib/ruby_ui_converter/rails_helpers.rb
86
+ - lib/ruby_ui_converter/template.rb
87
+ - lib/ruby_ui_converter/transformer.rb
88
+ - lib/ruby_ui_converter/version.rb
89
+ homepage: https://github.com/jacksonpires/ruby_ui_converter
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/jacksonpires/ruby_ui_converter
94
+ source_code_uri: https://github.com/jacksonpires/ruby_ui_converter
95
+ changelog_uri: https://github.com/jacksonpires/ruby_ui_converter/blob/main/CHANGELOG.md
96
+ rubygems_mfa_required: 'true'
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.0.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 4.0.10
112
+ specification_version: 4
113
+ summary: Convert Rails .erb views and partials into RubyUI/Phlex Ruby components.
114
+ test_files: []