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