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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +146 -9
- data/lib/ruby_ui_converter/cli.rb +22 -6
- data/lib/ruby_ui_converter/converter.rb +15 -2
- data/lib/ruby_ui_converter/file_walker.rb +12 -5
- data/lib/ruby_ui_converter/form_builder.rb +4 -0
- data/lib/ruby_ui_converter/haml_attributes.rb +186 -0
- data/lib/ruby_ui_converter/haml_parser.rb +326 -0
- data/lib/ruby_ui_converter/html_entities.rb +89 -0
- data/lib/ruby_ui_converter/locals_detector.rb +2 -0
- data/lib/ruby_ui_converter/nodes.rb +5 -1
- data/lib/ruby_ui_converter/parser.rb +8 -2
- data/lib/ruby_ui_converter/ruby_literal.rb +100 -0
- data/lib/ruby_ui_converter/template.rb +9 -3
- data/lib/ruby_ui_converter/transformer.rb +76 -15
- data/lib/ruby_ui_converter/version.rb +1 -1
- data/lib/ruby_ui_converter.rb +29 -3
- metadata +25 -6
|
@@ -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 (`©`, ` `, `—`, `—`) into the characters they
|
|
6
|
+
# stand for. Phlex escapes everything it outputs, so leaving them encoded
|
|
7
|
+
# would double-escape them (`©` would render as the literal text
|
|
8
|
+
# "©"); decoded, Phlex re-escapes only what needs it (`<` -> `<` ->
|
|
9
|
+
# `<`).
|
|
10
|
+
#
|
|
11
|
+
# Named references come from the HTML 4 set (plus `'`) — 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
|
|
@@ -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 & 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
|
|
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
|
-
#
|
|
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 ||=
|
|
76
|
+
@tree ||= RubyUIConverter.parse(source, format: format)
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
def locals
|