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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +73 -0
- data/LICENSE.txt +21 -0
- data/README.md +487 -0
- data/exe/ruby_ui_converter +7 -0
- data/lib/ruby_ui_converter/cli.rb +179 -0
- data/lib/ruby_ui_converter/code_builder.rb +33 -0
- data/lib/ruby_ui_converter/component_map.rb +125 -0
- data/lib/ruby_ui_converter/configuration.rb +53 -0
- data/lib/ruby_ui_converter/converter.rb +76 -0
- data/lib/ruby_ui_converter/doctor.rb +190 -0
- data/lib/ruby_ui_converter/file_walker.rb +22 -0
- data/lib/ruby_ui_converter/form_builder.rb +252 -0
- data/lib/ruby_ui_converter/html_tokenizer.rb +109 -0
- data/lib/ruby_ui_converter/lexer.rb +58 -0
- data/lib/ruby_ui_converter/locals_detector.rb +111 -0
- data/lib/ruby_ui_converter/naming.rb +45 -0
- data/lib/ruby_ui_converter/nodes.rb +128 -0
- data/lib/ruby_ui_converter/parser.rb +179 -0
- data/lib/ruby_ui_converter/rails_helpers.rb +230 -0
- data/lib/ruby_ui_converter/template.rb +170 -0
- data/lib/ruby_ui_converter/transformer.rb +401 -0
- data/lib/ruby_ui_converter/version.rb +5 -0
- data/lib/ruby_ui_converter.rb +54 -0
- metadata +114 -0
|
@@ -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
|