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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a143101aa0a18f9aa1e77160d0810561f9b5e58fe6dbf8c68d9f809b91421d8d
4
- data.tar.gz: b3fb93b17518384e6ce03e96162a30ef870ab4534bfc3c0fe62775b5db21c594
3
+ metadata.gz: 750fc042cf9ff6273cebd5907a096e6fc304d28a49b0872cf6eec93f9818e148
4
+ data.tar.gz: 138a44a38b430267f646cd771b4a9fa01e3c0e9609083291de5270280db36650
5
5
  SHA512:
6
- metadata.gz: a811a85d468e8332b29c533efe918038c68fccb9f7383d82bfceff42954cbe7c9faeccbe72dde57380b422881b620a358db2d6032bef62928053a6b25c001d71
7
- data.tar.gz: c04f1e1df5835cbf5d0c2f81835ba942aa6f9d3149e592662cffeec22ece7c2841d89f44d4834e5ba29ef8b2891070c402a9da277b04363a7f59541740f2c785
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
- raw_inner(child)
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 << raw_inner(child)
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 ? raw_inner(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 ? raw_inner(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. & -> &amp;).
@@ -143,7 +143,7 @@ module MjmlRb
143
143
  link_attrs["title"] = a["title"]
144
144
  end
145
145
 
146
- content = raw_inner(node)
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
 
@@ -205,7 +205,7 @@ module MjmlRb
205
205
  "style" => anchor_style
206
206
  }
207
207
 
208
- content = raw_inner(node)
208
+ content = raw_inner_for_body(node)
209
209
  link = %(<a#{html_attrs(link_attrs)}>#{content}</a>)
210
210
  return link unless parent == "mj-navbar"
211
211
 
@@ -10,7 +10,7 @@ module MjmlRb
10
10
  }.freeze
11
11
 
12
12
  def render(tag_name:, node:, context:, attrs:, parent:)
13
- raw_inner(node)
13
+ raw_inner_for_body(node)
14
14
  end
15
15
 
16
16
  def handle_head(node, context)
@@ -72,7 +72,7 @@ module MjmlRb
72
72
  "height" => height
73
73
  )
74
74
 
75
- content = raw_inner(node)
75
+ content = raw_inner_for_body(node)
76
76
  inner_div = %(<div style="#{div_style}">#{content}</div>)
77
77
 
78
78
  body = if height
@@ -18,13 +18,22 @@ module MjmlRb
18
18
  ].freeze
19
19
 
20
20
  # Pre-compiled regex patterns to avoid rebuilding on every call
21
- ENDING_TAGS_CDATA_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi.freeze
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
- # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
243
- content.gsub(ENDING_TAGS_CDATA_RE) do
244
- tag = Regexp.last_match(1)
245
- attrs = Regexp.last_match(2).to_s
246
- inner = Regexp.last_match(3).to_s
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
- memo << AstNode.new(tag_name: "#text", content: text) unless text.strip.empty?
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
@@ -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, at_rules_css = parse_inline_css_rules(css_blocks.join("\n"))
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
- merge_inline_style!(node, declarations)
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
- # Inject preserved @-rules (@media, @font-face, etc.) as a <style> block.
356
- # These rules cannot be inlined into style attributes but should be kept
357
- # in the document for runtime application by email clients.
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 merge_inline_style!(node, declarations)
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 normalize_background_fallbacks!(node, declarations)
548
- background_image = declaration_value(declarations["background-image"])
549
- if background_image && !background_image.empty?
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
- value = declaration[:value]
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(" ", "&#32;").gsub("\t", "&#9;")
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(" ", "&#32;").gsub("\t", "&#9;")
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
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.4.3".freeze
2
+ VERSION = "0.4.4".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.4.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk