mjml-rb 0.2.31 → 0.2.33
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/section.rb +34 -10
- data/lib/mjml-rb/components/text.rb +1 -1
- data/lib/mjml-rb/parser.rb +109 -15
- data/lib/mjml-rb/renderer.rb +78 -7
- 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: 6147ba24e9e7ede9406ebbaa48655e885f72e7f61d14c0da37ecde7ecdc66a68
|
|
4
|
+
data.tar.gz: 6809e1ba8ee7927d565831840a5938dea5bc37abfaff3691d4b60dd4eacd67d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 961a93196418591ce8d2f485697ac7cdf7bd4ef2cbf2f53eac02b0811190b324e9d035c442d6d53b48e3faa94db47c44a3b32098354e36f5348ae2b4d1351372
|
|
7
|
+
data.tar.gz: 98a28a390dda4f04d50ab4170c8e277cb7546023bcb5da0aefd242e2524a00d9beb5a91d5c28c18dc308644072911630da43437a5f2b06d96972aa3b13454175
|
|
@@ -123,6 +123,12 @@ module MjmlRb
|
|
|
123
123
|
value && !value.to_s.strip.empty?
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# Matches npm's suffixCssClasses: "foo bar" → "foo-outlook bar-outlook"
|
|
127
|
+
def suffix_css_classes(classes, suffix = "outlook")
|
|
128
|
+
return "" unless classes && !classes.strip.empty?
|
|
129
|
+
classes.split(" ").map { |c| "#{c}-#{suffix}" }.join(" ")
|
|
130
|
+
end
|
|
131
|
+
|
|
126
132
|
# Merge adjacent Outlook conditional comments. Applied locally within
|
|
127
133
|
# each section/wrapper to avoid incorrectly merging across sibling sections.
|
|
128
134
|
def merge_outlook_conditionals(html)
|
|
@@ -388,7 +394,8 @@ module MjmlRb
|
|
|
388
394
|
end
|
|
389
395
|
|
|
390
396
|
def render_section_before(css_class, container_px, bg_color, wrapper_gap)
|
|
391
|
-
outlook_class = css_class
|
|
397
|
+
outlook_class = suffix_css_classes(css_class)
|
|
398
|
+
has_gap = wrapper_gap && !wrapper_gap.to_s.strip.empty?
|
|
392
399
|
before_pairs = [
|
|
393
400
|
["align", "center"],
|
|
394
401
|
["border", "0"],
|
|
@@ -399,7 +406,7 @@ module MjmlRb
|
|
|
399
406
|
["style", style_join("width" => "#{container_px}px", "padding-top" => wrapper_gap) + ";"],
|
|
400
407
|
["width", container_px.to_s]
|
|
401
408
|
]
|
|
402
|
-
before_pairs << ["bgcolor", bg_color] if bg_color
|
|
409
|
+
before_pairs << ["bgcolor", bg_color] if bg_color && !has_gap
|
|
403
410
|
|
|
404
411
|
%(<!--[if mso | IE]><table#{outlook_attrs(before_pairs)}><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->)
|
|
405
412
|
end
|
|
@@ -576,7 +583,8 @@ module MjmlRb
|
|
|
576
583
|
end
|
|
577
584
|
|
|
578
585
|
# renderBefore — Outlook conditional table wrapper
|
|
579
|
-
outlook_class = css_class
|
|
586
|
+
outlook_class = suffix_css_classes(css_class)
|
|
587
|
+
has_gap = wrapper_gap && !wrapper_gap.to_s.strip.empty?
|
|
580
588
|
before_pairs = [
|
|
581
589
|
["align", "center"],
|
|
582
590
|
["border", "0"],
|
|
@@ -587,7 +595,7 @@ module MjmlRb
|
|
|
587
595
|
["style", style_join("width" => "#{container_px}px", "padding-top" => wrapper_gap) + ";"],
|
|
588
596
|
["width", container_px.to_s]
|
|
589
597
|
]
|
|
590
|
-
before_pairs << ["bgcolor", bg_color] if bg_color
|
|
598
|
+
before_pairs << ["bgcolor", bg_color] if bg_color && !has_gap
|
|
591
599
|
|
|
592
600
|
render_before = %(<!--[if mso | IE]><table#{outlook_attrs(before_pairs)}><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->)
|
|
593
601
|
|
|
@@ -622,6 +630,17 @@ module MjmlRb
|
|
|
622
630
|
"text-align" => a["text-align"]
|
|
623
631
|
)
|
|
624
632
|
|
|
633
|
+
# Compute box width and update context for children, matching npm
|
|
634
|
+
# wrapper's getChildContext() which sets containerWidth to box width.
|
|
635
|
+
border_left = parse_border_width(a["border-left"] || a["border"])
|
|
636
|
+
border_right = parse_border_width(a["border-right"] || a["border"])
|
|
637
|
+
pad_left = parse_padding_side(a, "left")
|
|
638
|
+
pad_right = parse_padding_side(a, "right")
|
|
639
|
+
box_width = container_px - pad_left - pad_right - border_left - border_right
|
|
640
|
+
|
|
641
|
+
previous_container_width = context[:container_width]
|
|
642
|
+
context[:container_width] = "#{box_width}px"
|
|
643
|
+
|
|
625
644
|
div_attrs = {"class" => (full_width ? nil : css_class), "style" => div_style}
|
|
626
645
|
inner = merge_outlook_conditionals(render_wrapped_children_wrapper(node, context, container_px, a["gap"]))
|
|
627
646
|
inner_content = bg_has ? %(<div style="line-height:0;font-size:0">#{inner}</div>) : inner
|
|
@@ -665,22 +684,27 @@ module MjmlRb
|
|
|
665
684
|
body = bg_has ? render_with_background(wrapper_html, a, container_px) : wrapper_html
|
|
666
685
|
"#{render_before}\n#{body}\n#{render_after}"
|
|
667
686
|
end
|
|
687
|
+
ensure
|
|
688
|
+
context[:container_width] = previous_container_width if previous_container_width
|
|
668
689
|
end
|
|
669
690
|
|
|
670
|
-
# Wrap each child mj-section/mj-wrapper in an Outlook conditional <td>.
|
|
691
|
+
# Wrap each child mj-section/mj-wrapper in an Outlook conditional <tr><td>.
|
|
692
|
+
# npm wrapper renders each child in its own row, unlike section which
|
|
693
|
+
# places all columns in a single row.
|
|
671
694
|
def render_wrapped_children_wrapper(node, context, container_px, gap)
|
|
672
695
|
children = node.element_children.select { |e| %w[mj-section mj-wrapper].include?(e.tag_name) }
|
|
673
696
|
return render_children(node, context, parent: "mj-wrapper") if children.empty?
|
|
674
697
|
|
|
675
698
|
open_table = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><![endif]-->)
|
|
676
|
-
open_tr = %(<!--[if mso | IE]><tr><![endif]-->)
|
|
677
|
-
close_tr = %(<!--[if mso | IE]></tr><![endif]-->)
|
|
678
699
|
close_table = %(<!--[if mso | IE]></table><![endif]-->)
|
|
679
700
|
|
|
680
701
|
section_parts = with_inherited_mj_class(context, node) do
|
|
681
702
|
children.each_with_index.map do |child, index|
|
|
682
|
-
|
|
683
|
-
|
|
703
|
+
child_attrs = resolved_attributes(child, context)
|
|
704
|
+
child_css = child_attrs["css-class"]
|
|
705
|
+
outlook_class = suffix_css_classes(child_css)
|
|
706
|
+
td_open = %(<!--[if mso | IE]><tr><td class="#{escape_attr(outlook_class)}" width="#{container_px}px" ><![endif]-->)
|
|
707
|
+
td_close = %(<!--[if mso | IE]></td></tr><![endif]-->)
|
|
684
708
|
child_html = with_wrapper_child_gap(context, index.zero? ? nil : gap) do
|
|
685
709
|
render_node(child, context, parent: "mj-wrapper")
|
|
686
710
|
end
|
|
@@ -688,7 +712,7 @@ module MjmlRb
|
|
|
688
712
|
end
|
|
689
713
|
end
|
|
690
714
|
|
|
691
|
-
([open_table
|
|
715
|
+
([open_table] + section_parts + [close_table]).join("\n")
|
|
692
716
|
end
|
|
693
717
|
|
|
694
718
|
def with_wrapper_child_gap(context, gap)
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -8,6 +8,15 @@ module MjmlRb
|
|
|
8
8
|
include REXML
|
|
9
9
|
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
10
10
|
|
|
11
|
+
# Ending-tag components whose inner HTML is preserved as raw content via CDATA
|
|
12
|
+
# wrapping, matching upstream npm's endingTag behavior. mj-table is excluded
|
|
13
|
+
# because its component needs structural AST access for attribute normalization.
|
|
14
|
+
# mj-carousel-image is excluded because it has no meaningful inner content.
|
|
15
|
+
ENDING_TAGS_FOR_CDATA = %w[
|
|
16
|
+
mj-accordion-text mj-accordion-title mj-button
|
|
17
|
+
mj-navbar-link mj-raw mj-text
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
11
20
|
class ParseError < StandardError
|
|
12
21
|
attr_reader :line
|
|
13
22
|
|
|
@@ -20,7 +29,7 @@ module MjmlRb
|
|
|
20
29
|
def parse(mjml, options = {})
|
|
21
30
|
opts = normalize_options(options)
|
|
22
31
|
xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
|
|
23
|
-
xml =
|
|
32
|
+
xml = wrap_ending_tags_in_cdata(xml)
|
|
24
33
|
xml = normalize_html_void_tags(xml)
|
|
25
34
|
xml = expand_includes(xml, opts) unless opts[:ignore_includes]
|
|
26
35
|
|
|
@@ -48,29 +57,66 @@ module MjmlRb
|
|
|
48
57
|
end
|
|
49
58
|
end
|
|
50
59
|
|
|
51
|
-
def expand_includes(xml, options)
|
|
52
|
-
xml =
|
|
60
|
+
def expand_includes(xml, options, included_in = [])
|
|
61
|
+
xml = wrap_ending_tags_in_cdata(xml)
|
|
53
62
|
xml = normalize_html_void_tags(xml)
|
|
54
63
|
doc = Document.new(sanitize_bare_ampersands(xml))
|
|
55
64
|
includes = XPath.match(doc, "//mj-include")
|
|
56
65
|
return xml if includes.empty?
|
|
57
66
|
|
|
67
|
+
css_includes = []
|
|
68
|
+
|
|
58
69
|
includes.reverse_each do |include_node|
|
|
59
70
|
path_attr = include_node.attributes["path"]
|
|
60
71
|
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
61
72
|
|
|
62
73
|
include_type = include_node.attributes["type"].to_s
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
parent = include_node.parent
|
|
75
|
+
|
|
76
|
+
resolved_path = begin
|
|
77
|
+
resolve_include_path(path_attr, options[:actual_path], options[:file_path])
|
|
78
|
+
rescue Errno::ENOENT
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
include_content = resolved_path ? File.read(resolved_path) : nil
|
|
83
|
+
|
|
84
|
+
if include_content.nil?
|
|
85
|
+
# Collect error as an mj-raw comment node instead of raising
|
|
86
|
+
display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
|
|
87
|
+
error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
|
|
88
|
+
error_node = Element.new("mj-raw")
|
|
89
|
+
error_node.add(CData.new(error_comment))
|
|
90
|
+
parent.insert_before(include_node, error_node)
|
|
91
|
+
parent.delete(include_node)
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Circular include detection
|
|
96
|
+
if included_in.include?(resolved_path)
|
|
97
|
+
raise ParseError, "Circular inclusion detected on file : #{resolved_path}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if include_type == "css"
|
|
101
|
+
# CSS includes get collected and added to mj-head later
|
|
102
|
+
css_inline = include_node.attributes["css-inline"].to_s
|
|
103
|
+
css_includes << { content: include_content, inline: css_inline == "inline" }
|
|
104
|
+
parent.delete(include_node)
|
|
105
|
+
next
|
|
106
|
+
end
|
|
65
107
|
|
|
66
108
|
replacement = if include_type == "html"
|
|
67
109
|
%(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
|
|
68
110
|
else
|
|
69
|
-
|
|
111
|
+
prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
|
|
112
|
+
# Recursively expand includes in the included content
|
|
113
|
+
expand_includes(prepared, options.merge(
|
|
114
|
+
actual_path: resolved_path,
|
|
115
|
+
file_path: File.dirname(resolved_path)
|
|
116
|
+
), included_in + [resolved_path])
|
|
70
117
|
end
|
|
71
118
|
|
|
72
119
|
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
73
|
-
parent = include_node.parent
|
|
74
120
|
insert_before = include_node
|
|
75
121
|
fragment.root.children.each do |child|
|
|
76
122
|
parent.insert_before(insert_before, deep_clone(child))
|
|
@@ -78,15 +124,44 @@ module MjmlRb
|
|
|
78
124
|
parent.delete(include_node)
|
|
79
125
|
end
|
|
80
126
|
|
|
127
|
+
# Inject CSS includes into mj-head
|
|
128
|
+
unless css_includes.empty?
|
|
129
|
+
inject_css_includes(doc, css_includes)
|
|
130
|
+
end
|
|
131
|
+
|
|
81
132
|
output = +""
|
|
82
133
|
doc.write(output)
|
|
83
134
|
output
|
|
84
|
-
rescue Errno::ENOENT => e
|
|
85
|
-
raise ParseError, "Cannot read included file: #{e.message}"
|
|
86
135
|
rescue ParseException => e
|
|
87
136
|
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
88
137
|
end
|
|
89
138
|
|
|
139
|
+
def inject_css_includes(doc, css_includes)
|
|
140
|
+
mjml_root = doc.root
|
|
141
|
+
return unless mjml_root
|
|
142
|
+
|
|
143
|
+
# Find or create mj-head
|
|
144
|
+
head = XPath.first(mjml_root, "mj-head")
|
|
145
|
+
unless head
|
|
146
|
+
head = Element.new("mj-head")
|
|
147
|
+
# Insert mj-head before mj-body if possible
|
|
148
|
+
body = XPath.first(mjml_root, "mj-body")
|
|
149
|
+
if body
|
|
150
|
+
mjml_root.insert_before(body, head)
|
|
151
|
+
else
|
|
152
|
+
mjml_root.add(head)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Add each CSS include as an mj-style element
|
|
157
|
+
css_includes.each do |css_include|
|
|
158
|
+
style_node = Element.new("mj-style")
|
|
159
|
+
style_node.add_attribute("inline", "inline") if css_include[:inline]
|
|
160
|
+
style_node.add(CData.new(css_include[:content]))
|
|
161
|
+
head.add(style_node)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
90
165
|
def strip_xml_declaration(content)
|
|
91
166
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
92
167
|
end
|
|
@@ -107,14 +182,20 @@ module MjmlRb
|
|
|
107
182
|
end
|
|
108
183
|
end
|
|
109
184
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
185
|
+
def wrap_ending_tags_in_cdata(content)
|
|
186
|
+
tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
|
|
187
|
+
# Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
|
|
188
|
+
content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
|
|
189
|
+
tag = Regexp.last_match(1)
|
|
190
|
+
attrs = Regexp.last_match(2).to_s
|
|
191
|
+
inner = Regexp.last_match(3).to_s
|
|
114
192
|
if inner.include?("<![CDATA[")
|
|
115
|
-
"
|
|
193
|
+
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
116
194
|
else
|
|
117
|
-
|
|
195
|
+
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
196
|
+
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
197
|
+
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
198
|
+
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
118
199
|
end
|
|
119
200
|
end
|
|
120
201
|
end
|
|
@@ -164,6 +245,19 @@ module MjmlRb
|
|
|
164
245
|
def element_to_ast(element, keep_comments:)
|
|
165
246
|
raise ParseError, "Missing XML root element" unless element
|
|
166
247
|
|
|
248
|
+
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
249
|
+
# the raw HTML directly as content instead of parsing structurally.
|
|
250
|
+
if ENDING_TAGS_FOR_CDATA.include?(element.name)
|
|
251
|
+
raw_content = element.children.select { |c| c.is_a?(Text) }.map(&:value).join
|
|
252
|
+
return AstNode.new(
|
|
253
|
+
tag_name: element.name,
|
|
254
|
+
attributes: element.attributes.each_with_object({}) { |(name, val), h| h[name] = val },
|
|
255
|
+
children: [],
|
|
256
|
+
content: raw_content.empty? ? nil : raw_content,
|
|
257
|
+
line: nil
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
167
261
|
children = element.children.each_with_object([]) do |child, memo|
|
|
168
262
|
case child
|
|
169
263
|
when Element
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -296,7 +296,9 @@ module MjmlRb
|
|
|
296
296
|
return html if css_blocks.empty?
|
|
297
297
|
|
|
298
298
|
document = parse_html_document(html)
|
|
299
|
-
parse_inline_css_rules(css_blocks.join("\n"))
|
|
299
|
+
rules, at_rules_css = parse_inline_css_rules(css_blocks.join("\n"))
|
|
300
|
+
|
|
301
|
+
rules.each do |selector, declarations|
|
|
300
302
|
next if selector.empty? || declarations.empty?
|
|
301
303
|
|
|
302
304
|
select_nodes(document, selector).each do |node|
|
|
@@ -304,6 +306,11 @@ module MjmlRb
|
|
|
304
306
|
end
|
|
305
307
|
end
|
|
306
308
|
|
|
309
|
+
# Inject preserved @-rules (@media, @font-face, etc.) as a <style> block.
|
|
310
|
+
# These rules cannot be inlined into style attributes but should be kept
|
|
311
|
+
# in the document for runtime application by email clients.
|
|
312
|
+
inject_preserved_at_rules(document, at_rules_css)
|
|
313
|
+
|
|
307
314
|
document.to_html
|
|
308
315
|
end
|
|
309
316
|
|
|
@@ -315,6 +322,18 @@ module MjmlRb
|
|
|
315
322
|
end
|
|
316
323
|
end
|
|
317
324
|
|
|
325
|
+
def inject_preserved_at_rules(document, at_rules_css)
|
|
326
|
+
return if at_rules_css.nil? || at_rules_css.strip.empty?
|
|
327
|
+
|
|
328
|
+
head = document.at_css("head")
|
|
329
|
+
return unless head
|
|
330
|
+
|
|
331
|
+
style = Nokogiri::XML::Node.new("style", document)
|
|
332
|
+
style["type"] = "text/css"
|
|
333
|
+
style.content = at_rules_css.strip
|
|
334
|
+
head.add_child(style)
|
|
335
|
+
end
|
|
336
|
+
|
|
318
337
|
def select_nodes(document, selector)
|
|
319
338
|
document.css(selector)
|
|
320
339
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
@@ -356,21 +375,34 @@ module MjmlRb
|
|
|
356
375
|
|
|
357
376
|
def parse_inline_css_rules(css)
|
|
358
377
|
stripped_css = strip_css_comments(css.to_s)
|
|
359
|
-
plain_css =
|
|
378
|
+
plain_css, at_rules_css = extract_css_at_rules(stripped_css)
|
|
360
379
|
|
|
361
|
-
plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
|
|
380
|
+
rules = plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
|
|
362
381
|
selectors = selector_group.split(",").map(&:strip).reject(&:empty?)
|
|
363
382
|
declaration_map = parse_css_declarations(declarations)
|
|
364
383
|
selectors.map { |selector| [selector, declaration_map] }
|
|
365
384
|
end
|
|
385
|
+
|
|
386
|
+
# Sort rules by specificity (ascending). With the "last wins" merge
|
|
387
|
+
# strategy, higher-specificity rules applied later correctly override
|
|
388
|
+
# lower-specificity ones — matching CSS cascade behavior.
|
|
389
|
+
sorted = rules.each_with_index
|
|
390
|
+
.sort_by { |(selector, _), idx| [css_specificity(selector), idx] }
|
|
391
|
+
.map(&:first)
|
|
392
|
+
|
|
393
|
+
[sorted, at_rules_css]
|
|
366
394
|
end
|
|
367
395
|
|
|
368
396
|
def strip_css_comments(css)
|
|
369
397
|
css.gsub(%r{/\*.*?\*/}m, "")
|
|
370
398
|
end
|
|
371
399
|
|
|
372
|
-
|
|
373
|
-
|
|
400
|
+
# Separates @-rules (@media, @font-face, etc.) from plain CSS selectors.
|
|
401
|
+
# Returns [plain_css, at_rules_css]. The at_rules_css can be injected as a
|
|
402
|
+
# <style> block since @-rules cannot be inlined into style attributes.
|
|
403
|
+
def extract_css_at_rules(css)
|
|
404
|
+
plain = +""
|
|
405
|
+
at_rules = +""
|
|
374
406
|
index = 0
|
|
375
407
|
|
|
376
408
|
while index < css.length
|
|
@@ -378,11 +410,14 @@ module MjmlRb
|
|
|
378
410
|
brace_index = css.index("{", index)
|
|
379
411
|
semicolon_index = css.index(";", index)
|
|
380
412
|
|
|
413
|
+
# Simple @-rules like @import or @charset end with semicolon
|
|
381
414
|
if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
|
|
415
|
+
at_rules << css[index..semicolon_index] << "\n"
|
|
382
416
|
index = semicolon_index + 1
|
|
383
417
|
next
|
|
384
418
|
end
|
|
385
419
|
|
|
420
|
+
# Block @-rules like @media, @font-face have nested braces
|
|
386
421
|
if brace_index
|
|
387
422
|
depth = 1
|
|
388
423
|
cursor = brace_index + 1
|
|
@@ -391,16 +426,49 @@ module MjmlRb
|
|
|
391
426
|
depth -= 1 if css[cursor] == "}"
|
|
392
427
|
cursor += 1
|
|
393
428
|
end
|
|
429
|
+
at_rules << css[index...cursor] << "\n"
|
|
394
430
|
index = cursor
|
|
395
431
|
next
|
|
396
432
|
end
|
|
397
433
|
end
|
|
398
434
|
|
|
399
|
-
|
|
435
|
+
plain << css[index]
|
|
400
436
|
index += 1
|
|
401
437
|
end
|
|
402
438
|
|
|
403
|
-
|
|
439
|
+
[plain, at_rules]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Calculates CSS specificity as a comparable [a, b, c] tuple:
|
|
443
|
+
# a = number of ID selectors (#id)
|
|
444
|
+
# b = number of class selectors (.class), attribute selectors ([attr]),
|
|
445
|
+
# and pseudo-classes (:hover, :lang())
|
|
446
|
+
# c = number of type selectors (div, p) and pseudo-elements (::before)
|
|
447
|
+
def css_specificity(selector)
|
|
448
|
+
s = selector.to_s
|
|
449
|
+
|
|
450
|
+
# a: ID selectors
|
|
451
|
+
a = s.scan(/#[\w-]+/).length
|
|
452
|
+
|
|
453
|
+
# b: class selectors + attribute selectors + pseudo-classes
|
|
454
|
+
b = s.scan(/\.[\w-]+/).length +
|
|
455
|
+
s.scan(/\[[^\]]*\]/).length +
|
|
456
|
+
s.scan(/:(?!:)[\w-]+/).length
|
|
457
|
+
|
|
458
|
+
# c: type selectors + pseudo-elements
|
|
459
|
+
# Strip everything except element names and combinators
|
|
460
|
+
cleaned = s
|
|
461
|
+
.gsub(/#[\w-]+/, "") # remove IDs
|
|
462
|
+
.gsub(/\.[\w-]+/, "") # remove classes
|
|
463
|
+
.gsub(/\[[^\]]*\]/, "") # remove attribute selectors
|
|
464
|
+
.gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
|
|
465
|
+
.gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
|
|
466
|
+
.gsub(/[>+~]/, " ") # combinators → spaces
|
|
467
|
+
.gsub(/\*/, "") # universal selector has no specificity
|
|
468
|
+
c = cleaned.split.reject(&:empty?).length +
|
|
469
|
+
s.scan(/::[\w-]+/).length
|
|
470
|
+
|
|
471
|
+
[a, b, c]
|
|
404
472
|
end
|
|
405
473
|
|
|
406
474
|
def parse_css_declarations(declarations)
|
|
@@ -580,6 +648,9 @@ module MjmlRb
|
|
|
580
648
|
end
|
|
581
649
|
|
|
582
650
|
def raw_inner(node)
|
|
651
|
+
# For ending-tag nodes whose content was preserved as raw HTML by the parser
|
|
652
|
+
return node.content if node.element? && node.content
|
|
653
|
+
|
|
583
654
|
if node.respond_to?(:children)
|
|
584
655
|
node.children.map do |child|
|
|
585
656
|
if child.text?
|
data/lib/mjml-rb/version.rb
CHANGED