mjml-rb 0.4.3 → 0.4.4
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/lib/mjml-rb/components/accordion.rb +4 -4
- data/lib/mjml-rb/components/base.rb +4 -0
- data/lib/mjml-rb/components/button.rb +1 -1
- data/lib/mjml-rb/components/navbar.rb +1 -1
- data/lib/mjml-rb/components/raw.rb +1 -1
- data/lib/mjml-rb/components/text.rb +1 -1
- data/lib/mjml-rb/parser.rb +116 -9
- data/lib/mjml-rb/renderer.rb +112 -67
- data/lib/mjml-rb/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 750fc042cf9ff6273cebd5907a096e6fc304d28a49b0872cf6eec93f9818e148
|
|
4
|
+
data.tar.gz: 138a44a38b430267f646cd771b4a9fa01e3c0e9609083291de5270280db36650
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d83da073643a75183303c6a14a6b0cbd2e06d864875c80a4bbfc65ef5c857b5a4ffb2971a0465cc58e46b400efe8a1d760994f643a420da0d549837799f8bf5
|
|
7
|
+
data.tar.gz: 1bd7d0c899cb6de244ab534d0b598b12f437c53ef556cb7fd5c56880ec4d6fc164dedb493ad49e45e60b52e5bd9180dd725b7f91790eb2952440df5d5219bb3a
|
|
@@ -173,7 +173,7 @@ module MjmlRb
|
|
|
173
173
|
when "mj-accordion-element"
|
|
174
174
|
render_accordion_element(child, context, accordion_attrs)
|
|
175
175
|
when "mj-raw"
|
|
176
|
-
|
|
176
|
+
raw_inner_for_body(child)
|
|
177
177
|
else
|
|
178
178
|
render_node(child, context, parent: "mj-accordion")
|
|
179
179
|
end
|
|
@@ -210,7 +210,7 @@ module MjmlRb
|
|
|
210
210
|
child_attrs = attrs.merge(resolved_attributes(child, context))
|
|
211
211
|
content << render_accordion_text(child, child_attrs)
|
|
212
212
|
when "mj-raw"
|
|
213
|
-
content <<
|
|
213
|
+
content << raw_inner_for_body(child)
|
|
214
214
|
end
|
|
215
215
|
end
|
|
216
216
|
end
|
|
@@ -249,7 +249,7 @@ module MjmlRb
|
|
|
249
249
|
"width" => "100%",
|
|
250
250
|
"border-bottom" => title_attrs["border"] || DEFAULTS["border"]
|
|
251
251
|
)
|
|
252
|
-
title_content = node ?
|
|
252
|
+
title_content = node ? raw_inner_for_body(node) : ""
|
|
253
253
|
title_cell = %(<td style="#{td_style}">#{title_content}</td>)
|
|
254
254
|
icon_cell = %(<td class="mj-accordion-ico" style="#{td2_style}"><img src="#{escape_attr(title_attrs["icon-wrapped-url"] || DEFAULTS["icon-wrapped-url"])}" alt="#{escape_attr(title_attrs["icon-wrapped-alt"] || DEFAULTS["icon-wrapped-alt"])}" class="mj-accordion-more" style="#{icon_style}" /><img src="#{escape_attr(title_attrs["icon-unwrapped-url"] || DEFAULTS["icon-unwrapped-url"])}" alt="#{escape_attr(title_attrs["icon-unwrapped-alt"] || DEFAULTS["icon-unwrapped-alt"])}" class="mj-accordion-less" style="#{icon_style}" /></td>)
|
|
255
255
|
cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
|
|
@@ -277,7 +277,7 @@ module MjmlRb
|
|
|
277
277
|
"width" => "100%",
|
|
278
278
|
"border-bottom" => text_attrs["border"] || DEFAULTS["border"]
|
|
279
279
|
)
|
|
280
|
-
content = node ?
|
|
280
|
+
content = node ? raw_inner_for_body(node) : ""
|
|
281
281
|
css_class = text_attrs["css-class"] ? %( class="#{escape_attr(text_attrs["css-class"])}") : ""
|
|
282
282
|
|
|
283
283
|
%(<div class="mj-accordion-content"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr><td#{css_class} style="#{td_style}">#{content}</td></tr></tbody></table></div>)
|
|
@@ -50,6 +50,10 @@ module MjmlRb
|
|
|
50
50
|
renderer.send(:raw_inner, node)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def raw_inner_for_body(node)
|
|
54
|
+
renderer.send(:annotate_raw_html, raw_inner(node))
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
# Like raw_inner but HTML-escapes text nodes. Use for components such as
|
|
54
58
|
# mj-text where the inner content is treated as HTML but bare text must
|
|
55
59
|
# be properly encoded (e.g. & -> &).
|
|
@@ -143,7 +143,7 @@ module MjmlRb
|
|
|
143
143
|
link_attrs["title"] = a["title"]
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
content =
|
|
146
|
+
content = raw_inner_for_body(node)
|
|
147
147
|
inner_tag = %(<#{tag}#{html_attrs(link_attrs)}>#{content}</#{tag}>)
|
|
148
148
|
table = %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{table_style}"><tbody><tr><td#{html_attrs(td_attrs)}>#{inner_tag}</td></tr></tbody></table>)
|
|
149
149
|
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -18,13 +18,22 @@ module MjmlRb
|
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
# Pre-compiled regex patterns to avoid rebuilding on every call
|
|
21
|
-
|
|
21
|
+
ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
|
|
22
22
|
|
|
23
23
|
VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
|
|
24
24
|
VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
|
|
25
25
|
VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
|
|
26
26
|
LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
|
|
27
27
|
BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
|
|
28
|
+
ROOT_LEVEL_HEAD_TAGS = %w[
|
|
29
|
+
mj-attributes
|
|
30
|
+
mj-breakpoint
|
|
31
|
+
mj-html-attributes
|
|
32
|
+
mj-font
|
|
33
|
+
mj-preview
|
|
34
|
+
mj-style
|
|
35
|
+
mj-title
|
|
36
|
+
].freeze
|
|
28
37
|
|
|
29
38
|
class ParseError < StandardError
|
|
30
39
|
attr_reader :line
|
|
@@ -49,6 +58,7 @@ module MjmlRb
|
|
|
49
58
|
|
|
50
59
|
xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
|
|
51
60
|
doc = Document.new(xml)
|
|
61
|
+
normalize_root_head_elements(doc)
|
|
52
62
|
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
53
63
|
rescue ParseException => e
|
|
54
64
|
raise ParseError.new("XML parse error: #{e.message}")
|
|
@@ -173,6 +183,7 @@ module MjmlRb
|
|
|
173
183
|
|
|
174
184
|
def extract_mjml_include_children(xml)
|
|
175
185
|
include_doc = Document.new(sanitize_bare_ampersands(xml))
|
|
186
|
+
normalize_root_head_elements(include_doc)
|
|
176
187
|
mjml_root = include_doc.root
|
|
177
188
|
return [[], []] unless mjml_root&.name == "mjml"
|
|
178
189
|
|
|
@@ -219,6 +230,38 @@ module MjmlRb
|
|
|
219
230
|
head
|
|
220
231
|
end
|
|
221
232
|
|
|
233
|
+
def normalize_root_head_elements(doc)
|
|
234
|
+
mjml_root = doc.root
|
|
235
|
+
return unless mjml_root&.name == "mjml"
|
|
236
|
+
|
|
237
|
+
head_nodes = []
|
|
238
|
+
normalized_head_children = []
|
|
239
|
+
root_head_elements = []
|
|
240
|
+
|
|
241
|
+
mjml_root.children.each do |child|
|
|
242
|
+
next unless child.is_a?(Element)
|
|
243
|
+
|
|
244
|
+
if child.name == "mj-head"
|
|
245
|
+
head_nodes << child
|
|
246
|
+
child.children.each { |head_child| normalized_head_children << deep_clone(head_child) }
|
|
247
|
+
elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
|
|
248
|
+
root_head_elements << child
|
|
249
|
+
normalized_head_children << deep_clone(child)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return if root_head_elements.empty? && head_nodes.length <= 1
|
|
254
|
+
|
|
255
|
+
head = head_nodes.first || ensure_head(doc)
|
|
256
|
+
return unless head
|
|
257
|
+
|
|
258
|
+
head.children.to_a.each { |child| head.delete(child) }
|
|
259
|
+
normalized_head_children.each { |child| head.add(child) }
|
|
260
|
+
|
|
261
|
+
root_head_elements.each { |child| mjml_root.delete(child) }
|
|
262
|
+
head_nodes.drop(1).each { |extra_head| mjml_root.delete(extra_head) }
|
|
263
|
+
end
|
|
264
|
+
|
|
222
265
|
def strip_xml_declaration(content)
|
|
223
266
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
224
267
|
end
|
|
@@ -239,20 +282,74 @@ module MjmlRb
|
|
|
239
282
|
end
|
|
240
283
|
|
|
241
284
|
def wrap_ending_tags_in_cdata(content)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
wrapped = +""
|
|
286
|
+
cursor = 0
|
|
287
|
+
|
|
288
|
+
while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
|
|
289
|
+
tag = match[1]
|
|
290
|
+
attrs = match[2].to_s
|
|
291
|
+
wrapped << content[cursor...match.begin(0)]
|
|
292
|
+
|
|
293
|
+
closing_range = find_matching_ending_tag(content, tag, match.end(0))
|
|
294
|
+
unless closing_range
|
|
295
|
+
wrapped << match[0]
|
|
296
|
+
cursor = match.end(0)
|
|
297
|
+
next
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
inner = content[match.end(0)...closing_range.begin(0)]
|
|
247
301
|
if inner.include?("<![CDATA[")
|
|
248
|
-
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
302
|
+
wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
249
303
|
else
|
|
250
304
|
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
251
305
|
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
252
306
|
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
253
|
-
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
307
|
+
wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
254
308
|
end
|
|
309
|
+
|
|
310
|
+
cursor = closing_range.end(0)
|
|
255
311
|
end
|
|
312
|
+
|
|
313
|
+
wrapped << content[cursor..] if cursor < content.length
|
|
314
|
+
wrapped
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def find_matching_ending_tag(content, tag_name, cursor)
|
|
318
|
+
open_tag_re = /<#{Regexp.escape(tag_name)}(\s[^<>]*?)?(?<!\/)>/mi
|
|
319
|
+
close_tag_re = %r{</#{Regexp.escape(tag_name)}\s*>}i
|
|
320
|
+
depth = 1
|
|
321
|
+
|
|
322
|
+
while cursor < content.length
|
|
323
|
+
cdata_index = content.index("<![CDATA[", cursor)
|
|
324
|
+
open_match = open_tag_re.match(content, cursor)
|
|
325
|
+
close_match = close_tag_re.match(content, cursor)
|
|
326
|
+
|
|
327
|
+
candidates = []
|
|
328
|
+
candidates << [:cdata, cdata_index, nil] if cdata_index
|
|
329
|
+
candidates << [:open, open_match.begin(0), open_match] if open_match
|
|
330
|
+
candidates << [:close, close_match.begin(0), close_match] if close_match
|
|
331
|
+
return nil if candidates.empty?
|
|
332
|
+
|
|
333
|
+
kind, _, match = candidates.min_by { |candidate| candidate[1] }
|
|
334
|
+
|
|
335
|
+
case kind
|
|
336
|
+
when :cdata
|
|
337
|
+
cdata_end = content.index("]]>", cdata_index + 9)
|
|
338
|
+
return nil unless cdata_end
|
|
339
|
+
|
|
340
|
+
cursor = cdata_end + 3
|
|
341
|
+
when :open
|
|
342
|
+
depth += 1
|
|
343
|
+
cursor = match.end(0)
|
|
344
|
+
when :close
|
|
345
|
+
depth -= 1
|
|
346
|
+
return match if depth.zero?
|
|
347
|
+
|
|
348
|
+
cursor = match.end(0)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
nil
|
|
256
353
|
end
|
|
257
354
|
|
|
258
355
|
def escape_cdata(content)
|
|
@@ -337,6 +434,7 @@ module MjmlRb
|
|
|
337
434
|
attrs = element.attributes.each_with_object({}) do |(name, val), h|
|
|
338
435
|
h[name] = val unless name.start_with?("data-mjml-")
|
|
339
436
|
end
|
|
437
|
+
attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
|
|
340
438
|
|
|
341
439
|
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
342
440
|
# the raw HTML directly as content instead of parsing structurally.
|
|
@@ -358,7 +456,10 @@ module MjmlRb
|
|
|
358
456
|
memo << element_to_ast(child, keep_comments: keep_comments)
|
|
359
457
|
when Text
|
|
360
458
|
text = child.value
|
|
361
|
-
|
|
459
|
+
next if text.empty?
|
|
460
|
+
next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
|
|
461
|
+
|
|
462
|
+
memo << AstNode.new(tag_name: "#text", content: text)
|
|
362
463
|
when Comment
|
|
363
464
|
memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
|
|
364
465
|
end
|
|
@@ -372,5 +473,11 @@ module MjmlRb
|
|
|
372
473
|
file: meta_file
|
|
373
474
|
)
|
|
374
475
|
end
|
|
476
|
+
|
|
477
|
+
def ignorable_whitespace_text?(text, parent_element_name:)
|
|
478
|
+
return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
|
|
479
|
+
|
|
480
|
+
text.match?(/[\r\n]/)
|
|
481
|
+
end
|
|
375
482
|
end
|
|
376
483
|
end
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -168,6 +168,8 @@ module MjmlRb
|
|
|
168
168
|
HTML
|
|
169
169
|
|
|
170
170
|
html = apply_inline_styles(html, context)
|
|
171
|
+
html = preserve_raw_tag_spacing(html)
|
|
172
|
+
html = strip_internal_raw_markers(html)
|
|
171
173
|
html = merge_outlook_conditionals(html)
|
|
172
174
|
before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
|
|
173
175
|
end
|
|
@@ -342,20 +344,27 @@ module MjmlRb
|
|
|
342
344
|
return html if css_blocks.empty?
|
|
343
345
|
|
|
344
346
|
document = parse_html_document(html)
|
|
345
|
-
rules,
|
|
347
|
+
rules, = parse_inline_css_rules(css_blocks.join("\n"))
|
|
348
|
+
merged_declarations_by_node = {}
|
|
349
|
+
touched_properties_by_node = Hash.new { |hash, node| hash[node] = Set.new }
|
|
346
350
|
|
|
347
351
|
rules.each do |selector, declarations|
|
|
348
352
|
next if selector.empty? || declarations.empty?
|
|
349
353
|
|
|
350
354
|
select_nodes(document, selector).each do |node|
|
|
351
|
-
|
|
355
|
+
existing = merged_declarations_by_node[node] ||= begin
|
|
356
|
+
source = node["data-mjml-raw"] == "true" ? :inline : :css
|
|
357
|
+
parsed = parse_css_declarations(node["style"].to_s, source: source)
|
|
358
|
+
touched_properties_by_node[node].merge(parsed.keys & HTML_ATTRIBUTE_SYNC_PROPERTIES.to_a) if source == :inline
|
|
359
|
+
parsed
|
|
360
|
+
end
|
|
361
|
+
merge_inline_declarations!(existing, declarations, touched_properties_by_node[node])
|
|
352
362
|
end
|
|
353
363
|
end
|
|
354
364
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
inject_preserved_at_rules(document, at_rules_css)
|
|
365
|
+
merged_declarations_by_node.each do |node, declarations|
|
|
366
|
+
finalize_inline_style!(node, declarations, touched_properties_by_node[node])
|
|
367
|
+
end
|
|
359
368
|
|
|
360
369
|
document.to_html
|
|
361
370
|
end
|
|
@@ -368,18 +377,6 @@ module MjmlRb
|
|
|
368
377
|
end
|
|
369
378
|
end
|
|
370
379
|
|
|
371
|
-
def inject_preserved_at_rules(document, at_rules_css)
|
|
372
|
-
return if at_rules_css.nil? || at_rules_css.strip.empty?
|
|
373
|
-
|
|
374
|
-
head = document.at_css("head")
|
|
375
|
-
return unless head
|
|
376
|
-
|
|
377
|
-
style = Nokogiri::XML::Node.new("style", document)
|
|
378
|
-
style["type"] = "text/css"
|
|
379
|
-
style.content = at_rules_css.strip
|
|
380
|
-
head.add_child(style)
|
|
381
|
-
end
|
|
382
|
-
|
|
383
380
|
def select_nodes(document, selector)
|
|
384
381
|
document.css(selector)
|
|
385
382
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
@@ -517,7 +514,7 @@ module MjmlRb
|
|
|
517
514
|
[a, b, c]
|
|
518
515
|
end
|
|
519
516
|
|
|
520
|
-
def parse_css_declarations(declarations)
|
|
517
|
+
def parse_css_declarations(declarations, source: :css)
|
|
521
518
|
declarations.split(";").each_with_object({}) do |entry, memo|
|
|
522
519
|
property, value = entry.split(":", 2).map { |part| part&.strip }
|
|
523
520
|
next if property.nil? || property.empty? || value.nil? || value.empty?
|
|
@@ -525,41 +522,26 @@ module MjmlRb
|
|
|
525
522
|
important = value.match?(/\s*!important\s*\z/)
|
|
526
523
|
memo[property] = {
|
|
527
524
|
value: value.sub(/\s*!important\s*\z/, "").strip,
|
|
528
|
-
important: important
|
|
525
|
+
important: important,
|
|
526
|
+
source: source
|
|
529
527
|
}
|
|
530
528
|
end
|
|
531
529
|
end
|
|
532
530
|
|
|
533
|
-
def
|
|
534
|
-
existing = parse_css_declarations(node["style"].to_s)
|
|
531
|
+
def merge_inline_declarations!(existing, declarations, touched_properties)
|
|
535
532
|
declarations.each do |property, value|
|
|
536
533
|
merged = merge_css_declaration(existing[property], value)
|
|
537
534
|
next if merged.equal?(existing[property])
|
|
538
535
|
|
|
539
536
|
existing.delete(property)
|
|
540
537
|
existing[property] = merged
|
|
538
|
+
touched_properties << property
|
|
541
539
|
end
|
|
542
|
-
normalize_background_fallbacks!(node, existing)
|
|
543
|
-
sync_html_attributes!(node, existing)
|
|
544
|
-
node["style"] = serialize_css_declarations(existing)
|
|
545
540
|
end
|
|
546
541
|
|
|
547
|
-
def
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
declarations.delete("background") if syncable_background?(declaration_value(declarations["background"]))
|
|
551
|
-
return
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
background_color = declaration_value(declarations["background-color"])
|
|
555
|
-
return if background_color.nil? || background_color.empty?
|
|
556
|
-
|
|
557
|
-
if syncable_background?(declaration_value(declarations["background"]))
|
|
558
|
-
declarations["background"] = {
|
|
559
|
-
value: background_color,
|
|
560
|
-
important: declarations.fetch("background-color", {}).fetch(:important, false)
|
|
561
|
-
}
|
|
562
|
-
end
|
|
542
|
+
def finalize_inline_style!(node, declarations, touched_properties)
|
|
543
|
+
sync_html_attributes!(node, declarations, touched_properties)
|
|
544
|
+
node["style"] = serialize_css_declarations(declarations)
|
|
563
545
|
end
|
|
564
546
|
|
|
565
547
|
# Sync HTML attributes from inlined CSS declarations.
|
|
@@ -575,15 +557,19 @@ module MjmlRb
|
|
|
575
557
|
"text-align" => "align",
|
|
576
558
|
"vertical-align" => "valign"
|
|
577
559
|
}.freeze
|
|
560
|
+
HTML_ATTRIBUTE_SYNC_PROPERTIES = Set.new((%w[width height] + STYLE_TO_ATTRIBUTE.keys)).freeze
|
|
578
561
|
|
|
579
|
-
def sync_html_attributes!(node, declarations)
|
|
562
|
+
def sync_html_attributes!(node, declarations, touched_properties = nil)
|
|
580
563
|
tag = node.name.downcase
|
|
581
564
|
|
|
582
565
|
# Sync width/height on TABLE, TD, TH, IMG
|
|
583
566
|
if WIDTH_HEIGHT_ELEMENTS.include?(tag)
|
|
584
567
|
%w[width height].each do |prop|
|
|
568
|
+
next if touched_properties && !touched_properties.include?(prop)
|
|
569
|
+
|
|
585
570
|
css_value = declaration_value(declarations[prop])
|
|
586
571
|
next if css_value.nil? || css_value.empty?
|
|
572
|
+
next if tag == "img" && prop == "width" && css_value.include?("%")
|
|
587
573
|
|
|
588
574
|
# Convert CSS px values to plain numbers for HTML attributes;
|
|
589
575
|
# keep other values (auto, %) as-is.
|
|
@@ -595,6 +581,8 @@ module MjmlRb
|
|
|
595
581
|
# Sync style-to-attribute mappings on table elements
|
|
596
582
|
if TABLE_ELEMENTS.include?(tag)
|
|
597
583
|
STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
|
|
584
|
+
next if touched_properties && !touched_properties.include?(css_prop)
|
|
585
|
+
|
|
598
586
|
css_value = declaration_value(declarations[css_prop])
|
|
599
587
|
next if css_value.nil? || css_value.empty?
|
|
600
588
|
|
|
@@ -615,26 +603,10 @@ module MjmlRb
|
|
|
615
603
|
end
|
|
616
604
|
end
|
|
617
605
|
|
|
618
|
-
def syncable_background?(value)
|
|
619
|
-
return true if value.nil? || value.empty?
|
|
620
|
-
|
|
621
|
-
normalized = value.downcase
|
|
622
|
-
!normalized.include?("url(") &&
|
|
623
|
-
!normalized.include?("gradient(") &&
|
|
624
|
-
!normalized.include?("/") &&
|
|
625
|
-
!normalized.include?(" no-repeat") &&
|
|
626
|
-
!normalized.include?(" repeat") &&
|
|
627
|
-
!normalized.include?(" fixed") &&
|
|
628
|
-
!normalized.include?(" scroll") &&
|
|
629
|
-
!normalized.include?(" center") &&
|
|
630
|
-
!normalized.include?(" top") &&
|
|
631
|
-
!normalized.include?(" bottom") &&
|
|
632
|
-
!normalized.include?(" left") &&
|
|
633
|
-
!normalized.include?(" right")
|
|
634
|
-
end
|
|
635
|
-
|
|
636
606
|
def merge_css_declaration(existing, incoming)
|
|
637
607
|
return incoming if existing.nil?
|
|
608
|
+
return incoming if incoming[:important] && !existing[:important]
|
|
609
|
+
return existing if existing[:source] == :inline
|
|
638
610
|
return existing if existing[:important] && !incoming[:important]
|
|
639
611
|
|
|
640
612
|
incoming
|
|
@@ -645,13 +617,49 @@ module MjmlRb
|
|
|
645
617
|
end
|
|
646
618
|
|
|
647
619
|
def serialize_css_declarations(declarations)
|
|
648
|
-
declarations.map do |property, declaration|
|
|
649
|
-
|
|
650
|
-
value = "#{value} !important" if declaration[:important]
|
|
651
|
-
"#{property}: #{value}"
|
|
620
|
+
ordered_css_declarations(declarations).map do |property, declaration|
|
|
621
|
+
"#{property}: #{declaration[:value]}"
|
|
652
622
|
end.join("; ")
|
|
653
623
|
end
|
|
654
624
|
|
|
625
|
+
SHORTHAND_LONGHAND_FAMILIES = {
|
|
626
|
+
"background" => /\Abackground-/,
|
|
627
|
+
"border" => /\Aborder(?:-(?!collapse|spacing)[a-z-]+)?\z/,
|
|
628
|
+
"border-radius" => /\Aborder-(?:top|bottom)-(?:left|right)-radius\z/,
|
|
629
|
+
"font" => /\Afont-/,
|
|
630
|
+
"list-style" => /\Alist-style-/,
|
|
631
|
+
"margin" => /\Amargin-(?:top|right|bottom|left)\z/,
|
|
632
|
+
"padding" => /\Apadding-(?:top|right|bottom|left)\z/
|
|
633
|
+
}.freeze
|
|
634
|
+
|
|
635
|
+
def ordered_css_declarations(declarations)
|
|
636
|
+
ordered = declarations.to_a
|
|
637
|
+
|
|
638
|
+
SHORTHAND_LONGHAND_FAMILIES.each do |shorthand, longhand_pattern|
|
|
639
|
+
family_indexes = ordered.each_index.select do |index|
|
|
640
|
+
property = ordered[index][0]
|
|
641
|
+
property == shorthand || property.match?(longhand_pattern)
|
|
642
|
+
end
|
|
643
|
+
next if family_indexes.length < 2
|
|
644
|
+
|
|
645
|
+
family_entries = family_indexes.map.with_index do |declaration_index, original_family_index|
|
|
646
|
+
[ordered[declaration_index], original_family_index]
|
|
647
|
+
end
|
|
648
|
+
next unless family_entries.any? { |((_, declaration), _)| declaration[:important] }
|
|
649
|
+
next unless family_entries.any? { |((_, declaration), _)| !declaration[:important] }
|
|
650
|
+
|
|
651
|
+
reordered_entries = family_entries.sort_by do |((_, declaration), original_family_index)|
|
|
652
|
+
[declaration[:important] ? 1 : 0, original_family_index]
|
|
653
|
+
end.map(&:first)
|
|
654
|
+
|
|
655
|
+
family_indexes.each_with_index do |declaration_index, reordered_index|
|
|
656
|
+
ordered[declaration_index] = reordered_entries[reordered_index]
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
ordered
|
|
661
|
+
end
|
|
662
|
+
|
|
655
663
|
def append_component_head_styles(document, context)
|
|
656
664
|
all_tags = collect_tag_names(document)
|
|
657
665
|
|
|
@@ -746,7 +754,7 @@ module MjmlRb
|
|
|
746
754
|
if node.respond_to?(:children)
|
|
747
755
|
node.children.map do |child|
|
|
748
756
|
if child.text?
|
|
749
|
-
escape_html(child.content.to_s)
|
|
757
|
+
serialize_text_content(escape_html(child.content.to_s))
|
|
750
758
|
elsif child.comment?
|
|
751
759
|
"<!--#{child.content}-->"
|
|
752
760
|
else
|
|
@@ -765,7 +773,7 @@ module MjmlRb
|
|
|
765
773
|
if node.respond_to?(:children)
|
|
766
774
|
node.children.map do |child|
|
|
767
775
|
if child.text?
|
|
768
|
-
child.content.to_s
|
|
776
|
+
serialize_text_content(child.content.to_s)
|
|
769
777
|
elsif child.comment?
|
|
770
778
|
"<!--#{child.content}-->"
|
|
771
779
|
else
|
|
@@ -777,15 +785,52 @@ module MjmlRb
|
|
|
777
785
|
end
|
|
778
786
|
end
|
|
779
787
|
|
|
788
|
+
def annotate_raw_html(content)
|
|
789
|
+
return content if content.nil? || content.empty? || !content.include?("<")
|
|
790
|
+
content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
|
|
791
|
+
tag_name = Regexp.last_match(1)
|
|
792
|
+
attrs = Regexp.last_match(2).to_s
|
|
793
|
+
closing = Regexp.last_match(3).to_s
|
|
794
|
+
"<#{tag_name}#{attrs} data-mjml-raw=\"true\"#{closing}>"
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def strip_internal_raw_markers(html)
|
|
799
|
+
html.gsub(/\sdata-mjml-raw=(['"])true\1/, "")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def preserve_raw_tag_spacing(html)
|
|
803
|
+
html.gsub(
|
|
804
|
+
/(<[^>]+data-mjml-raw=(['"])true\2[^>]*>)([ \t]+)(<[^\/!][^>]+data-mjml-raw=(['"])true\5[^>]*>)/
|
|
805
|
+
) do
|
|
806
|
+
"#{Regexp.last_match(1)}#{encode_whitespace_entities(Regexp.last_match(3))}#{Regexp.last_match(4)}"
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def encode_whitespace_entities(text)
|
|
811
|
+
text.to_s.gsub(" ", " ").gsub("\t", "	")
|
|
812
|
+
end
|
|
813
|
+
|
|
780
814
|
def serialize_node(node)
|
|
781
815
|
attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
|
|
782
816
|
return "<#{node.tag_name}#{attrs} />" if node.children.empty? && html_void_tag?(node.tag_name)
|
|
783
817
|
return "<#{node.tag_name}#{attrs}></#{node.tag_name}>" if node.children.empty?
|
|
784
818
|
|
|
785
|
-
inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
|
|
819
|
+
inner = node.children.map { |child| child.text? ? serialize_text_content(child.content.to_s) : serialize_node(child) }.join
|
|
786
820
|
"<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
|
|
787
821
|
end
|
|
788
822
|
|
|
823
|
+
def serialize_text_content(text)
|
|
824
|
+
value = text.to_s
|
|
825
|
+
return value unless significant_whitespace_text?(value)
|
|
826
|
+
|
|
827
|
+
value.gsub(" ", " ").gsub("\t", "	")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def significant_whitespace_text?(text)
|
|
831
|
+
!text.empty? && text.strip.empty? && !text.match?(/[\r\n]/)
|
|
832
|
+
end
|
|
833
|
+
|
|
789
834
|
def html_void_tag?(tag_name)
|
|
790
835
|
HTML_VOID_TAGS.include?(tag_name.to_s.downcase)
|
|
791
836
|
end
|
data/lib/mjml-rb/version.rb
CHANGED