ruby_ui_converter 0.1.0 → 0.2.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,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Builds the converter's AST (Nodes) from a HAML template.
5
+ #
6
+ # Parsing itself is delegated to the haml gem's own parser (Haml::Parser), an
7
+ # OPTIONAL dependency required lazily — only when a .haml file is converted —
8
+ # so ERB-only users keep `thor` as the sole runtime dependency. Apps with
9
+ # .haml views already have haml in their bundle.
10
+ #
11
+ # The result has the same shape the ERB Parser builds for the equivalent ERB
12
+ # (Text/Output/Statement/Control/Element nodes, attribute "parts"), so the
13
+ # whole downstream pipeline — Transformer, ComponentMap, RailsHelpers,
14
+ # FormBuilder, LocalsDetector — is shared between both formats. HAML text
15
+ # (which may hold inline HTML) is itself routed through the ERB Parser, with
16
+ # each `#{}` interpolation turned into an `<%= %>` tag.
17
+ #
18
+ # Verified against haml 5.2, 6.x and 7.x, which share the ParseNode tree
19
+ # shape and the `Haml::Parser.new(options).call(source)` API.
20
+ class HamlParser
21
+ # Mid-block keywords. Haml nests them (flattened) among the children of the
22
+ # `if`/`case`/`begin` node they belong to; each one starts a new Branch.
23
+ MID_KEYWORDS = %w[else elsif when in rescue ensure].freeze
24
+
25
+ # With escape_html on, haml 5 and 6 wrap every text interpolation in an
26
+ # escape call (haml 5: `Haml::Helpers.html_escape((x))`, haml 6:
27
+ # `Haml::Util.escape_html_safe((x).to_s)`); haml 7 doesn't. Phlex escapes
28
+ # output itself, so the wrapper is stripped back to the original code.
29
+ ESCAPE_WRAPPER = /\A(?:::)?Haml::(?:Helpers\.html_escape|Util\.escape_html_safe|Util\.escape_html)\(\((.*)\)(?:\.to_s)?\)\z/m
30
+
31
+ # Filters whose content is HAML/HTML text (interpolated, unescaped HTML).
32
+ TEXT_FILTERS = %w[plain preserve].freeze
33
+ # Filters rendered as an element wrapping their raw content.
34
+ RAW_ELEMENT_FILTERS = { "javascript" => "script", "css" => "style" }.freeze
35
+
36
+ MISSING_HAML = "converting .haml templates requires the haml gem " \
37
+ "(add `gem \"haml\"` to your Gemfile or run `gem install haml`)"
38
+
39
+ def self.load_haml!
40
+ require_haml
41
+ true
42
+ rescue LoadError
43
+ raise Error, MISSING_HAML
44
+ end
45
+
46
+ # Separate seam so tests can simulate a missing haml gem.
47
+ def self.require_haml
48
+ require "haml"
49
+ end
50
+
51
+ # `Haml::Helpers.html_escape((name))` -> `name`; anything else unchanged.
52
+ def self.unwrap_escape(code)
53
+ code = code.to_s.strip
54
+ (inner = code[ESCAPE_WRAPPER, 1]) ? inner.strip : code
55
+ end
56
+
57
+ def initialize(source)
58
+ @source = source.to_s
59
+ end
60
+
61
+ def parse
62
+ self.class.load_haml!
63
+ # escape_html: true (the Rails default) is what lets the parser tell
64
+ # `=` (escaped) apart from `!=` (raw).
65
+ root = ::Haml::Parser.new(escape_html: true).call(@source)
66
+
67
+ document = Nodes::Document.new
68
+ convert_children(root.children, document.children)
69
+ document
70
+ end
71
+
72
+ private
73
+
74
+ # Converts sibling HAML nodes, then pads text that sits next to another
75
+ # rendered sibling: HAML puts a newline between lines, so `Signed in as`
76
+ # followed by `%strong= name` renders with a space in between.
77
+ def convert_children(nodes, target)
78
+ rendered = nodes.reject { |node| silent?(node) }
79
+
80
+ nodes.each do |node|
81
+ start = target.length
82
+ convert(node, target)
83
+ next if silent?(node)
84
+
85
+ position = rendered.index { |other| other.equal?(node) }
86
+ before = position.positive? && !nuke_outer?(rendered[position - 1])
87
+ after = position < rendered.length - 1 && !nuke_outer?(rendered[position + 1])
88
+ pad_text(target, start, before: before && !nuke_outer?(node), after: after && !nuke_outer?(node))
89
+ end
90
+ end
91
+
92
+ # Nodes that render nothing between their siblings.
93
+ def silent?(node)
94
+ node.type == :haml_comment || (node.type == :silent_script && node.children.empty?)
95
+ end
96
+
97
+ # `%img>`: whitespace around the tag is removed.
98
+ def nuke_outer?(node)
99
+ node.type == :tag && node.value[:nuke_outer_whitespace] ? true : false
100
+ end
101
+
102
+ def pad_text(target, start, before:, after:)
103
+ return if start >= target.length
104
+
105
+ first = target[start]
106
+ if before && first.is_a?(Nodes::Text) && first.content !~ /\A\s/
107
+ target[start] = Nodes::Text.new(content: " #{first.content}")
108
+ end
109
+
110
+ last = target[-1]
111
+ return unless after && last.is_a?(Nodes::Text) && last.content !~ /\s\z/
112
+
113
+ target[-1] = Nodes::Text.new(content: "#{last.content} ")
114
+ end
115
+
116
+ def convert(node, target)
117
+ case node.type
118
+ when :tag then target << element(node)
119
+ when :plain then target.concat(text_nodes([[:text, node.value[:text].to_s]]))
120
+ when :script then script(node, target)
121
+ when :silent_script then silent_script(node, target)
122
+ when :haml_comment then target << ruby_comment(node.value[:text])
123
+ when :comment then html_comment(node, target)
124
+ when :doctype then doctype(node, target)
125
+ when :filter then filter(node, target)
126
+ end
127
+ end
128
+
129
+ # HAML text is unescaped HTML with escaped `#{}` interpolations — exactly
130
+ # what ERB text with `<%= %>` tags is — so it goes through the ERB Parser:
131
+ # inline markup (`Hi <strong>you</strong>`) becomes real elements, and
132
+ # interpolated helpers (`#{link_to ...}`) become Output nodes the
133
+ # RailsHelpers mapping understands.
134
+ def text_nodes(parts)
135
+ erb = parts.map { |kind, value| kind == :text ? value : "<%= #{value} %>" }.join
136
+ Parser.new(erb).parse.children
137
+ end
138
+
139
+ # --- tags --------------------------------------------------------------
140
+
141
+ def element(node)
142
+ value = node.value
143
+ element = Nodes::Element.new(
144
+ name: value[:name],
145
+ attributes: HamlAttributes.build(value),
146
+ self_closing: value[:self_closing] ? true : false
147
+ )
148
+ inline_content(value, element.children)
149
+ convert_children(node.children, element.children)
150
+ element
151
+ end
152
+
153
+ # `%p Hello` / `%p= code` / `%p!= code` / `%p Hi #{name}`
154
+ def inline_content(value, target)
155
+ content = value[:value]
156
+ return if content.nil? || content.to_s.empty?
157
+
158
+ if value[:parse]
159
+ expression(content, value[:escape_html], value[:escape_interpolation], target)
160
+ else
161
+ target.concat(text_nodes([[:text, content.to_s]]))
162
+ end
163
+ end
164
+
165
+ # --- Ruby --------------------------------------------------------------
166
+
167
+ # `= code` (with or without a block) and interpolated plain text, which
168
+ # haml hands over as a script node.
169
+ def script(node, target)
170
+ text = node.value[:text].to_s.strip
171
+
172
+ if node.children.any?
173
+ target << control(node, text, output: true)
174
+ else
175
+ expression(text, node.value[:escape_html], node.value[:escape_interpolation], target)
176
+ end
177
+ end
178
+
179
+ def silent_script(node, target)
180
+ text = node.value[:text].to_s.strip
181
+
182
+ if mid_keyword?(node) && target.last.is_a?(Nodes::Control)
183
+ # Defensive: a mid-block keyword left as a sibling of its opener.
184
+ branch = Nodes::Branch.new(header: text)
185
+ target.last.branches << branch
186
+ convert_children(node.children, branch.children)
187
+ elsif node.children.any?
188
+ target << control(node, text, output: false)
189
+ else
190
+ target << Nodes::Statement.new(code: text)
191
+ end
192
+ end
193
+
194
+ # `- if a` … `- elsif b` … `- else`, `- case`/`- when`, `- x.each do |i|`,
195
+ # `= form_with(...) do |f|`. Haml always closes blocks by indentation, so
196
+ # the matching `end` is implicit (the Transformer emits it).
197
+ def control(node, header, output:)
198
+ control = Nodes::Control.new(block: block_opener?(header), output: output)
199
+ segments = [[header, []]]
200
+
201
+ node.children.each do |child|
202
+ if mid_keyword?(child)
203
+ segments << [child.value[:text].to_s.strip, child.children.dup]
204
+ else
205
+ segments.last[1] << child
206
+ end
207
+ end
208
+
209
+ segments.each do |branch_header, children|
210
+ branch = Nodes::Branch.new(header: branch_header)
211
+ convert_children(children, branch.children)
212
+ control.branches << branch
213
+ end
214
+
215
+ control
216
+ end
217
+
218
+ def mid_keyword?(node)
219
+ node.type == :silent_script && MID_KEYWORDS.include?(node.value[:keyword])
220
+ end
221
+
222
+ def block_opener?(code)
223
+ code =~ /\bdo\s*(\|[^|]*\|)?\s*\z/ ? true : false
224
+ end
225
+
226
+ # A Ruby expression whose value is output: escaped (`=`, `&=`, `~`), raw
227
+ # (`!=`), or text with `#{}` interpolation — parsed like the ERB
228
+ # `Hello <%= name %>` equivalent, so helpers and locals inside the
229
+ # interpolation are seen by the rest of the pipeline.
230
+ def expression(code, escape_html, escape_interpolation, target)
231
+ code = code.to_s.strip
232
+ parts = interpolated_text_parts(code, escape_html, escape_interpolation)
233
+
234
+ if parts
235
+ target.concat(text_nodes(parts))
236
+ else
237
+ target << Nodes::Output.new(code: code, raw: escape_html == false)
238
+ end
239
+ end
240
+
241
+ # Interpolated text is flagged with escape_interpolation (haml 6/7 plain
242
+ # lines, haml 7 inline text); haml 5/6 inline text is only recognizable by
243
+ # the escape wrappers around its interpolations.
244
+ def interpolated_text_parts(code, escape_html, escape_interpolation)
245
+ parts = RubyLiteral.string_parts(code)
246
+ return nil unless parts
247
+
248
+ wrapped = parts.any? { |kind, value| kind == :code && value.match?(ESCAPE_WRAPPER) }
249
+ return nil unless escape_interpolation || (escape_html == false && wrapped)
250
+
251
+ parts.map { |kind, value| kind == :code ? [kind, self.class.unwrap_escape(value)] : [kind, value] }
252
+ end
253
+
254
+ # Text with HAML `#{}` interpolation (filter content) as text/code parts.
255
+ def interpolation_parts(text)
256
+ return [[:text, text]] unless text =~ /#[{@$]/
257
+
258
+ RubyLiteral.string_parts(::Haml::Util.unescape_interpolation(text)) || [[:text, text]]
259
+ end
260
+
261
+ # --- comments, doctype, filters ----------------------------------------
262
+
263
+ def ruby_comment(text)
264
+ Nodes::Comment.new(text: text.to_s.strip, html: false)
265
+ end
266
+
267
+ # `/ note` -> comment { "note" }; a `/` with nested HAML renders that
268
+ # content inside the comment (`comment do ... end`). IE conditional
269
+ # comments (`/[if IE]`) are obsolete and left as a TODO.
270
+ def html_comment(node, target)
271
+ value = node.value
272
+
273
+ if value[:conditional]
274
+ target << ruby_comment("TODO: HAML conditional comment #{value[:conditional]} dropped (content not converted)")
275
+ else
276
+ comment = Nodes::Comment.new(text: value[:text].to_s, html: true)
277
+ convert_children(node.children, comment.children)
278
+ target << comment
279
+ end
280
+ end
281
+
282
+ def doctype(node, target)
283
+ return if node.value[:type].to_s.downcase == "xml"
284
+
285
+ target << Nodes::Doctype.new(value: "<!DOCTYPE html>")
286
+ end
287
+
288
+ def filter(node, target)
289
+ name = node.value[:name].to_s
290
+ text = node.value[:text].to_s
291
+
292
+ if RAW_ELEMENT_FILTERS.key?(name)
293
+ target << raw_element(RAW_ELEMENT_FILTERS[name], text)
294
+ elsif TEXT_FILTERS.include?(name)
295
+ target.concat(text_nodes(interpolation_parts(text.rstrip)))
296
+ elsif name == "escaped"
297
+ interpolation_parts(text.rstrip).each do |kind, value|
298
+ target << (kind == :text ? Nodes::Text.new(content: value) : Nodes::Output.new(code: value))
299
+ end
300
+ elsif name == "erb"
301
+ target.concat(Parser.new(text).parse.children)
302
+ elsif name == "ruby"
303
+ text.each_line { |line| target << Nodes::Statement.new(code: line.rstrip) unless line.strip.empty? }
304
+ else
305
+ target << ruby_comment("TODO: HAML :#{name} filter not converted\n#{text.rstrip}")
306
+ end
307
+ end
308
+
309
+ # :javascript / :css -> script / style. Content without interpolation is
310
+ # kept verbatim (RawText, like an ERB <script>); with `#{}` it becomes a
311
+ # raw interpolated string.
312
+ def raw_element(tag, text)
313
+ element = Nodes::Element.new(name: tag)
314
+ return element if text.strip.empty?
315
+
316
+ parts = interpolation_parts(text)
317
+ element.children <<
318
+ if parts.any? { |kind, _| kind == :code }
319
+ Nodes::Output.new(code: ::Haml::Util.unescape_interpolation(text), raw: true)
320
+ else
321
+ Nodes::RawText.new(content: text)
322
+ end
323
+ element
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIConverter
4
+ # Decodes HTML character references found in template text and attribute
5
+ # values (`&copy;`, `&nbsp;`, `&#8212;`, `&#x2014;`) into the characters they
6
+ # stand for. Phlex escapes everything it outputs, so leaving them encoded
7
+ # would double-escape them (`&copy;` would render as the literal text
8
+ # "&copy;"); decoded, Phlex re-escapes only what needs it (`&lt;` -> `<` ->
9
+ # `&lt;`).
10
+ #
11
+ # Named references come from the HTML 4 set (plus `&apos;`) — the ones seen
12
+ # in real templates. Unknown names, references without the closing `;` and
13
+ # invalid code points are left untouched.
14
+ module HtmlEntities
15
+ module_function
16
+
17
+ PATTERN = /&(?:#(\d+)|#[xX](\h+)|([A-Za-z][A-Za-z0-9]*));/
18
+
19
+ # Generated from Python's html.entities.name2codepoint (HTML 4.01) + apos.
20
+ NAMED = {
21
+ "Aacute" => 193, "aacute" => 225, "Acirc" => 194, "acirc" => 226, "acute" => 180,
22
+ "AElig" => 198, "aelig" => 230, "Agrave" => 192, "agrave" => 224, "alefsym" => 8501,
23
+ "Alpha" => 913, "alpha" => 945, "amp" => 38, "and" => 8743, "ang" => 8736, "apos" => 39,
24
+ "Aring" => 197, "aring" => 229, "asymp" => 8776, "Atilde" => 195, "atilde" => 227,
25
+ "Auml" => 196, "auml" => 228, "bdquo" => 8222, "Beta" => 914, "beta" => 946, "brvbar" => 166,
26
+ "bull" => 8226, "cap" => 8745, "Ccedil" => 199, "ccedil" => 231, "cedil" => 184,
27
+ "cent" => 162, "Chi" => 935, "chi" => 967, "circ" => 710, "clubs" => 9827, "cong" => 8773,
28
+ "copy" => 169, "crarr" => 8629, "cup" => 8746, "curren" => 164, "Dagger" => 8225,
29
+ "dagger" => 8224, "dArr" => 8659, "darr" => 8595, "deg" => 176, "Delta" => 916,
30
+ "delta" => 948, "diams" => 9830, "divide" => 247, "Eacute" => 201, "eacute" => 233,
31
+ "Ecirc" => 202, "ecirc" => 234, "Egrave" => 200, "egrave" => 232, "empty" => 8709,
32
+ "emsp" => 8195, "ensp" => 8194, "Epsilon" => 917, "epsilon" => 949, "equiv" => 8801,
33
+ "Eta" => 919, "eta" => 951, "ETH" => 208, "eth" => 240, "Euml" => 203, "euml" => 235,
34
+ "euro" => 8364, "exist" => 8707, "fnof" => 402, "forall" => 8704, "frac12" => 189,
35
+ "frac14" => 188, "frac34" => 190, "frasl" => 8260, "Gamma" => 915, "gamma" => 947,
36
+ "ge" => 8805, "gt" => 62, "hArr" => 8660, "harr" => 8596, "hearts" => 9829, "hellip" => 8230,
37
+ "Iacute" => 205, "iacute" => 237, "Icirc" => 206, "icirc" => 238, "iexcl" => 161,
38
+ "Igrave" => 204, "igrave" => 236, "image" => 8465, "infin" => 8734, "int" => 8747,
39
+ "Iota" => 921, "iota" => 953, "iquest" => 191, "isin" => 8712, "Iuml" => 207, "iuml" => 239,
40
+ "Kappa" => 922, "kappa" => 954, "Lambda" => 923, "lambda" => 955, "lang" => 9001,
41
+ "laquo" => 171, "lArr" => 8656, "larr" => 8592, "lceil" => 8968, "ldquo" => 8220,
42
+ "le" => 8804, "lfloor" => 8970, "lowast" => 8727, "loz" => 9674, "lrm" => 8206,
43
+ "lsaquo" => 8249, "lsquo" => 8216, "lt" => 60, "macr" => 175, "mdash" => 8212,
44
+ "micro" => 181, "middot" => 183, "minus" => 8722, "Mu" => 924, "mu" => 956, "nabla" => 8711,
45
+ "nbsp" => 160, "ndash" => 8211, "ne" => 8800, "ni" => 8715, "not" => 172, "notin" => 8713,
46
+ "nsub" => 8836, "Ntilde" => 209, "ntilde" => 241, "Nu" => 925, "nu" => 957, "Oacute" => 211,
47
+ "oacute" => 243, "Ocirc" => 212, "ocirc" => 244, "OElig" => 338, "oelig" => 339,
48
+ "Ograve" => 210, "ograve" => 242, "oline" => 8254, "Omega" => 937, "omega" => 969,
49
+ "Omicron" => 927, "omicron" => 959, "oplus" => 8853, "or" => 8744, "ordf" => 170,
50
+ "ordm" => 186, "Oslash" => 216, "oslash" => 248, "Otilde" => 213, "otilde" => 245,
51
+ "otimes" => 8855, "Ouml" => 214, "ouml" => 246, "para" => 182, "part" => 8706,
52
+ "permil" => 8240, "perp" => 8869, "Phi" => 934, "phi" => 966, "Pi" => 928, "pi" => 960,
53
+ "piv" => 982, "plusmn" => 177, "pound" => 163, "Prime" => 8243, "prime" => 8242,
54
+ "prod" => 8719, "prop" => 8733, "Psi" => 936, "psi" => 968, "quot" => 34, "radic" => 8730,
55
+ "rang" => 9002, "raquo" => 187, "rArr" => 8658, "rarr" => 8594, "rceil" => 8969,
56
+ "rdquo" => 8221, "real" => 8476, "reg" => 174, "rfloor" => 8971, "Rho" => 929, "rho" => 961,
57
+ "rlm" => 8207, "rsaquo" => 8250, "rsquo" => 8217, "sbquo" => 8218, "Scaron" => 352,
58
+ "scaron" => 353, "sdot" => 8901, "sect" => 167, "shy" => 173, "Sigma" => 931, "sigma" => 963,
59
+ "sigmaf" => 962, "sim" => 8764, "spades" => 9824, "sub" => 8834, "sube" => 8838,
60
+ "sum" => 8721, "sup" => 8835, "sup1" => 185, "sup2" => 178, "sup3" => 179, "supe" => 8839,
61
+ "szlig" => 223, "Tau" => 932, "tau" => 964, "there4" => 8756, "Theta" => 920, "theta" => 952,
62
+ "thetasym" => 977, "thinsp" => 8201, "THORN" => 222, "thorn" => 254, "tilde" => 732,
63
+ "times" => 215, "trade" => 8482, "Uacute" => 218, "uacute" => 250, "uArr" => 8657,
64
+ "uarr" => 8593, "Ucirc" => 219, "ucirc" => 251, "Ugrave" => 217, "ugrave" => 249,
65
+ "uml" => 168, "upsih" => 978, "Upsilon" => 933, "upsilon" => 965, "Uuml" => 220,
66
+ "uuml" => 252, "weierp" => 8472, "Xi" => 926, "xi" => 958, "Yacute" => 221, "yacute" => 253,
67
+ "yen" => 165, "Yuml" => 376, "yuml" => 255, "Zeta" => 918, "zeta" => 950, "zwj" => 8205,
68
+ "zwnj" => 8204
69
+ }.freeze
70
+
71
+ def decode(text)
72
+ return text unless text.include?("&")
73
+
74
+ text.gsub(PATTERN) do
75
+ match = Regexp.last_match
76
+ codepoint =
77
+ if match[1] then match[1].to_i
78
+ elsif match[2] then match[2].to_i(16)
79
+ else NAMED[match[3]]
80
+ end
81
+ valid_codepoint?(codepoint) ? codepoint.chr(Encoding::UTF_8) : match[0]
82
+ end
83
+ end
84
+
85
+ def valid_codepoint?(codepoint)
86
+ codepoint && codepoint.positive? && codepoint <= 0x10FFFF && !(0xD800..0xDFFF).cover?(codepoint)
87
+ end
88
+ end
89
+ end
@@ -104,6 +104,8 @@ module RubyUIConverter
104
104
  end
105
105
  end
106
106
  collect(node.children, codes)
107
+ when Nodes::Comment
108
+ collect(node.children, codes)
107
109
  end
108
110
  end
109
111
  end
@@ -86,12 +86,16 @@ module RubyUIConverter
86
86
  end
87
87
  end
88
88
 
89
+ # An HTML comment (html: true) or a template-only comment (html: false).
90
+ # HTML comments may wrap rendered children (HAML's `/` with nested
91
+ # content), emitted as `comment do ... end`.
89
92
  class Comment < Base
90
- attr_reader :text, :html
93
+ attr_reader :text, :html, :children
91
94
 
92
95
  def initialize(text:, html: false)
93
96
  @text = text
94
97
  @html = html
98
+ @children = []
95
99
  end
96
100
  end
97
101
 
@@ -81,7 +81,7 @@ module RubyUIConverter
81
81
  def emit_text(text, stack)
82
82
  split_parts(text).each do |kind, value|
83
83
  if kind == :text
84
- append(stack, Nodes::Text.new(content: value)) unless value.empty?
84
+ append(stack, Nodes::Text.new(content: HtmlEntities.decode(value))) unless value.empty?
85
85
  else
86
86
  emit_erb(value, stack)
87
87
  end
@@ -149,11 +149,17 @@ module RubyUIConverter
149
149
  if name.include?("RUCxERBx") && value.nil?
150
150
  [:__splat__, split_parts(name)]
151
151
  else
152
- [name, value.nil? ? nil : split_parts(value)]
152
+ [name, value.nil? ? nil : decode_text_parts(split_parts(value))]
153
153
  end
154
154
  end
155
155
  end
156
156
 
157
+ # Literal attribute text is HTML: decode its character references
158
+ # (`title="Tom &amp; Jerry"`) — Phlex re-escapes attribute values.
159
+ def decode_text_parts(parts)
160
+ parts.map { |kind, value| kind == :text ? [kind, HtmlEntities.decode(value)] : [kind, value] }
161
+ end
162
+
157
163
  def split_parts(string)
158
164
  parts = []
159
165
  pos = 0
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module RubyUIConverter
6
+ # Splits a Ruby string literal into its literal text and interpolated code.
7
+ # Used by the HAML front-end, whose parser hands interpolated text and
8
+ # attribute values over as Ruby source (`"Hello #{name}!"`), so they can be
9
+ # turned into the same Text/Output nodes and attribute "parts" the ERB
10
+ # front-end produces. Lexing is done with Ripper (stdlib), so this has no
11
+ # dependency on the haml gem.
12
+ module RubyLiteral
13
+ module_function
14
+
15
+ QUOTES = ['"', "'"].freeze
16
+
17
+ # '"Hi #{name}!"' -> [[:text, "Hi "], [:code, "name"], [:text, "!"]]
18
+ # "'static'" -> [[:text, "static"]]
19
+ #
20
+ # Returns nil unless `code` is exactly one single- or double-quoted string
21
+ # literal (so `"a" + b`, `"a".upcase`, `:sym`, `%w[]` and heredocs are
22
+ # rejected), or when an escape sequence can't be decoded safely.
23
+ def string_parts(code)
24
+ code = code.to_s.strip
25
+ return nil unless single_string_literal?(code)
26
+
27
+ tokens = Ripper.lex(code)
28
+ quote = tokens.first[2]
29
+ return nil unless QUOTES.include?(quote) && tokens.last[1] == :on_tstring_end
30
+
31
+ parts = scan_parts(tokens[1...-1], quote)
32
+ parts && merge_text(parts)
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def single_string_literal?(code)
38
+ sexp = Ripper.sexp(code)
39
+ statements = sexp && sexp[1]
40
+ statements.is_a?(Array) && statements.length == 1 &&
41
+ statements[0].is_a?(Array) && statements[0][0] == :string_literal
42
+ end
43
+
44
+ def scan_parts(tokens, quote)
45
+ parts = []
46
+ depth = 0
47
+ buffer = +""
48
+ embedded_var = false
49
+
50
+ tokens.each do |(_, type, tok, _)|
51
+ if depth.positive?
52
+ depth += 1 if type == :on_embexpr_beg
53
+ depth -= 1 if type == :on_embexpr_end
54
+ depth.zero? ? parts << [:code, buffer.strip] : buffer << tok
55
+ next
56
+ end
57
+
58
+ case type
59
+ when :on_tstring_content
60
+ parts << [:text, unescape(tok, quote)]
61
+ when :on_embexpr_beg
62
+ depth = 1
63
+ buffer = +""
64
+ when :on_embvar # "#@ivar" / "#$global" shorthand
65
+ embedded_var = true
66
+ when :on_ivar, :on_gvar, :on_cvar
67
+ return nil unless embedded_var
68
+
69
+ embedded_var = false
70
+ parts << [:code, tok]
71
+ else
72
+ return nil
73
+ end
74
+ end
75
+
76
+ depth.zero? ? parts : nil
77
+ end
78
+
79
+ # Decodes the escapes of a literal's raw content. String#undump understands
80
+ # every escape String#dump emits (which is how haml hands text over) but
81
+ # rejects raw non-ASCII characters, so those are \u-escaped first.
82
+ def unescape(content, quote)
83
+ return content.gsub(/\\([\\'])/, '\1') if quote == "'"
84
+ return content unless content.include?("\\")
85
+
86
+ ascii = content.gsub(/[^\x00-\x7F]/) { |char| format("\\u{%x}", char.ord) }
87
+ "\"#{ascii}\"".undump.force_encoding(content.encoding)
88
+ end
89
+
90
+ def merge_text(parts)
91
+ parts.each_with_object([]) do |part, merged|
92
+ if part[0] == :text && merged.last && merged.last[0] == :text
93
+ merged.last[1] = merged.last[1] + part[1]
94
+ elsif part[0] != :text || !part[1].empty?
95
+ merged << part.dup
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyUIConverter
4
- # Represents a single .erb file and knows how to render its .rb equivalent.
4
+ # Represents a single template file (.erb or .haml) and knows how to render
5
+ # its .rb equivalent.
5
6
  class Template
6
7
  attr_reader :path, :root, :config
7
8
 
@@ -19,7 +20,12 @@ module RubyUIConverter
19
20
  File.basename(path)
20
21
  end
21
22
 
22
- # "index.html.erb" -> "index"; "_form.html.erb" -> "_form"
23
+ # Source format, from the extension: :erb or :haml.
24
+ def format
25
+ RubyUIConverter.format_for(path) || :erb
26
+ end
27
+
28
+ # "index.html.erb" -> "index"; "_form.html.haml" -> "_form"
23
29
  def basename
24
30
  filename.split(".").first
25
31
  end
@@ -67,7 +73,7 @@ module RubyUIConverter
67
73
  end
68
74
 
69
75
  def tree
70
- @tree ||= Parser.new(source).parse
76
+ @tree ||= RubyUIConverter.parse(source, format: format)
71
77
  end
72
78
 
73
79
  def locals