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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Builds a unified HTML + ERB tree from a source template.
5
+ #
6
+ # The tricky part is that HTML nesting (tags) and Ruby nesting (if/each/do
7
+ # ... end) interleave. We track both on a single stack and pop tolerantly so
8
+ # that well-formed templates produce a correct tree and slightly malformed
9
+ # ones degrade gracefully instead of raising.
10
+ class Parser
11
+ def initialize(source)
12
+ @html, @registry = Lexer.new(source).tokenize_with_placeholders
13
+ end
14
+
15
+ def parse
16
+ root = Nodes::Document.new
17
+ stack = [root]
18
+
19
+ HtmlTokenizer.new(@html).tokens.each do |token|
20
+ dispatch(token, stack)
21
+ end
22
+
23
+ root
24
+ end
25
+
26
+ private
27
+
28
+ def dispatch(token, stack)
29
+ case token[0]
30
+ when :text
31
+ emit_text(token[1], stack)
32
+ when :html_comment
33
+ append(stack, Nodes::Comment.new(text: strip_placeholders(token[1]), html: true))
34
+ when :doctype
35
+ append(stack, Nodes::Doctype.new(value: token[1]))
36
+ when :open
37
+ element = Nodes::Element.new(name: token[1], attributes: build_attrs(token[2]))
38
+ append(stack, element)
39
+ stack.push(element)
40
+ when :selfclose
41
+ append(stack, Nodes::Element.new(name: token[1], attributes: build_attrs(token[2]), self_closing: true))
42
+ when :raw_element
43
+ element = Nodes::Element.new(name: token[1], attributes: build_attrs(token[2]))
44
+ element.children << Nodes::RawText.new(content: token[3]) unless token[3].to_s.strip.empty?
45
+ append(stack, element)
46
+ when :close
47
+ close_element(stack, token[1])
48
+ end
49
+ end
50
+
51
+ # --- tree helpers ------------------------------------------------------
52
+
53
+ def append(stack, node)
54
+ container(stack.last) << node
55
+ end
56
+
57
+ def container(node)
58
+ case node
59
+ when Nodes::Control
60
+ node.branches.last.children
61
+ else
62
+ node.children
63
+ end
64
+ end
65
+
66
+ def close_element(stack, name)
67
+ target = name.to_s.downcase
68
+ index = stack.rindex { |node| node.is_a?(Nodes::Element) && node.name.to_s.downcase == target }
69
+ stack.slice!(index..) if index && index.positive?
70
+ end
71
+
72
+ def close_control(stack)
73
+ while stack.size > 1
74
+ node = stack.pop
75
+ break if node.is_a?(Nodes::Control)
76
+ end
77
+ end
78
+
79
+ # --- text + ERB --------------------------------------------------------
80
+
81
+ def emit_text(text, stack)
82
+ split_parts(text).each do |kind, value|
83
+ if kind == :text
84
+ append(stack, Nodes::Text.new(content: value)) unless value.empty?
85
+ else
86
+ emit_erb(value, stack)
87
+ end
88
+ end
89
+ end
90
+
91
+ def emit_erb(token, stack)
92
+ case token.type
93
+ when :output
94
+ if block_opener?(token.value)
95
+ control = Nodes::Control.new(block: true, output: true)
96
+ control.branches << Nodes::Branch.new(header: token.value)
97
+ append(stack, control)
98
+ stack.push(control)
99
+ else
100
+ append(stack, Nodes::Output.new(code: token.value, raw: token.raw))
101
+ end
102
+ when :comment
103
+ append(stack, Nodes::Comment.new(text: token.value, html: false))
104
+ when :eval
105
+ handle_eval(token.value, stack)
106
+ end
107
+ end
108
+
109
+ def handle_eval(code, stack)
110
+ case classify(code)
111
+ when :opener
112
+ control = Nodes::Control.new(block: block_opener?(code))
113
+ control.branches << Nodes::Branch.new(header: code)
114
+ append(stack, control)
115
+ stack.push(control)
116
+ when :mid
117
+ control = stack.reverse.find { |node| node.is_a?(Nodes::Control) }
118
+ if control
119
+ control.branches << Nodes::Branch.new(header: code)
120
+ stack.pop until stack.last.equal?(control) || stack.size <= 1
121
+ else
122
+ append(stack, Nodes::Statement.new(code: code))
123
+ end
124
+ when :close
125
+ close_control(stack)
126
+ else
127
+ append(stack, Nodes::Statement.new(code: code))
128
+ end
129
+ end
130
+
131
+ def classify(code)
132
+ stripped = code.strip
133
+ return :close if stripped == "end"
134
+ return :mid if stripped =~ /\A(else|elsif|when|in|rescue|ensure)\b/
135
+ return :opener if stripped =~ /\A(if|unless|case|while|until|for|begin)\b/
136
+ return :opener if block_opener?(stripped)
137
+
138
+ :statement
139
+ end
140
+
141
+ def block_opener?(code)
142
+ code =~ /\bdo\s*(\|[^|]*\|)?\s*\z/ ? true : false
143
+ end
144
+
145
+ # --- attributes + placeholders ----------------------------------------
146
+
147
+ def build_attrs(raw_attrs)
148
+ raw_attrs.map do |name, value|
149
+ if name.include?("RUCxERBx") && value.nil?
150
+ [:__splat__, split_parts(name)]
151
+ else
152
+ [name, value.nil? ? nil : split_parts(value)]
153
+ end
154
+ end
155
+ end
156
+
157
+ def split_parts(string)
158
+ parts = []
159
+ pos = 0
160
+ str = string.to_s
161
+
162
+ str.scan(Lexer::PLACEHOLDER_PATTERN) do
163
+ match = Regexp.last_match
164
+ parts << [:text, str[pos...match.begin(0)]] if match.begin(0) > pos
165
+ parts << [:erb, @registry.fetch(Lexer.placeholder(match[1]))]
166
+ pos = match.end(0)
167
+ end
168
+
169
+ parts << [:text, str[pos..]] if pos < str.length
170
+ parts
171
+ end
172
+
173
+ def strip_placeholders(string)
174
+ split_parts(string).map do |kind, value|
175
+ kind == :text ? value : value.value.to_s
176
+ end.join
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Best-effort translation of common Rails view helpers found inside <%= %>
5
+ # into Phlex/RubyUI equivalents. Anything not understood is emitted as a bare
6
+ # call (phlex-rails registers these as output helpers that write to the buffer
7
+ # themselves) or, for the few that return a string, through a raw call.
8
+ module RailsHelpers
9
+ module_function
10
+
11
+ # Rails helpers that produce HTML. Under phlex-rails these are output
12
+ # helpers (they write to the buffer and return nil), so an unmapped one is
13
+ # emitted as a bare call — except STRING_HELPERS below, which return a value.
14
+ HTML_HELPERS = %w[
15
+ link_to button_to image_tag video_tag audio_tag content_tag tag
16
+ form_with form_for fields_for label_tag text_field_tag mail_to
17
+ link_to_unless link_to_if sanitize simple_format raw safe_join
18
+ time_tag favicon_link_tag stylesheet_link_tag javascript_include_tag
19
+ ].freeze
20
+
21
+ # The subset of HTML_HELPERS that RETURN a string instead of writing to the
22
+ # Phlex buffer, so they must be wrapped in a raw call to appear in the output.
23
+ STRING_HELPERS = %w[sanitize safe_join raw strip_tags].freeze
24
+
25
+ # Helpers that return plain (already-escaped or scalar) values.
26
+ KNOWN_HELPERS = %w[
27
+ t l translate localize number_to_currency number_with_delimiter
28
+ number_to_percentage pluralize truncate current_user current_page?
29
+ params session flash request cookies asset_path image_path url_for
30
+ polymorphic_path time_ago_in_words distance_of_time_in_words
31
+ dom_id dom_class notice alert content_for cycle render
32
+ ].freeze
33
+
34
+ # Attempts to emit a transformed helper. Returns true when it wrote
35
+ # something to the builder, false otherwise.
36
+ def transform(code, node, transformer, builder)
37
+ stripped = code.strip
38
+
39
+ if stripped == "yield" || stripped =~ /\Ayield\b/
40
+ builder.line(stripped)
41
+ return true
42
+ end
43
+
44
+ if stripped.start_with?("render")
45
+ rendered = safe { render_call(stripped, transformer) }
46
+ # phlex-rails' #render handles model objects / relations and writes to
47
+ # the buffer itself, so the object/collection fallback is a bare call.
48
+ builder.line(rendered || stripped)
49
+ return true
50
+ end
51
+
52
+ if stripped.start_with?("link_to")
53
+ rendered = safe { link_to_call(stripped, ruby_ui: transformer.config.ruby_ui?) }
54
+ return builder.line(rendered) && true if rendered
55
+ end
56
+
57
+ if stripped.start_with?("image_tag")
58
+ rendered = safe { image_tag_call(stripped) }
59
+ return builder.line(rendered) && true if rendered
60
+ end
61
+
62
+ if stripped.start_with?("content_tag")
63
+ rendered = safe { content_tag_call(stripped) }
64
+ return builder.line(rendered) && true if rendered
65
+ end
66
+
67
+ if html_helper?(stripped)
68
+ # Output helpers write to the buffer (bare call); string-returning ones
69
+ # need a raw call to be emitted.
70
+ builder.line(string_helper?(stripped) ? transformer.config.raw_call(stripped) : stripped)
71
+ return true
72
+ end
73
+
74
+ false
75
+ end
76
+
77
+ def html_helper?(code)
78
+ matches_helper?(code, HTML_HELPERS)
79
+ end
80
+
81
+ def string_helper?(code)
82
+ matches_helper?(code, STRING_HELPERS)
83
+ end
84
+
85
+ def matches_helper?(code, helpers)
86
+ helpers.any? do |helper|
87
+ code == helper || code.start_with?("#{helper}(") || code.start_with?("#{helper} ")
88
+ end
89
+ end
90
+
91
+ # render "shared/header" / render partial: "x", locals: {..} / render "form", a: 1
92
+ def render_call(code, transformer)
93
+ rest = strip_parens(code.sub(/\Arender\b/, "").strip)
94
+ args = split_args(rest)
95
+ return nil if args.empty?
96
+
97
+ first = args[0]
98
+ first = Regexp.last_match(1).strip if first =~ /\Apartial:\s*(.+)\z/m
99
+
100
+ match = first.match(/\A["']([^"']+)["']\z/)
101
+ return nil unless match
102
+
103
+ const = Naming.partial_const(
104
+ match[1],
105
+ base_namespace: transformer.base_namespace,
106
+ current_namespace_parts: transformer.current_namespace_parts
107
+ )
108
+ locals = build_locals(args[1..])
109
+ locals ? "render #{const}.new(#{locals})" : "render #{const}.new"
110
+ end
111
+
112
+ def build_locals(arg_list)
113
+ return nil if arg_list.nil? || arg_list.empty?
114
+
115
+ pairs = arg_list.map do |arg|
116
+ if arg =~ /\Alocals:\s*\{(.*)\}\z/m
117
+ Regexp.last_match(1).strip
118
+ else
119
+ arg
120
+ end
121
+ end
122
+
123
+ result = pairs.reject(&:empty?).join(", ")
124
+ result.empty? ? nil : result
125
+ end
126
+
127
+ # link_to "Text", path, class: "x" -> a(href: path, class: "x") { "Text" }
128
+ # With ruby_ui enabled -> Link(href: path, class: "x") { "Text" }
129
+ def link_to_call(code, ruby_ui: false)
130
+ return nil if code =~ /\bdo\b/ # block form handled elsewhere
131
+
132
+ rest = strip_parens(code.sub(/\Alink_to\b/, "").strip)
133
+ args = split_args(rest)
134
+ return nil if args.length < 2
135
+
136
+ text, path, *options = args
137
+ attrs = ["href: #{link_target(path)}"] + options
138
+ call = ruby_ui ? "Link" : "a"
139
+ "#{call}(#{attrs.join(", ")}) { #{text} }"
140
+ end
141
+
142
+ # Targets that are not strings or route helper calls (e.g. a record:
143
+ # `link_to "Show", user`) need url_for to resolve to a path.
144
+ def link_target(path)
145
+ return path if path.start_with?('"', "'") # literal string
146
+ return path if path =~ /\A(\w+_)?(path|url)\b/ # route helper call
147
+
148
+ "url_for(#{path})"
149
+ end
150
+
151
+ # image_tag "logo.png", alt: "Logo" -> img(src: "logo.png", alt: "Logo")
152
+ def image_tag_call(code)
153
+ rest = strip_parens(code.sub(/\Aimage_tag\b/, "").strip)
154
+ args = split_args(rest)
155
+ return nil if args.empty?
156
+
157
+ src, *options = args
158
+ attrs = ["src: #{src}"] + options
159
+ "img(#{attrs.join(", ")})"
160
+ end
161
+
162
+ # content_tag(:div, "hi", class: "x") -> div(class: "x") { "hi" }
163
+ def content_tag_call(code)
164
+ rest = strip_parens(code.sub(/\Acontent_tag\b/, "").strip)
165
+ args = split_args(rest)
166
+ return nil if args.length < 2
167
+
168
+ name = args[0].sub(/\A:/, "").gsub(/['"]/, "")
169
+ content = args[1]
170
+ options = args[2..] || []
171
+ call = options.empty? ? name : "#{name}(#{options.join(", ")})"
172
+ "#{call} { #{content} }"
173
+ end
174
+
175
+ # Splits a top-level argument list, respecting strings and nested brackets.
176
+ def split_args(string)
177
+ args = []
178
+ depth = 0
179
+ current = +""
180
+ quote = nil
181
+
182
+ string.each_char do |char|
183
+ if quote
184
+ current << char
185
+ quote = nil if char == quote
186
+ next
187
+ end
188
+
189
+ case char
190
+ when '"', "'"
191
+ quote = char
192
+ current << char
193
+ when "(", "[", "{"
194
+ depth += 1
195
+ current << char
196
+ when ")", "]", "}"
197
+ depth -= 1
198
+ current << char
199
+ when ","
200
+ if depth.zero?
201
+ args << current.strip
202
+ current = +""
203
+ else
204
+ current << char
205
+ end
206
+ else
207
+ current << char
208
+ end
209
+ end
210
+
211
+ args << current.strip unless current.strip.empty?
212
+ args
213
+ end
214
+
215
+ def strip_parens(string)
216
+ stripped = string.strip
217
+ if stripped.start_with?("(") && stripped.end_with?(")")
218
+ stripped[1..-2].strip
219
+ else
220
+ stripped
221
+ end
222
+ end
223
+
224
+ def safe
225
+ yield
226
+ rescue StandardError
227
+ nil
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Represents a single .erb file and knows how to render its .rb equivalent.
5
+ class Template
6
+ attr_reader :path, :root, :config
7
+
8
+ def initialize(path:, root:, config:)
9
+ @path = path
10
+ @root = root.to_s
11
+ @config = config
12
+ end
13
+
14
+ def source
15
+ @source ||= File.read(path)
16
+ end
17
+
18
+ def filename
19
+ File.basename(path)
20
+ end
21
+
22
+ # "index.html.erb" -> "index"; "_form.html.erb" -> "_form"
23
+ def basename
24
+ filename.split(".").first
25
+ end
26
+
27
+ def partial?
28
+ filename.start_with?("_")
29
+ end
30
+
31
+ def rel_path
32
+ expanded = File.expand_path(path)
33
+ base = File.expand_path(root)
34
+ expanded.delete_prefix(base).sub(%r{\A/}, "")
35
+ end
36
+
37
+ def dir_rel
38
+ dir = File.dirname(rel_path)
39
+ dir == "." ? "" : dir
40
+ end
41
+
42
+ def class_name
43
+ Naming.class_name(basename)
44
+ end
45
+
46
+ def namespace_parts
47
+ Naming.namespace_parts(dir_rel, config.base_namespace)
48
+ end
49
+
50
+ def full_const
51
+ (namespace_parts + [class_name]).join("::")
52
+ end
53
+
54
+ def output_filename
55
+ "#{basename.sub(/\A_/, "")}.rb"
56
+ end
57
+
58
+ def output_path
59
+ target_dir =
60
+ if config.output_root
61
+ File.join(config.output_root, dir_rel)
62
+ else
63
+ File.dirname(File.expand_path(path))
64
+ end
65
+
66
+ File.join(target_dir, output_filename)
67
+ end
68
+
69
+ def tree
70
+ @tree ||= Parser.new(source).parse
71
+ end
72
+
73
+ def locals
74
+ @locals ||= partial? ? LocalsDetector.new(tree).locals : []
75
+ end
76
+
77
+ # Top-level views read controller instance variables (`@products`); partials
78
+ # take their data as bare locals instead.
79
+ def ivars
80
+ @ivars ||= partial? ? [] : LocalsDetector.new(tree).ivars
81
+ end
82
+
83
+ # Keyword arguments the generated initializer/props expose: bare locals for
84
+ # partials (referenced through attr_readers), controller ivars for top-level
85
+ # views (referenced directly as @ivars).
86
+ def init_args
87
+ partial? ? locals : ivars
88
+ end
89
+
90
+ def render
91
+ builder = CodeBuilder.new(indent: config.indent)
92
+ builder.line("# frozen_string_literal: true")
93
+ builder.line
94
+ builder.line("# Auto-generated by ruby_ui_converter")
95
+ builder.line("# Source: #{rel_path}")
96
+ builder.line
97
+
98
+ namespace_parts.each do |segment|
99
+ builder.line("module #{segment}")
100
+ builder.indent
101
+ end
102
+
103
+ builder.line("class #{class_name} < #{config.base_class}")
104
+ builder.indent
105
+
106
+ emit_initializer(builder)
107
+
108
+ builder.line("def #{config.template_method}")
109
+ builder.indent
110
+ Transformer.new(config: config, template: self).emit(tree, builder)
111
+ builder.dedent
112
+ builder.line("end")
113
+
114
+ emit_readers(builder)
115
+
116
+ builder.dedent
117
+ builder.line("end")
118
+
119
+ namespace_parts.each do
120
+ builder.dedent
121
+ builder.line("end")
122
+ end
123
+
124
+ builder.to_s
125
+ end
126
+
127
+ private
128
+
129
+ def emit_initializer(builder)
130
+ return emit_props(builder) if config.literal?
131
+ return if init_args.empty?
132
+
133
+ args = init_args.map { |name| "#{name}: nil" }.join(", ")
134
+ builder.line("def initialize(#{args})")
135
+ builder.indent
136
+ init_args.each { |name| builder.line("@#{name} = #{name}") }
137
+ builder.dedent
138
+ builder.line("end")
139
+ builder.line
140
+ end
141
+
142
+ # Literal::Properties props instead of initialize/attr_reader. The local
143
+ # matching the partial's name gets an inferred model type; the rest stay
144
+ # permissive (_Any? accepts anything, including nil).
145
+ def emit_props(builder)
146
+ return if init_args.empty?
147
+
148
+ init_args.each { |name| builder.line("prop :#{name}, #{prop_type(name)}") }
149
+ builder.line
150
+ end
151
+
152
+ def prop_type(local)
153
+ if local == basename.sub(/\A_/, "")
154
+ "_Nilable(#{Naming.camelize(local)})"
155
+ else
156
+ "_Any?"
157
+ end
158
+ end
159
+
160
+ def emit_readers(builder)
161
+ return unless partial? && locals.any?
162
+ return if config.literal? # props set ivars; the body uses @locals
163
+
164
+ builder.line
165
+ builder.line("private")
166
+ builder.line
167
+ builder.line("attr_reader #{locals.map { |local| ":#{local}" }.join(", ")}")
168
+ end
169
+ end
170
+ end