mjml-rb 0.2.32 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f70adefb32b3a7bb2df503b8749cfbd2fde06bb819c6bfc3e587a1a8eb65772
4
- data.tar.gz: ef1c17cd44ec046c37c75b3a6650610161fe74f71824dfe64b72db2958e24f77
3
+ metadata.gz: 6147ba24e9e7ede9406ebbaa48655e885f72e7f61d14c0da37ecde7ecdc66a68
4
+ data.tar.gz: 6809e1ba8ee7927d565831840a5938dea5bc37abfaff3691d4b60dd4eacd67d0
5
5
  SHA512:
6
- metadata.gz: 64230656cfbf00b6d220cb579e078cddda30edeb5ccf1faa9f4245ed389ba76f6fe1990a10f48a6ccc2e67ecc3deb0c09cb6d4d146079d992e08cf5376f55c60
7
- data.tar.gz: cb12486d8dd7571e11f19bbed0054a7698def27350678874c65c07688baf28a5b2e19f5bb31db4d4ce7c761e3509114c6d2b85155567a842e1812a1d82ba985b
6
+ metadata.gz: 961a93196418591ce8d2f485697ac7cdf7bd4ef2cbf2f53eac02b0811190b324e9d035c442d6d53b48e3faa94db47c44a3b32098354e36f5348ae2b4d1351372
7
+ data.tar.gz: 98a28a390dda4f04d50ab4170c8e277cb7546023bcb5da0aefd242e2524a00d9beb5a91d5c28c18dc308644072911630da43437a5f2b06d96972aa3b13454175
@@ -76,7 +76,7 @@ module MjmlRb
76
76
  "height" => height
77
77
  )
78
78
 
79
- content = html_inner(node)
79
+ content = raw_inner(node)
80
80
  inner_div = %(<div style="#{div_style}">#{content}</div>)
81
81
 
82
82
  body = if height
@@ -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 = wrap_raw_tags_in_cdata(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 = wrap_raw_tags_in_cdata(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
- resolved_path = resolve_include_path(path_attr, options[:actual_path], options[:file_path])
64
- include_content = File.read(resolved_path)
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
- wrap_raw_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
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 wrap_raw_tags_in_cdata(content)
111
- content.gsub(/<mj-raw(\s[^<>]*?)?>(.*?)<\/mj-raw>/mi) do
112
- attrs = Regexp.last_match(1).to_s
113
- inner = Regexp.last_match(2).to_s
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
- "<mj-raw#{attrs}>#{inner}</mj-raw>"
193
+ "<#{tag}#{attrs}>#{inner}</#{tag}>"
116
194
  else
117
- "<mj-raw#{attrs}><![CDATA[#{escape_cdata(inner)}]]></mj-raw>"
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
@@ -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")).each do |selector, declarations|
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 = strip_css_at_rules(stripped_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
- def strip_css_at_rules(css)
373
- result = +""
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
- result << css[index]
435
+ plain << css[index]
400
436
  index += 1
401
437
  end
402
438
 
403
- result
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?
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.32".freeze
2
+ VERSION = "0.2.33".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mjml-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.32
4
+ version: 0.2.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk