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,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIConverter
|
|
4
|
+
# Translates Rails form-builder field calls (`form.text_field :name, ...`)
|
|
5
|
+
# found inside a `form_with` / `form_for` block into RubyUI form components
|
|
6
|
+
# (`Input`, `Textarea`, `Checkbox`, `FormFieldLabel`, `Button`), following the
|
|
7
|
+
# convention of building `name`/`id` as `"model[attr]"` and `value` as
|
|
8
|
+
# `model.attr`.
|
|
9
|
+
#
|
|
10
|
+
# Only active when `ruby_ui?` is on and the enclosing form has a determinable
|
|
11
|
+
# model, so the name/value can be reconstructed; otherwise the calls are left
|
|
12
|
+
# untouched (and the block keeps its `|form|` builder variable).
|
|
13
|
+
module FormBuilder
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# form field method -> input type (nil = no explicit type, like text_field)
|
|
17
|
+
INPUT_TYPES = {
|
|
18
|
+
"text_field" => nil,
|
|
19
|
+
"email_field" => "email",
|
|
20
|
+
"password_field" => "password",
|
|
21
|
+
"number_field" => "number",
|
|
22
|
+
"telephone_field" => "tel",
|
|
23
|
+
"phone_field" => "tel",
|
|
24
|
+
"url_field" => "url",
|
|
25
|
+
"search_field" => "search",
|
|
26
|
+
"color_field" => "color",
|
|
27
|
+
"range_field" => "range",
|
|
28
|
+
"date_field" => "date",
|
|
29
|
+
"datetime_field" => "datetime-local",
|
|
30
|
+
"datetime_local_field" => "datetime-local",
|
|
31
|
+
"time_field" => "time",
|
|
32
|
+
"month_field" => "month",
|
|
33
|
+
"week_field" => "week",
|
|
34
|
+
"file_field" => "file"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
TEXTAREA_METHODS = %w[text_area textarea].freeze
|
|
38
|
+
CHECKBOX_METHODS = %w[check_box checkbox].freeze
|
|
39
|
+
|
|
40
|
+
# Every builder method this module knows how to translate.
|
|
41
|
+
def mappable_methods
|
|
42
|
+
INPUT_TYPES.keys + TEXTAREA_METHODS + CHECKBOX_METHODS + %w[label submit collection_select]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Parse a `form_with`/`form_for` block header into a form scope
|
|
46
|
+
# ({var:, model:, param:}) or nil when it isn't a model-bound form we can map.
|
|
47
|
+
def form_scope(header)
|
|
48
|
+
return nil unless header =~ /\A(form_with|form_for)\b/
|
|
49
|
+
|
|
50
|
+
var = header[/\bdo\s*\|\s*(\w+)\s*\|/, 1]
|
|
51
|
+
return nil unless var
|
|
52
|
+
|
|
53
|
+
model = model_expression(header)
|
|
54
|
+
return nil unless model
|
|
55
|
+
|
|
56
|
+
param = model.sub(/\A@/, "")
|
|
57
|
+
return nil unless param =~ /\A\w+\z/
|
|
58
|
+
|
|
59
|
+
{ var: var, model: model, param: param }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def model_expression(header)
|
|
63
|
+
if (model = header[/\bmodel:\s*([^,)]+)/, 1])
|
|
64
|
+
return model.strip
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return nil unless header =~ /\Aform_for\b/
|
|
68
|
+
|
|
69
|
+
rest = RailsHelpers.strip_parens(header.sub(/\Aform_for\b/, "").sub(/\bdo\b.*\z/m, "").strip)
|
|
70
|
+
RailsHelpers.split_args(rest).first&.strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True when the children contain a `form.<var>` call this module won't map,
|
|
74
|
+
# so the block variable must be kept.
|
|
75
|
+
def needs_block_var?(var, codes)
|
|
76
|
+
codes.any? do |code|
|
|
77
|
+
method = code[/\A#{Regexp.escape(var)}\.(\w+)/, 1]
|
|
78
|
+
method && !mappable_methods.include?(method)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# True when the code is a mappable form field call (so it should not be
|
|
83
|
+
# inlined as `{ ... }` but emitted through the field translation).
|
|
84
|
+
def form_field?(code, form)
|
|
85
|
+
return false unless form
|
|
86
|
+
|
|
87
|
+
method = code[/\A#{Regexp.escape(form[:var])}\.(\w+)/, 1]
|
|
88
|
+
method && mappable_methods.include?(method)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Emit a form field call as a RubyUI component. Returns true when handled.
|
|
92
|
+
def transform(code, transformer, builder)
|
|
93
|
+
form = transformer.current_form
|
|
94
|
+
return false unless form
|
|
95
|
+
|
|
96
|
+
method = code[/\A#{Regexp.escape(form[:var])}\.(\w+)/, 1]
|
|
97
|
+
return false unless method
|
|
98
|
+
|
|
99
|
+
rest = code.sub(/\A#{Regexp.escape(form[:var])}\.\w+\s*/, "")
|
|
100
|
+
args = RailsHelpers.split_args(RailsHelpers.strip_parens(rest))
|
|
101
|
+
|
|
102
|
+
# collection_select expands into a NativeSelect with a loop, so it emits
|
|
103
|
+
# its (indented) block directly rather than returning flat lines.
|
|
104
|
+
return emit_collection_select(args, form, builder) if method == "collection_select"
|
|
105
|
+
|
|
106
|
+
lines = build(method, args, form)
|
|
107
|
+
return false unless lines
|
|
108
|
+
|
|
109
|
+
Array(lines).each { |line| builder.line(line) }
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# form.collection_select :category_id, Category.all, :id, :name ->
|
|
114
|
+
# NativeSelect(name:, id:) do
|
|
115
|
+
# Category.all.each do |option|
|
|
116
|
+
# NativeSelectOption(value: option.id, selected: model.category_id == option.id) { option.name }
|
|
117
|
+
# end
|
|
118
|
+
# end
|
|
119
|
+
# FormFieldError { ... }
|
|
120
|
+
# (extra options/html_options beyond the four positionals are not carried over)
|
|
121
|
+
def emit_collection_select(args, form, builder)
|
|
122
|
+
attr = attr_name(args[0])
|
|
123
|
+
value_method = attr_name(args[2])
|
|
124
|
+
text_method = attr_name(args[3])
|
|
125
|
+
return false unless attr && args[1] && value_method && text_method
|
|
126
|
+
|
|
127
|
+
collection = args[1].strip
|
|
128
|
+
builder.line("NativeSelect(#{name_and_id(form, attr)}) do")
|
|
129
|
+
builder.indent
|
|
130
|
+
builder.line("#{collection}.each do |option|")
|
|
131
|
+
builder.indent
|
|
132
|
+
builder.line(
|
|
133
|
+
"NativeSelectOption(value: option.#{value_method}, " \
|
|
134
|
+
"selected: #{form[:model]}.#{attr} == option.#{value_method}) { option.#{text_method} }"
|
|
135
|
+
)
|
|
136
|
+
builder.dedent
|
|
137
|
+
builder.line("end")
|
|
138
|
+
builder.dedent
|
|
139
|
+
builder.line("end")
|
|
140
|
+
builder.line(error_line(form, attr))
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns a line (or array of lines) for the field, or nil when unmappable.
|
|
145
|
+
# Input/textarea/checkbox additionally get a FormFieldError reading the
|
|
146
|
+
# attribute's backend errors, like the RubyUI form convention.
|
|
147
|
+
def build(method, args, form)
|
|
148
|
+
if INPUT_TYPES.key?(method)
|
|
149
|
+
with_error(input_field(method, args, form), args, form)
|
|
150
|
+
elsif TEXTAREA_METHODS.include?(method)
|
|
151
|
+
with_error(textarea_field(args, form), args, form)
|
|
152
|
+
elsif CHECKBOX_METHODS.include?(method)
|
|
153
|
+
with_error(checkbox_field(args, form), args, form)
|
|
154
|
+
elsif method == "label"
|
|
155
|
+
label_field(args, form)
|
|
156
|
+
elsif method == "submit"
|
|
157
|
+
submit_button(args)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def with_error(component, args, form)
|
|
162
|
+
return nil unless component
|
|
163
|
+
|
|
164
|
+
[component, error_line(form, attr_name(args[0]))]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# FormFieldError { product.errors[:name].to_sentence.upcase_first }
|
|
168
|
+
def error_line(form, attr)
|
|
169
|
+
"FormFieldError { #{form[:model]}.errors[:#{attr}].to_sentence.upcase_first }"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def input_field(method, args, form)
|
|
173
|
+
attr = attr_name(args[0])
|
|
174
|
+
return nil unless attr
|
|
175
|
+
|
|
176
|
+
parts = []
|
|
177
|
+
if (type = INPUT_TYPES[method])
|
|
178
|
+
parts << %(type: "#{type}")
|
|
179
|
+
end
|
|
180
|
+
parts << name_and_id(form, attr)
|
|
181
|
+
parts << "value: #{field_value(form, attr)}"
|
|
182
|
+
parts.concat(args[1..] || [])
|
|
183
|
+
"Input(#{parts.join(", ")})"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def textarea_field(args, form)
|
|
187
|
+
attr = attr_name(args[0])
|
|
188
|
+
return nil unless attr
|
|
189
|
+
|
|
190
|
+
parts = [name_and_id(form, attr)].concat(args[1..] || [])
|
|
191
|
+
"Textarea(#{parts.join(", ")}) { #{field_value(form, attr)} }"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# HTML attribute values are strings; calling #to_s keeps Phlex happy for
|
|
195
|
+
# non-string columns (decimal/BigDecimal, integer, date, nil, ...) which it
|
|
196
|
+
# would otherwise reject as invalid attribute values.
|
|
197
|
+
def field_value(form, attr)
|
|
198
|
+
"#{form[:model]}.#{attr}.to_s"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def checkbox_field(args, form)
|
|
202
|
+
attr = attr_name(args[0])
|
|
203
|
+
return nil unless attr
|
|
204
|
+
|
|
205
|
+
parts = [%(value: "1"), name_and_id(form, attr), "checked: #{form[:model]}.#{attr}?"]
|
|
206
|
+
parts.concat(args[1..] || [])
|
|
207
|
+
"Checkbox(#{parts.join(", ")})"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def label_field(args, form)
|
|
211
|
+
attr = attr_name(args[0])
|
|
212
|
+
return nil unless attr
|
|
213
|
+
|
|
214
|
+
text = string_arg?(args[1]) ? args[1].strip : %("#{humanize(attr)}")
|
|
215
|
+
%(FormFieldLabel(for: "#{form[:param]}[#{attr}]") { #{text} })
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def submit_button(args)
|
|
219
|
+
if string_arg?(args[0])
|
|
220
|
+
text = args[0].strip
|
|
221
|
+
opts = args[1..] || []
|
|
222
|
+
else
|
|
223
|
+
text = '"Save"'
|
|
224
|
+
opts = args
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
call = opts.empty? ? %(Button(type: "submit")) : %(Button(type: "submit", #{opts.join(", ")}))
|
|
228
|
+
"#{call} { #{text} }"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# "product" + "name" -> name: "product[name]", id: "product[name]"
|
|
232
|
+
def name_and_id(form, attr)
|
|
233
|
+
key = %("#{form[:param]}[#{attr}]")
|
|
234
|
+
"name: #{key}, id: #{key}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ":name" / "\"name\"" -> "name"
|
|
238
|
+
def attr_name(arg)
|
|
239
|
+
return nil unless arg
|
|
240
|
+
|
|
241
|
+
arg.strip.sub(/\A:/, "").gsub(/\A["']|["']\z/, "")[/\A\w+\z/]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def string_arg?(arg)
|
|
245
|
+
arg && arg.strip.start_with?('"', "'")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def humanize(attr)
|
|
249
|
+
attr.tr("_", " ").capitalize
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module RubyUIConverter
|
|
6
|
+
# A small, forgiving HTML tokenizer. It does not validate markup; it emits a
|
|
7
|
+
# flat stream of tokens that the Parser turns into a tree. ERB placeholders
|
|
8
|
+
# (from the Lexer) are treated as ordinary text/attribute characters.
|
|
9
|
+
#
|
|
10
|
+
# Token shapes:
|
|
11
|
+
# [:text, string]
|
|
12
|
+
# [:html_comment, inner]
|
|
13
|
+
# [:doctype, raw]
|
|
14
|
+
# [:open, name, attrs] attrs => [[name, value_or_nil], ...]
|
|
15
|
+
# [:selfclose, name, attrs]
|
|
16
|
+
# [:close, name]
|
|
17
|
+
# [:raw_element, name, attrs, inner_text] (script/style)
|
|
18
|
+
class HtmlTokenizer
|
|
19
|
+
VOID = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
20
|
+
RAW = %w[script style].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(html)
|
|
23
|
+
@s = StringScanner.new(html.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tokens
|
|
27
|
+
out = []
|
|
28
|
+
|
|
29
|
+
until @s.eos?
|
|
30
|
+
if @s.scan(/<!--(.*?)-->/m)
|
|
31
|
+
out << [:html_comment, @s[1]]
|
|
32
|
+
elsif @s.scan(/<!\[CDATA\[.*?\]\]>/m)
|
|
33
|
+
out << [:text, @s.matched]
|
|
34
|
+
elsif @s.scan(/<![^>]*>/m)
|
|
35
|
+
out << [:doctype, @s.matched]
|
|
36
|
+
elsif @s.scan(%r{</\s*([a-zA-Z][\w:-]*)\s*>})
|
|
37
|
+
out << [:close, @s[1]]
|
|
38
|
+
elsif @s.scan(/<([a-zA-Z][\w:-]*)/)
|
|
39
|
+
out << scan_tag(@s[1])
|
|
40
|
+
else
|
|
41
|
+
text = @s.scan(/[^<]+/) || @s.getch
|
|
42
|
+
out << [:text, text]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def scan_tag(name)
|
|
52
|
+
attrs, self_close = scan_attrs
|
|
53
|
+
|
|
54
|
+
if RAW.include?(name.downcase) && !self_close
|
|
55
|
+
inner = scan_raw_content(name)
|
|
56
|
+
[:raw_element, name, attrs, inner]
|
|
57
|
+
elsif self_close || VOID.include?(name.downcase)
|
|
58
|
+
[:selfclose, name, attrs]
|
|
59
|
+
else
|
|
60
|
+
[:open, name, attrs]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scan_attrs
|
|
65
|
+
attrs = []
|
|
66
|
+
|
|
67
|
+
loop do
|
|
68
|
+
@s.skip(/\s+/)
|
|
69
|
+
|
|
70
|
+
return [attrs, true] if @s.scan(%r{/\s*>})
|
|
71
|
+
return [attrs, false] if @s.scan(/>/)
|
|
72
|
+
return [attrs, false] if @s.eos?
|
|
73
|
+
|
|
74
|
+
if @s.scan(%r{([^\s=/>]+)})
|
|
75
|
+
name = @s[1]
|
|
76
|
+
value = nil
|
|
77
|
+
|
|
78
|
+
if @s.skip(/\s*=\s*/)
|
|
79
|
+
value =
|
|
80
|
+
if @s.scan(/"([^"]*)"/)
|
|
81
|
+
@s[1]
|
|
82
|
+
elsif @s.scan(/'([^']*)'/)
|
|
83
|
+
@s[1]
|
|
84
|
+
elsif @s.scan(/([^\s>]+)/)
|
|
85
|
+
@s[1]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
attrs << [name, value]
|
|
90
|
+
else
|
|
91
|
+
@s.getch # defensive: never loop forever
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def scan_raw_content(name)
|
|
97
|
+
closing = %r{</\s*#{Regexp.escape(name)}\s*>}im
|
|
98
|
+
captured = @s.scan_until(closing)
|
|
99
|
+
|
|
100
|
+
if captured
|
|
101
|
+
captured.sub(closing, "")
|
|
102
|
+
else
|
|
103
|
+
rest = @s.rest
|
|
104
|
+
@s.terminate
|
|
105
|
+
rest
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIConverter
|
|
4
|
+
# Splits an ERB document into HTML (with placeholders) + a registry of ERB
|
|
5
|
+
# tokens. Each ERB tag is replaced in the source by a unique placeholder so
|
|
6
|
+
# the HTML tokenizer can run over a well-formed string even when ERB appears
|
|
7
|
+
# inside tags/attributes. The placeholder uses only [A-Za-z0-9_] characters,
|
|
8
|
+
# which the HTML tokenizer treats as inert text/identifiers.
|
|
9
|
+
class Lexer
|
|
10
|
+
Token = Struct.new(:type, :value, :raw, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# Matches <% %>, <%= %>, <%== %>, <%# %> with optional trim markers (<%- -%>).
|
|
13
|
+
PATTERN = /<%(={1,2}|#|-)?(.*?)(-)?%>/m
|
|
14
|
+
|
|
15
|
+
PLACEHOLDER_PREFIX = "RUCxERBx"
|
|
16
|
+
PLACEHOLDER_SUFFIX = "xERBxRUC"
|
|
17
|
+
PLACEHOLDER_PATTERN = /RUCxERBx(\d+)xERBxRUC/
|
|
18
|
+
|
|
19
|
+
def initialize(source)
|
|
20
|
+
@source = source.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array(String, Hash{String => Token})]
|
|
24
|
+
def tokenize_with_placeholders
|
|
25
|
+
registry = {}
|
|
26
|
+
index = 0
|
|
27
|
+
|
|
28
|
+
html = @source.gsub(PATTERN) do
|
|
29
|
+
marker = Regexp.last_match(1)
|
|
30
|
+
code = Regexp.last_match(2)
|
|
31
|
+
key = self.class.placeholder(index)
|
|
32
|
+
registry[key] = build_token(marker, code)
|
|
33
|
+
index += 1
|
|
34
|
+
key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
[html, registry]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_token(marker, code)
|
|
41
|
+
code = code.to_s
|
|
42
|
+
case marker
|
|
43
|
+
when "#"
|
|
44
|
+
Token.new(type: :comment, value: code.strip, raw: false)
|
|
45
|
+
when "="
|
|
46
|
+
Token.new(type: :output, value: code.strip, raw: false)
|
|
47
|
+
when "=="
|
|
48
|
+
Token.new(type: :output, value: code.strip, raw: true)
|
|
49
|
+
else
|
|
50
|
+
Token.new(type: :eval, value: code.strip, raw: false)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.placeholder(index)
|
|
55
|
+
"#{PLACEHOLDER_PREFIX}#{index}#{PLACEHOLDER_SUFFIX}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RubyUIConverter
|
|
6
|
+
# Heuristically detects the locals a partial expects, so the generated
|
|
7
|
+
# component can declare keyword arguments and private readers.
|
|
8
|
+
#
|
|
9
|
+
# This is intentionally conservative and best-effort: it favors a few clearly
|
|
10
|
+
# detectable cases (local_assigns[:x]) plus bare identifiers that look like
|
|
11
|
+
# locals. Anything it misses can be added by hand to the generated component.
|
|
12
|
+
class LocalsDetector
|
|
13
|
+
KEYWORDS = %w[
|
|
14
|
+
if elsif else end unless case when in while until for do begin rescue
|
|
15
|
+
ensure return yield self nil true false and or not then break next redo
|
|
16
|
+
retry def class module super defined lambda proc new raise puts print p
|
|
17
|
+
require require_relative attr_reader attr_accessor attr_writer
|
|
18
|
+
].to_set
|
|
19
|
+
|
|
20
|
+
def initialize(tree)
|
|
21
|
+
@tree = tree
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def locals
|
|
25
|
+
codes = []
|
|
26
|
+
collect(@tree.children, codes)
|
|
27
|
+
|
|
28
|
+
found = Set.new
|
|
29
|
+
assigned = Set.new
|
|
30
|
+
block_params = Set.new
|
|
31
|
+
|
|
32
|
+
codes.each do |code|
|
|
33
|
+
code.scan(/\|([^|]*)\|/) do
|
|
34
|
+
Regexp.last_match(1).split(",").each do |param|
|
|
35
|
+
block_params << param.strip.sub(/\A\*+/, "").sub(/:.*\z/, "").strip
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
code.scan(/([a-z_]\w*)\s*=(?!=)/) { assigned << Regexp.last_match(1) }
|
|
39
|
+
code.scan(/local_assigns\[:(\w+)\]/) { found << Regexp.last_match(1) }
|
|
40
|
+
code.scan(/local_assigns\.fetch\(:(\w+)/) { found << Regexp.last_match(1) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
codes.each do |code|
|
|
44
|
+
without_strings = code.gsub(/"[^"]*"|'[^']*'/, " ")
|
|
45
|
+
without_strings.scan(/(?<![.\w:@$])([a-z_]\w*)/) do
|
|
46
|
+
name = Regexp.last_match(1)
|
|
47
|
+
after = Regexp.last_match.post_match
|
|
48
|
+
|
|
49
|
+
next if after =~ /\A\s*\(/ # method call
|
|
50
|
+
next if after =~ /\A\s*=(?!=)/ # assignment target
|
|
51
|
+
next if after =~ /\A:(?!:)/ # keyword/hash key
|
|
52
|
+
next if KEYWORDS.include?(name)
|
|
53
|
+
next if RailsHelpers::HTML_HELPERS.include?(name)
|
|
54
|
+
next if RailsHelpers::KNOWN_HELPERS.include?(name)
|
|
55
|
+
next if block_params.include?(name)
|
|
56
|
+
next if assigned.include?(name)
|
|
57
|
+
|
|
58
|
+
found << name
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
found.delete("local_assigns")
|
|
63
|
+
found.to_a.sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Instance variables a top-level view reads from its controller (`@products`),
|
|
67
|
+
# so the generated component can take them as keyword arguments. Excludes any
|
|
68
|
+
# ivar assigned within the template and class variables (`@@foo`). Strings are
|
|
69
|
+
# stripped first, like #locals, to avoid `@` inside literals (`"x@y.com"`).
|
|
70
|
+
def ivars
|
|
71
|
+
codes = []
|
|
72
|
+
collect(@tree.children, codes)
|
|
73
|
+
|
|
74
|
+
found = Set.new
|
|
75
|
+
assigned = Set.new
|
|
76
|
+
|
|
77
|
+
codes.each do |code|
|
|
78
|
+
without_strings = code.gsub(/"[^"]*"|'[^']*'/, " ")
|
|
79
|
+
without_strings.scan(/(?<!@)@([a-zA-Z_]\w*)\s*=(?!=)/) { assigned << Regexp.last_match(1) }
|
|
80
|
+
without_strings.scan(/(?<!@)@([a-zA-Z_]\w*)/) { found << Regexp.last_match(1) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
(found - assigned).to_a.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def collect(nodes, codes)
|
|
89
|
+
nodes.each do |node|
|
|
90
|
+
case node
|
|
91
|
+
when Nodes::Output, Nodes::Statement
|
|
92
|
+
codes << node.code
|
|
93
|
+
when Nodes::Control
|
|
94
|
+
node.branches.each do |branch|
|
|
95
|
+
codes << branch.header
|
|
96
|
+
collect(branch.children, codes)
|
|
97
|
+
end
|
|
98
|
+
when Nodes::Element
|
|
99
|
+
node.attributes.each do |(_, parts)|
|
|
100
|
+
next unless parts
|
|
101
|
+
|
|
102
|
+
parts.each do |kind, value|
|
|
103
|
+
codes << value.value if kind == :erb
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
collect(node.children, codes)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIConverter
|
|
4
|
+
# Converts file paths into Ruby constant names following Rails-ish
|
|
5
|
+
# conventions: app/views/users/index.html.erb -> Views::Users::Index.
|
|
6
|
+
module Naming
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def camelize(string)
|
|
10
|
+
string.to_s.split(/[_\-\s]+/).reject(&:empty?).map do |part|
|
|
11
|
+
part[0].upcase + part[1..].to_s
|
|
12
|
+
end.join
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Class name for a template basename ("index" / "_form" -> Index / Form).
|
|
16
|
+
def class_name(basename)
|
|
17
|
+
camelize(basename.sub(/\A_/, ""))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Module segments derived from base namespace + relative directory.
|
|
21
|
+
# ("users", "Views") -> ["Views", "Users"]
|
|
22
|
+
def namespace_parts(dir_rel, base_namespace)
|
|
23
|
+
base_parts = base_namespace.to_s.split("::").reject(&:empty?)
|
|
24
|
+
dir_parts = dir_rel.to_s.split("/").reject(&:empty?).map { |part| camelize(part) }
|
|
25
|
+
base_parts + dir_parts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolves a render partial path to a fully-qualified constant.
|
|
29
|
+
# "shared/header" -> "Views::Shared::Header"
|
|
30
|
+
# "form" -> "<current namespace>::Form"
|
|
31
|
+
def partial_const(path, base_namespace:, current_namespace_parts: [])
|
|
32
|
+
segments = path.to_s.split("/")
|
|
33
|
+
name = segments.pop.to_s.sub(/\A_/, "")
|
|
34
|
+
|
|
35
|
+
if path.to_s.include?("/")
|
|
36
|
+
base_parts = base_namespace.to_s.split("::").reject(&:empty?)
|
|
37
|
+
dir_parts = segments.map { |segment| camelize(segment) }
|
|
38
|
+
(base_parts + dir_parts + [camelize(name)]).join("::")
|
|
39
|
+
else
|
|
40
|
+
current = current_namespace_parts.empty? ? [base_namespace.to_s].reject(&:empty?) : current_namespace_parts
|
|
41
|
+
(current + [camelize(name)]).join("::")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUIConverter
|
|
4
|
+
# AST node types produced by the Parser and consumed by the Transformer.
|
|
5
|
+
#
|
|
6
|
+
# Attribute values are stored as "parts": an array whose entries are either
|
|
7
|
+
# `[:text, "literal"]` or `[:erb, <Lexer::Token>]`. This lets the transformer
|
|
8
|
+
# decide between a plain string, a bare Ruby expression or an interpolated
|
|
9
|
+
# string when emitting the attribute.
|
|
10
|
+
module Nodes
|
|
11
|
+
class Base; end
|
|
12
|
+
|
|
13
|
+
class Document < Base
|
|
14
|
+
attr_reader :children
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@children = []
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Element < Base
|
|
22
|
+
attr_reader :name, :attributes, :children
|
|
23
|
+
attr_accessor :self_closing
|
|
24
|
+
|
|
25
|
+
def initialize(name:, attributes: [], self_closing: false)
|
|
26
|
+
@name = name
|
|
27
|
+
@attributes = attributes
|
|
28
|
+
@children = []
|
|
29
|
+
@self_closing = self_closing
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the literal value of an attribute when it has no ERB parts,
|
|
33
|
+
# otherwise nil. Useful for ComponentMap matchers/emitters.
|
|
34
|
+
def static_attr(name)
|
|
35
|
+
attr = attributes.find { |attr_name, _| attr_name == name }
|
|
36
|
+
return nil unless attr && attr[1]
|
|
37
|
+
return nil unless attr[1].all? { |kind, _| kind == :text }
|
|
38
|
+
|
|
39
|
+
attr[1].map { |_, value| value }.join
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the static CSS classes when the `class` attribute has no ERB,
|
|
43
|
+
# otherwise an empty array. Useful for ComponentMap matchers.
|
|
44
|
+
def static_classes
|
|
45
|
+
static_attr("class").to_s.split
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def attr?(name)
|
|
49
|
+
attributes.any? { |attr_name, _| attr_name == name }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Text < Base
|
|
54
|
+
attr_reader :content
|
|
55
|
+
|
|
56
|
+
def initialize(content:)
|
|
57
|
+
@content = content
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raw inner content of <script>/<style> elements (kept verbatim).
|
|
62
|
+
class RawText < Base
|
|
63
|
+
attr_reader :content
|
|
64
|
+
|
|
65
|
+
def initialize(content:)
|
|
66
|
+
@content = content
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# <%= code %> or <%== code %>
|
|
71
|
+
class Output < Base
|
|
72
|
+
attr_reader :code, :raw
|
|
73
|
+
|
|
74
|
+
def initialize(code:, raw: false)
|
|
75
|
+
@code = code
|
|
76
|
+
@raw = raw
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# <% code %> that is a plain statement (assignment, method call, etc.)
|
|
81
|
+
class Statement < Base
|
|
82
|
+
attr_reader :code
|
|
83
|
+
|
|
84
|
+
def initialize(code:)
|
|
85
|
+
@code = code
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Comment < Base
|
|
90
|
+
attr_reader :text, :html
|
|
91
|
+
|
|
92
|
+
def initialize(text:, html: false)
|
|
93
|
+
@text = text
|
|
94
|
+
@html = html
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Doctype < Base
|
|
99
|
+
attr_reader :value
|
|
100
|
+
|
|
101
|
+
def initialize(value:)
|
|
102
|
+
@value = value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A single branch of a control structure (if / elsif / else / when / each-do...).
|
|
107
|
+
class Branch
|
|
108
|
+
attr_reader :header, :children
|
|
109
|
+
|
|
110
|
+
def initialize(header:)
|
|
111
|
+
@header = header
|
|
112
|
+
@children = []
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# if/unless/case/while statements and `... do |x|` blocks.
|
|
117
|
+
class Control < Base
|
|
118
|
+
attr_reader :branches
|
|
119
|
+
attr_accessor :block, :output
|
|
120
|
+
|
|
121
|
+
def initialize(block: false, output: false)
|
|
122
|
+
@branches = []
|
|
123
|
+
@block = block
|
|
124
|
+
@output = output
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|