mjml-rb 0.2.32 → 0.2.34

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: bae58d9f87e82dc3c39bd0c9fca4949bb2a953cf8e22a2dd56fa2d5ff057a566
4
+ data.tar.gz: b892f3ed57cf49cf3935a60cd8abef4f232544b0bb2abf2999ef5a557b49849f
5
5
  SHA512:
6
- metadata.gz: 64230656cfbf00b6d220cb579e078cddda30edeb5ccf1faa9f4245ed389ba76f6fe1990a10f48a6ccc2e67ecc3deb0c09cb6d4d146079d992e08cf5376f55c60
7
- data.tar.gz: cb12486d8dd7571e11f19bbed0054a7698def27350678874c65c07688baf28a5b2e19f5bb31db4d4ce7c761e3509114c6d2b85155567a842e1812a1d82ba985b
6
+ metadata.gz: 320fa67da655fcb902557e784e8f1411ab94316b0c1d9e18b2890a6e25fba015ce159fe8ae6e404fbfa60273c9db3a0fdb45320abc9d72eba19a7eff8867706a
7
+ data.tar.gz: e6e3176e0c1cf87791bac2625ea520c339a4059f9ef474b74e16cc2ffd6bd8c1664caa35bd429e2c963d4f39167051fbaf7e391669d44cec93e7788664e5d519
@@ -1,13 +1,14 @@
1
1
  module MjmlRb
2
2
  class AstNode
3
- attr_reader :tag_name, :attributes, :children, :content, :line
3
+ attr_reader :tag_name, :attributes, :children, :content, :line, :file
4
4
 
5
- def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil)
5
+ def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil, file: nil)
6
6
  @tag_name = tag_name.to_s
7
7
  @attributes = attributes.transform_keys(&:to_s)
8
8
  @children = Array(children)
9
9
  @content = content
10
10
  @line = line
11
+ @file = file
11
12
  end
12
13
 
13
14
  def text?
@@ -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,11 +29,12 @@ 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
 
27
- doc = Document.new(sanitize_bare_ampersands(xml))
36
+ xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
37
+ doc = Document.new(xml)
28
38
  element_to_ast(doc.root, keep_comments: opts[:keep_comments])
29
39
  rescue ParseException => e
30
40
  raise ParseError.new("XML parse error: #{e.message}")
@@ -48,45 +58,112 @@ module MjmlRb
48
58
  end
49
59
  end
50
60
 
51
- def expand_includes(xml, options)
52
- xml = wrap_raw_tags_in_cdata(xml)
61
+ def expand_includes(xml, options, included_in = [])
62
+ xml = wrap_ending_tags_in_cdata(xml)
53
63
  xml = normalize_html_void_tags(xml)
54
64
  doc = Document.new(sanitize_bare_ampersands(xml))
55
65
  includes = XPath.match(doc, "//mj-include")
56
66
  return xml if includes.empty?
57
67
 
68
+ css_includes = []
69
+
58
70
  includes.reverse_each do |include_node|
59
71
  path_attr = include_node.attributes["path"]
60
72
  raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
61
73
 
62
74
  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)
75
+ parent = include_node.parent
76
+
77
+ resolved_path = begin
78
+ resolve_include_path(path_attr, options[:actual_path], options[:file_path])
79
+ rescue Errno::ENOENT
80
+ nil
81
+ end
82
+
83
+ include_content = resolved_path ? File.read(resolved_path) : nil
84
+
85
+ if include_content.nil?
86
+ # Collect error as an mj-raw comment node instead of raising
87
+ display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
88
+ error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
89
+ error_node = Element.new("mj-raw")
90
+ error_node.add(CData.new(error_comment))
91
+ parent.insert_before(include_node, error_node)
92
+ parent.delete(include_node)
93
+ next
94
+ end
95
+
96
+ # Circular include detection
97
+ if included_in.include?(resolved_path)
98
+ raise ParseError, "Circular inclusion detected on file : #{resolved_path}"
99
+ end
100
+
101
+ if include_type == "css"
102
+ # CSS includes get collected and added to mj-head later
103
+ css_inline = include_node.attributes["css-inline"].to_s
104
+ css_includes << { content: include_content, inline: css_inline == "inline" }
105
+ parent.delete(include_node)
106
+ next
107
+ end
65
108
 
66
109
  replacement = if include_type == "html"
67
110
  %(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
68
111
  else
69
- wrap_raw_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
112
+ prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
113
+ # Recursively expand includes in the included content
114
+ expand_includes(prepared, options.merge(
115
+ actual_path: resolved_path,
116
+ file_path: File.dirname(resolved_path)
117
+ ), included_in + [resolved_path])
70
118
  end
71
119
 
72
120
  fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
73
- parent = include_node.parent
74
121
  insert_before = include_node
75
122
  fragment.root.children.each do |child|
123
+ annotate_include_source(child, resolved_path) if child.is_a?(Element)
76
124
  parent.insert_before(insert_before, deep_clone(child))
77
125
  end
78
126
  parent.delete(include_node)
79
127
  end
80
128
 
129
+ # Inject CSS includes into mj-head
130
+ unless css_includes.empty?
131
+ inject_css_includes(doc, css_includes)
132
+ end
133
+
81
134
  output = +""
82
135
  doc.write(output)
83
136
  output
84
- rescue Errno::ENOENT => e
85
- raise ParseError, "Cannot read included file: #{e.message}"
86
137
  rescue ParseException => e
87
138
  raise ParseError, "Failed to parse included content: #{e.message}"
88
139
  end
89
140
 
141
+ def inject_css_includes(doc, css_includes)
142
+ mjml_root = doc.root
143
+ return unless mjml_root
144
+
145
+ # Find or create mj-head
146
+ head = XPath.first(mjml_root, "mj-head")
147
+ unless head
148
+ head = Element.new("mj-head")
149
+ # Insert mj-head before mj-body if possible
150
+ body = XPath.first(mjml_root, "mj-body")
151
+ if body
152
+ mjml_root.insert_before(body, head)
153
+ else
154
+ mjml_root.add(head)
155
+ end
156
+ end
157
+
158
+ # Add each CSS include as an mj-style element
159
+ css_includes.each do |css_include|
160
+ style_node = Element.new("mj-style")
161
+ style_node.add_attribute("inline", "inline") if css_include[:inline]
162
+ style_node.add(CData.new(css_include[:content]))
163
+ head.add(style_node)
164
+ end
165
+ end
166
+
90
167
  def strip_xml_declaration(content)
91
168
  content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
92
169
  end
@@ -107,14 +184,20 @@ module MjmlRb
107
184
  end
108
185
  end
109
186
 
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
187
+ def wrap_ending_tags_in_cdata(content)
188
+ tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
189
+ # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
190
+ content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
191
+ tag = Regexp.last_match(1)
192
+ attrs = Regexp.last_match(2).to_s
193
+ inner = Regexp.last_match(3).to_s
114
194
  if inner.include?("<![CDATA[")
115
- "<mj-raw#{attrs}>#{inner}</mj-raw>"
195
+ "<#{tag}#{attrs}>#{inner}</#{tag}>"
116
196
  else
117
- "<mj-raw#{attrs}><![CDATA[#{escape_cdata(inner)}]]></mj-raw>"
197
+ # Pre-process content: normalize void tags and sanitize bare ampersands
198
+ # before wrapping in CDATA, so the raw HTML is well-formed for output.
199
+ prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
200
+ "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
118
201
  end
119
202
  end
120
203
  end
@@ -130,6 +213,36 @@ module MjmlRb
130
213
  content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&amp;")
131
214
  end
132
215
 
216
+ # Adds data-mjml-line attributes to MJML tags so line numbers survive
217
+ # REXML parsing (which doesn't expose source positions).
218
+ # Skips content inside CDATA sections to avoid modifying raw HTML.
219
+ def annotate_line_numbers(xml)
220
+ line = 1
221
+ xml.gsub(/(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m) do
222
+ if Regexp.last_match(1) # newline
223
+ line += 1
224
+ "\n"
225
+ elsif Regexp.last_match(2) # CDATA section — count newlines, pass through
226
+ line += Regexp.last_match(2).count("\n")
227
+ Regexp.last_match(2)
228
+ else # opening MJML tag
229
+ "#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
230
+ end
231
+ end
232
+ end
233
+
234
+ # Recursively marks REXML elements from included files with data-mjml-file.
235
+ # Only sets the attribute on elements that don't already have it (preserving
236
+ # deeper include annotations from recursive expansion).
237
+ def annotate_include_source(element, file_path)
238
+ return unless element.is_a?(Element)
239
+
240
+ if (element.name.start_with?("mj-") || element.name == "mjml") && !element.attributes["data-mjml-file"]
241
+ element.add_attribute("data-mjml-file", file_path)
242
+ end
243
+ element.each_element { |child| annotate_include_source(child, file_path) }
244
+ end
245
+
133
246
  def resolve_include_path(include_path, actual_path, file_path)
134
247
  include_path = include_path.to_s
135
248
  return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
@@ -164,6 +277,28 @@ module MjmlRb
164
277
  def element_to_ast(element, keep_comments:)
165
278
  raise ParseError, "Missing XML root element" unless element
166
279
 
280
+ # Extract metadata annotations (added by annotate_line_numbers / annotate_include_source)
281
+ # and strip them from the public attributes hash.
282
+ meta_line = element.attributes["data-mjml-line"]&.to_i
283
+ meta_file = element.attributes["data-mjml-file"]
284
+ attrs = element.attributes.each_with_object({}) do |(name, val), h|
285
+ h[name] = val unless name.start_with?("data-mjml-")
286
+ end
287
+
288
+ # For ending-tag elements whose content was wrapped in CDATA, store
289
+ # the raw HTML directly as content instead of parsing structurally.
290
+ if ENDING_TAGS_FOR_CDATA.include?(element.name)
291
+ raw_content = element.children.select { |c| c.is_a?(Text) }.map(&:value).join
292
+ return AstNode.new(
293
+ tag_name: element.name,
294
+ attributes: attrs,
295
+ children: [],
296
+ content: raw_content.empty? ? nil : raw_content,
297
+ line: meta_line,
298
+ file: meta_file
299
+ )
300
+ end
301
+
167
302
  children = element.children.each_with_object([]) do |child, memo|
168
303
  case child
169
304
  when Element
@@ -178,9 +313,10 @@ module MjmlRb
178
313
 
179
314
  AstNode.new(
180
315
  tag_name: element.name,
181
- attributes: element.attributes.each_with_object({}) { |(name, val), h| h[name] = val },
316
+ attributes: attrs,
182
317
  children: children,
183
- line: nil
318
+ line: meta_line,
319
+ file: meta_file
184
320
  )
185
321
  end
186
322
  end
@@ -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?
@@ -64,7 +64,7 @@ module MjmlRb
64
64
 
65
65
  errors << error(
66
66
  "Element <#{child.tag_name}> is not allowed inside <#{node.tag_name}>",
67
- tag_name: child.tag_name
67
+ tag_name: child.tag_name, line: child.line, file: child.file
68
68
  )
69
69
  end
70
70
  end
@@ -74,7 +74,8 @@ module MjmlRb
74
74
  required.each do |attr|
75
75
  next if node.attributes.key?(attr)
76
76
 
77
- errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>", tag_name: node.tag_name)
77
+ errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>",
78
+ tag_name: node.tag_name, line: node.line, file: node.file)
78
79
  end
79
80
  end
80
81
 
@@ -86,7 +87,8 @@ module MjmlRb
86
87
  next if allowed_attributes.key?(attribute_name)
87
88
  next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
88
89
 
89
- errors << error("Attribute `#{attribute_name}` is not allowed for <#{node.tag_name}>", tag_name: node.tag_name)
90
+ errors << error("Attribute `#{attribute_name}` is not allowed for <#{node.tag_name}>",
91
+ tag_name: node.tag_name, line: node.line, file: node.file)
90
92
  end
91
93
  end
92
94
 
@@ -103,7 +105,7 @@ module MjmlRb
103
105
 
104
106
  errors << error(
105
107
  "Attribute `#{attribute_name}` on <#{node.tag_name}> has invalid value `#{attribute_value}` for type `#{expected_type}`",
106
- tag_name: node.tag_name
108
+ tag_name: node.tag_name, line: node.line, file: node.file
107
109
  )
108
110
  end
109
111
  end
@@ -183,12 +185,18 @@ module MjmlRb
183
185
  end
184
186
  end
185
187
 
186
- def error(message, line: nil, tag_name: nil)
188
+ def error(message, line: nil, tag_name: nil, file: nil)
189
+ location = [
190
+ ("line #{line}" if line),
191
+ ("in #{file}" if file)
192
+ ].compact.join(", ")
193
+
187
194
  {
188
195
  line: line,
196
+ file: file,
189
197
  message: message,
190
198
  tag_name: tag_name,
191
- formatted_message: message
199
+ formatted_message: location.empty? ? message : "#{message} (#{location})"
192
200
  }
193
201
  end
194
202
  end
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.32".freeze
2
+ VERSION = "0.2.34".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.34
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk