mjml-rb 0.5.1 → 0.6.0

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +60 -22
  4. data/THIRD_PARTY_LICENSES.md +25 -0
  5. data/docs/ARCHITECTURE.md +363 -0
  6. data/docs/MIGRATING_TO_V5.md +112 -0
  7. data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
  8. data/docs/PARITY_AUDIT.md +124 -0
  9. data/docs/RELEASING.md +35 -0
  10. data/docs/USAGE.md +1258 -0
  11. data/docs/releases/v0.6.0.md +63 -0
  12. data/lib/mjml-rb/cli.rb +43 -31
  13. data/lib/mjml-rb/column_gutter.rb +81 -0
  14. data/lib/mjml-rb/compiler.rb +32 -122
  15. data/lib/mjml-rb/component_registry.rb +11 -1
  16. data/lib/mjml-rb/components/accordion.rb +11 -9
  17. data/lib/mjml-rb/components/attributes.rb +5 -5
  18. data/lib/mjml-rb/components/base.rb +5 -1
  19. data/lib/mjml-rb/components/body.rb +6 -3
  20. data/lib/mjml-rb/components/breakpoint.rb +1 -1
  21. data/lib/mjml-rb/components/carousel.rb +2 -2
  22. data/lib/mjml-rb/components/carousel_image.rb +2 -2
  23. data/lib/mjml-rb/components/column.rb +16 -11
  24. data/lib/mjml-rb/components/group.rb +13 -4
  25. data/lib/mjml-rb/components/head.rb +3 -3
  26. data/lib/mjml-rb/components/hero.rb +7 -2
  27. data/lib/mjml-rb/components/html_attributes.rb +3 -3
  28. data/lib/mjml-rb/components/image.rb +1 -3
  29. data/lib/mjml-rb/components/raw.rb +1 -1
  30. data/lib/mjml-rb/components/section.rb +23 -8
  31. data/lib/mjml-rb/components/social.rb +19 -28
  32. data/lib/mjml-rb/components/table.rb +2 -2
  33. data/lib/mjml-rb/config_file.rb +25 -15
  34. data/lib/mjml-rb/dependencies.rb +1 -0
  35. data/lib/mjml-rb/formatter.rb +113 -0
  36. data/lib/mjml-rb/node_compat.rb +19 -0
  37. data/lib/mjml-rb/options.rb +81 -0
  38. data/lib/mjml-rb/parser.rb +158 -131
  39. data/lib/mjml-rb/renderer.rb +84 -100
  40. data/lib/mjml-rb/result.rb +3 -2
  41. data/lib/mjml-rb/validator.rb +26 -34
  42. data/lib/mjml-rb/version.rb +2 -1
  43. data/lib/mjml-rb.rb +3 -3
  44. data/mjml-rb.gemspec +2 -2
  45. metadata +20 -2
  46. data/lib/mjml-rb/ast_node.rb +0 -37
@@ -9,7 +9,7 @@ module MjmlRb
9
9
 
10
10
  ALLOWED_ATTRIBUTES = {
11
11
  "align" => "enum(left,center,right)",
12
- "border-radius" => "unit(px,%){1,4}",
12
+ "border-radius" => "string",
13
13
  "container-background-color" => "color",
14
14
  "icon-width" => "unit(px,%)",
15
15
  "left-icon" => "string",
@@ -21,7 +21,7 @@ module MjmlRb
21
21
  "right-icon" => "string",
22
22
  "thumbnails" => "enum(visible,hidden,supported)",
23
23
  "tb-border" => "string",
24
- "tb-border-radius" => "unit(px,%)",
24
+ "tb-border-radius" => "string",
25
25
  "tb-hover-border-color" => "color",
26
26
  "tb-selected-border-color" => "color",
27
27
  "tb-width" => "unit(px,%)"
@@ -13,9 +13,9 @@ module MjmlRb
13
13
  "title" => "string",
14
14
  "src" => "string",
15
15
  "thumbnails-src" => "string",
16
- "border-radius" => "unit(px,%){1,4}",
16
+ "border-radius" => "string",
17
17
  "tb-border" => "string",
18
- "tb-border-radius" => "unit(px,%){1,4}"
18
+ "tb-border-radius" => "string"
19
19
  }.freeze
20
20
 
21
21
  DEFAULT_ATTRIBUTES = {
@@ -10,7 +10,7 @@ module MjmlRb
10
10
  "border" => "string",
11
11
  "border-bottom" => "string",
12
12
  "border-left" => "string",
13
- "border-radius" => "unit(px,%){1,4}",
13
+ "border-radius" => "string",
14
14
  "border-right" => "string",
15
15
  "border-top" => "string",
16
16
  "direction" => "enum(ltr,rtl)",
@@ -18,7 +18,7 @@ module MjmlRb
18
18
  "inner-border" => "string",
19
19
  "inner-border-bottom" => "string",
20
20
  "inner-border-left" => "string",
21
- "inner-border-radius" => "unit(px,%){1,4}",
21
+ "inner-border-radius" => "string",
22
22
  "inner-border-right" => "string",
23
23
  "inner-border-top" => "string",
24
24
  "padding" => "unit(px,%){1,4}",
@@ -40,21 +40,25 @@ module MjmlRb
40
40
  def render(tag_name:, node:, context:, attrs:, parent:)
41
41
  width_pct = context.delete(:_column_width_pct) || 100.0
42
42
  mobile_width = context.delete(:_column_mobile_width)
43
+ column_gutter = context.delete(:_column_gutter)
43
44
  css_class = attrs["css-class"]
44
45
  a = self.class.default_attributes.merge(attrs)
45
46
 
46
- explicit_width = a["width"]
47
- if explicit_width && explicit_width.strip.end_with?("px")
48
- px_val = explicit_width.to_f.to_s.sub(/\.?0+$/, "")
49
- col_class_name = "mj-column-px-#{px_val.gsub('.', '-')}"
50
- context[:column_widths][col_class_name] = "#{px_val}px" if context[:column_widths]
47
+ if column_gutter
48
+ col_class_name = column_gutter.width_class
49
+ responsive_width = column_gutter.width
50
+ context[:column_widths][column_gutter.padding_class] = {padding: column_gutter.desktop_padding} unless column_gutter.grouped?
51
+ elsif a["width"]&.strip&.end_with?("px")
52
+ px_val = a["width"].to_f.round.to_s
53
+ col_class_name = "mj-column-px-#{px_val}"
54
+ responsive_width = "#{px_val}px"
51
55
  else
52
56
  pct_str = width_pct.to_f.to_s.sub(/\.?0+$/, "")
53
57
  col_class_name = "mj-column-per-#{pct_str.gsub('.', '-')}"
54
- context[:column_widths][col_class_name] = "#{pct_str}%" if context[:column_widths]
58
+ responsive_width = "#{pct_str}%"
55
59
  end
56
-
57
- col_class = "#{col_class_name} mj-outlook-group-fix"
60
+ context[:column_widths][col_class_name] = responsive_width if context[:column_widths]
61
+ col_class = [col_class_name, column_gutter&.padding_class, "mj-outlook-group-fix"].compact.join(" ")
58
62
  col_class = "#{col_class} #{css_class}" if css_class && !css_class.empty?
59
63
 
60
64
  vertical_align = a["vertical-align"]
@@ -64,7 +68,8 @@ module MjmlRb
64
68
  "direction" => a["direction"],
65
69
  "display" => "inline-block",
66
70
  "vertical-align" => vertical_align,
67
- "width" => (mobile_width ? mobile_width_value(a, width_pct, context[:container_width]) : "100%")
71
+ "width" => (column_gutter ? column_gutter.mobile_width : mobile_width ? mobile_width_value(a, width_pct, context[:container_width]) : "100%"),
72
+ "padding" => column_gutter&.mobile_padding
68
73
  )
69
74
 
70
75
  column_markup =
@@ -1,4 +1,5 @@
1
1
  require_relative "base"
2
+ require_relative "../column_gutter"
2
3
 
3
4
  module MjmlRb
4
5
  module Components
@@ -21,9 +22,11 @@ module MjmlRb
21
22
  a = DEFAULT_ATTRIBUTES.merge(attrs)
22
23
  css_class = a["css-class"]
23
24
 
24
- pct_str = width_pct.to_f.to_s.sub(/\.?0+$/, "")
25
- col_class_name = "mj-column-per-#{pct_str.gsub('.', '-')}"
26
- context[:column_widths][col_class_name] = "#{pct_str}%" if context[:column_widths]
25
+ unit = a["width"]&.end_with?("px") ? "px" : "%"
26
+ value = unit == "px" ? a["width"].to_f : width_pct
27
+ width_string = value.to_s.sub(/\.?0+$/, "")
28
+ col_class_name = "mj-column-#{unit == '%' ? 'per' : 'px'}-#{width_string.tr('.', '-')}"
29
+ context[:column_widths][col_class_name] = "#{width_string}#{unit}" if context[:column_widths]
27
30
 
28
31
  group_class = "#{col_class_name} mj-outlook-group-fix"
29
32
  group_class = "#{group_class} #{css_class}" if css_class && !css_class.empty?
@@ -71,12 +74,18 @@ module MjmlRb
71
74
  case child.tag_name
72
75
  when "mj-column"
73
76
  width_pct = widths[column_index] || 100.0
77
+ gutter = if context[:gutter]
78
+ ColumnGutter.new(width: resolved_attributes(child, context)["width"] || "#{width_pct}%", container_width: group_width,
79
+ gutter: context[:gutter], count: columns.size, index: column_index, direction: attrs["direction"], grouped: true)
80
+ end
81
+ context[:_column_gutter] = gutter
74
82
  column_index += 1
75
83
  context[:_column_width_pct] = width_pct
76
84
  context[:_column_mobile_width] = true
77
85
  td_style = style_join(
78
86
  "vertical-align" => resolved_attributes(child, context)["vertical-align"] || "top",
79
- "width" => "#{(group_width_px * width_pct / 100.0).round}px"
87
+ "width" => "#{(group_width_px * width_pct / 100.0).round}px",
88
+ "padding" => gutter&.desktop_padding("px")
80
89
  )
81
90
  td_open = %(<!--[if mso | IE]><td#{html_attrs("style" => td_style)}><![endif]-->)
82
91
  td_close = %(<!--[if mso | IE]></td><![endif]-->)
@@ -42,14 +42,14 @@ module MjmlRb
42
42
  context[:preview] = raw_inner(node).strip
43
43
  when "mj-style"
44
44
  css = raw_inner(node)
45
- if node.attributes["inline"] == "inline"
45
+ if node["inline"] == "inline"
46
46
  context[:inline_styles] << css
47
47
  else
48
48
  context[:user_styles] << css
49
49
  end
50
50
  when "mj-font"
51
- name = node.attributes["name"]
52
- href = node.attributes["href"]
51
+ name = node["name"]
52
+ href = node["href"]
53
53
  context[:fonts][name] = href if name && href
54
54
  end
55
55
  end
@@ -13,7 +13,6 @@ module MjmlRb
13
13
  "background-height" => "unit(px,%)",
14
14
  "background-position" => "string",
15
15
  "border-radius" => "string",
16
- "container-background-color" => "color",
17
16
  "inner-background-color" => "color",
18
17
  "inner-padding" => "unit(px,%){1,4}",
19
18
  "inner-padding-top" => "unit(px,%)",
@@ -131,6 +130,7 @@ module MjmlRb
131
130
  end
132
131
 
133
132
  def render_content(attrs, content, container_width)
133
+ container_width = hero_container_width(attrs, container_width)
134
134
  outlook_attrs = {
135
135
  "align" => "center",
136
136
  "border" => "0",
@@ -152,7 +152,12 @@ module MjmlRb
152
152
  "class" => "mj-hero-content",
153
153
  "style" => style_join(
154
154
  "background-color" => attrs["inner-background-color"],
155
- "margin" => "0px auto"
155
+ "margin" => "0px auto",
156
+ "padding" => attrs["inner-padding"],
157
+ "padding-top" => attrs["inner-padding-top"],
158
+ "padding-right" => attrs["inner-padding-right"],
159
+ "padding-bottom" => attrs["inner-padding-bottom"],
160
+ "padding-left" => attrs["inner-padding-left"]
156
161
  )
157
162
  }
158
163
  inner_table_attrs = {
@@ -32,16 +32,16 @@ module MjmlRb
32
32
  node.element_children.each do |selector|
33
33
  next unless selector.tag_name == "mj-selector"
34
34
 
35
- path = selector.attributes["path"].to_s.strip
35
+ path = selector["path"].to_s.strip
36
36
  next if path.empty?
37
37
 
38
38
  custom_attrs = selector.element_children.each_with_object({}) do |child, memo|
39
39
  next unless child.tag_name == "mj-html-attribute"
40
40
 
41
- name = child.attributes["name"].to_s.strip
41
+ name = child["name"].to_s.strip
42
42
  next if name.empty?
43
43
 
44
- memo[name] = child.text_content
44
+ memo[name] = child.content
45
45
  end
46
46
  next if custom_attrs.empty?
47
47
 
@@ -20,7 +20,7 @@ module MjmlRb
20
20
  "border-left" => "string",
21
21
  "border-right" => "string",
22
22
  "border-top" => "string",
23
- "border-radius" => "unit(px,%){1,4}",
23
+ "border-radius" => "string",
24
24
  "container-background-color" => "color",
25
25
  "fluid-on-mobile" => "boolean",
26
26
  "full-width" => "enum(full-width)",
@@ -168,8 +168,6 @@ module MjmlRb
168
168
  end
169
169
 
170
170
  def img_width_attribute(attrs, content_width)
171
- return "auto" if attrs["width"] && attrs["height"] && attrs["height"] != "auto"
172
- return attrs["width"].to_i.to_s if attrs["width"] && attrs["width"] =~ /^(\d+(?:\.\d+)?)\s*px$/
173
171
 
174
172
  content_width.to_i.to_s
175
173
  end
@@ -14,7 +14,7 @@ module MjmlRb
14
14
  end
15
15
 
16
16
  def handle_head(node, context)
17
- context[:head_raw] << raw_inner(node)
17
+ context[:head_raw] << renderer.send(:protect_raw_html, raw_inner(node))
18
18
  end
19
19
  end
20
20
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "base"
2
+ require_relative "../column_gutter"
2
3
 
3
4
  module MjmlRb
4
5
  module Components
@@ -20,6 +21,7 @@ module MjmlRb
20
21
  "border-right" => "string",
21
22
  "border-top" => "string",
22
23
  "direction" => "enum(ltr,rtl)",
24
+ "gutter" => "unit(px,%)",
23
25
  "full-width" => "enum(full-width,false,)",
24
26
  "padding" => "unit(px,%){1,4}",
25
27
  "padding-bottom" => "unit(px,%)",
@@ -30,7 +32,7 @@ module MjmlRb
30
32
  "text-padding" => "unit(px,%){1,4}"
31
33
  }.freeze
32
34
 
33
- WRAPPER_ALLOWED_ATTRIBUTES = SECTION_ALLOWED_ATTRIBUTES.merge(
35
+ WRAPPER_ALLOWED_ATTRIBUTES = SECTION_ALLOWED_ATTRIBUTES.reject { |key, _| key == "gutter" }.merge(
34
36
  "gap" => "unit(px)",
35
37
  ).freeze
36
38
 
@@ -79,7 +81,7 @@ module MjmlRb
79
81
  def parse_padding_value(str)
80
82
  return 0 if str.nil? || str.to_s.strip.empty?
81
83
 
82
- str =~ /(\d+(?:\.\d+)?)\s*px/ ? $1.to_i : 0
84
+ str.to_f
83
85
  end
84
86
 
85
87
  def parse_padding_side(attrs, side)
@@ -122,7 +124,7 @@ module MjmlRb
122
124
  # Merge adjacent Outlook conditional comments. Applied locally within
123
125
  # each section/wrapper to avoid incorrectly merging across sibling sections.
124
126
  def merge_outlook_conditionals(html)
125
- html.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
127
+ renderer.send(:merge_outlook_conditionals, html)
126
128
  end
127
129
 
128
130
  # Build an attribute string for Outlook conditional comment tags.
@@ -439,7 +441,6 @@ module MjmlRb
439
441
  )
440
442
  table_style = style_join(
441
443
  {
442
- "border-radius" => border_radius,
443
444
  "border-collapse" => (has_border_radius ? "separate" : nil),
444
445
  "width" => "100%"
445
446
  }.merge(full_width ? {} : background_styles)
@@ -510,6 +511,9 @@ module MjmlRb
510
511
  # the section's box width so that column children compute correct widths.
511
512
  def render_section_columns(node, context, box_width)
512
513
  previous_container_width = context[:container_width]
514
+ previous_gutter, previous_direction = context.values_at(:gutter, :section_direction)
515
+ attrs = self.class.default_attributes.merge(resolved_attributes(node, context))
516
+ context[:gutter], context[:section_direction] = attrs.values_at("gutter", "direction")
513
517
  context[:container_width] = "#{box_width}px"
514
518
 
515
519
  columns = node.element_children.select { |e| %w[mj-column mj-group].include?(e.tag_name) }
@@ -523,7 +527,9 @@ module MjmlRb
523
527
  close_table = %(<!--[if mso | IE]></table><![endif]-->)
524
528
 
525
529
  col_parts = with_inherited_mj_class(context, node) do
526
- columns.each_with_index.map do |col, i|
530
+ node.children.map do |col|
531
+ i = columns.index(col)
532
+ next render_node(col, context, parent: "mj-section") unless i
527
533
  col_attrs = resolved_attributes(col, context)
528
534
  v_align = col_attrs["vertical-align"] || "top"
529
535
  col_css = col_attrs["css-class"]
@@ -535,10 +541,16 @@ module MjmlRb
535
541
  (box_width.to_f * widths[i] / 100.0).round
536
542
  end
537
543
 
538
- td_open = %(<!--[if mso | IE]><td class="#{escape_attr(outlook_class)}" style="vertical-align:#{v_align};width:#{col_px}px;" ><![endif]-->)
544
+ gutter = if context[:gutter] && col.tag_name == "mj-column"
545
+ ColumnGutter.new(width: col_width_attr || "#{widths[i]}%", container_width: box_width,
546
+ gutter: context[:gutter], count: columns.size, index: i, direction: context[:section_direction])
547
+ end
548
+ padding = gutter ? "padding:#{gutter.desktop_padding('px')};" : ""
549
+ td_open = %(<!--[if mso | IE]><td class="#{escape_attr(outlook_class)}" style="vertical-align:#{v_align};width:#{col_px}px;#{padding}" ><![endif]-->)
539
550
  td_close = %(<!--[if mso | IE]></td><![endif]-->)
540
551
 
541
552
  context[:_column_width_pct] = widths[i]
553
+ context[:_column_gutter] = gutter
542
554
  col_html = render_node(col, context, parent: "mj-section")
543
555
 
544
556
  "#{td_open}\n#{col_html}\n#{td_close}"
@@ -548,6 +560,7 @@ module MjmlRb
548
560
  ([open_table, open_tr] + col_parts + [close_tr, close_table]).join("\n")
549
561
  ensure
550
562
  context[:container_width] = previous_container_width
563
+ context[:gutter], context[:section_direction] = previous_gutter, previous_direction
551
564
  end
552
565
 
553
566
  # ── mj-wrapper ─────────────────────────────────────────────────────────
@@ -692,14 +705,16 @@ module MjmlRb
692
705
  # npm wrapper renders each child in its own row, unlike section which
693
706
  # places all columns in a single row.
694
707
  def render_wrapped_children_wrapper(node, context, container_px, gap)
695
- children = node.element_children.select { |e| %w[mj-section mj-wrapper].include?(e.tag_name) }
708
+ children = node.element_children.reject { |e| e.tag_name == "mj-raw" }
696
709
  return render_children(node, context, parent: "mj-wrapper") if children.empty?
697
710
 
698
711
  open_table = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><![endif]-->)
699
712
  close_table = %(<!--[if mso | IE]></table><![endif]-->)
700
713
 
701
714
  section_parts = with_inherited_mj_class(context, node) do
702
- children.each_with_index.map do |child, index|
715
+ node.children.map do |child|
716
+ index = children.index(child)
717
+ next render_node(child, context, parent: "mj-wrapper") unless index
703
718
  child_attrs = resolved_attributes(child, context)
704
719
  child_css = child_attrs["css-class"]
705
720
  outlook_class = suffix_css_classes(child_css)
@@ -34,7 +34,8 @@ module MjmlRb
34
34
 
35
35
  SOCIAL_ALLOWED_ATTRIBUTES = {
36
36
  "align" => "enum(left,right,center)",
37
- "border-radius" => "unit(px,%)",
37
+ "border" => "string",
38
+ "border-radius" => "string",
38
39
  "container-background-color" => "color",
39
40
  "color" => "color",
40
41
  "font-family" => "string",
@@ -63,7 +64,8 @@ module MjmlRb
63
64
  "icon-position" => "enum(left,right)",
64
65
  "background-color" => "color",
65
66
  "color" => "color",
66
- "border-radius" => "unit(px)",
67
+ "border" => "string",
68
+ "border-radius" => "string",
67
69
  "font-family" => "string",
68
70
  "font-size" => "unit(px)",
69
71
  "font-style" => "string",
@@ -103,7 +105,7 @@ module MjmlRb
103
105
 
104
106
  # Attributes Social parent passes down to its mj-social-element children
105
107
  INHERITED_ATTRS = %w[
106
- border-radius color font-family font-size font-weight font-style
108
+ border border-radius color font-family font-size font-weight font-style
107
109
  icon-size icon-height icon-padding text-padding line-height text-decoration
108
110
  ].freeze
109
111
 
@@ -121,6 +123,7 @@ module MjmlRb
121
123
  }.freeze
122
124
 
123
125
  ELEMENT_DEFAULT_ATTRIBUTES = {
126
+ "border" => "0",
124
127
  "alt" => "",
125
128
  "align" => "left",
126
129
  "icon-position" => "left",
@@ -198,38 +201,25 @@ module MjmlRb
198
201
  def render_horizontal(node, context, social_attrs)
199
202
  align = social_attrs["align"]
200
203
  inherited = inherited_attrs(social_attrs)
201
- elements = social_element_children(node)
202
- return "" if elements.empty?
203
-
204
- outlook_open = %(<!--[if mso | IE]><table align="#{escape_attr(align)}" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr>)
205
- outlook_close = %(</tr></table><![endif]-->)
206
-
207
204
  children_html = with_inherited_mj_class(context, node) do
208
- elements.map.with_index do |child, idx|
209
- child_attrs = resolved_attributes(child, context)
210
- merged_attrs = ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(child_attrs)
211
- el_html = render_social_element(child, merged_attrs)
212
-
213
- outlook_td_open = idx == 0 ? "<td>" : "</td><td>"
214
- %(#{outlook_td_open}<![endif]--><table align="#{escape_attr(align)}" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody>#{el_html}</tbody></table><!--[if mso | IE]>)
205
+ node.children.map do |child|
206
+ next render_node(child, context, parent: "mj-social") unless child.tag_name == "mj-social-element"
207
+ child_attrs = ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(resolved_attributes(child, context))
208
+ el_html = render_social_element(child, child_attrs)
209
+ %(<!--[if mso | IE]><td><![endif]--><table align="#{escape_attr(align)}" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;"><tbody>#{el_html}</tbody></table><!--[if mso | IE]></td><![endif]-->)
215
210
  end.join
216
211
  end
217
-
218
- %(#{outlook_open}#{children_html}</td>#{outlook_close})
212
+ %(<!--[if mso | IE]><table align="#{escape_attr(align)}" border="0" cellpadding="0" cellspacing="0" role="presentation"><tr><![endif]-->#{children_html}<!--[if mso | IE]></tr></table><![endif]-->)
219
213
  end
220
214
 
221
215
  def render_vertical(node, context, social_attrs)
222
216
  inherited = inherited_attrs(social_attrs)
223
- elements = social_element_children(node)
224
-
225
217
  children_html = with_inherited_mj_class(context, node) do
226
- elements.map do |child|
227
- child_attrs = resolved_attributes(child, context)
228
- merged_attrs = ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(child_attrs)
229
- render_social_element(child, merged_attrs)
218
+ node.children.map do |child|
219
+ next render_node(child, context, parent: "mj-social") unless child.tag_name == "mj-social-element"
220
+ render_social_element(child, ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(resolved_attributes(child, context)))
230
221
  end.join
231
222
  end
232
-
233
223
  %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0px;"><tbody>#{children_html}</tbody></table>)
234
224
  end
235
225
 
@@ -237,7 +227,7 @@ module MjmlRb
237
227
 
238
228
  def render_social_element(node, attrs)
239
229
  a = attrs # already merged with defaults by caller
240
- net_name = node.attributes["name"]
230
+ net_name = node["name"]
241
231
  network = SOCIAL_NETWORKS[net_name] || {}
242
232
 
243
233
  # Resolve href: if network has a share-url, substitute [[URL]] with the raw href
@@ -296,6 +286,7 @@ module MjmlRb
296
286
  )
297
287
 
298
288
  img_style = style_join(
289
+ "border" => a["border"],
299
290
  "border-radius" => border_radius,
300
291
  "display" => "block"
301
292
  )
@@ -325,7 +316,7 @@ module MjmlRb
325
316
  HTML
326
317
 
327
318
  # Content cell (text)
328
- content = node.text_content.strip
319
+ content = raw_inner_for_body(node).strip
329
320
  content_cell = if content.empty?
330
321
  ""
331
322
  else
@@ -344,7 +335,7 @@ module MjmlRb
344
335
  "text-decoration" => a["text-decoration"]
345
336
  )
346
337
 
347
- inner_text = " #{escape_html(content)} "
338
+ inner_text = " #{content} "
348
339
  text_elem = if has_link
349
340
  link_attrs_str = html_attrs({ "href" => final_href, "style" => text_style, "rel" => rel_val, "target" => target_val })
350
341
  %(<a#{link_attrs_str}>#{inner_text}</a>)
@@ -118,7 +118,7 @@ module MjmlRb
118
118
 
119
119
  def serialize_table_node(node)
120
120
  return node.content.to_s if node.text?
121
- return "" if node.comment?
121
+ return "<!--#{node.content}-->" if node.comment?
122
122
 
123
123
  attrs = normalize_table_node_attributes(node)
124
124
  attr_html = attrs.map { |key, value| %( #{key}="#{escape_attr(value)}") }.join
@@ -130,7 +130,7 @@ module MjmlRb
130
130
  end
131
131
 
132
132
  def normalize_table_node_attributes(node)
133
- attrs = node.attributes.dup
133
+ attrs = node_string_attributes(node).dup
134
134
  style_map = parse_style_map(attrs["style"])
135
135
 
136
136
  if %w[table td th a].include?(node.tag_name)
@@ -1,8 +1,10 @@
1
1
  require "json"
2
+ require_relative "options"
2
3
 
3
4
  module MjmlRb
4
5
  class ConfigFile
5
- DEFAULT_NAME = ".mjmlrc"
6
+ DEFAULT_NAME = ".mjmlconfig"
7
+ NAMES = [DEFAULT_NAME, ".mjmlrc", ".mjmlconfig.js"].freeze
6
8
 
7
9
  class ConfigError < StandardError
8
10
  attr_reader :path
@@ -13,30 +15,38 @@ module MjmlRb
13
15
  end
14
16
  end
15
17
 
16
- def self.load(dir = Dir.pwd)
17
- path = File.join(dir, DEFAULT_NAME)
18
- return {} unless File.exist?(path)
18
+ def self.load(location = Dir.pwd)
19
+ path = if File.directory?(location)
20
+ NAMES.map { |name| File.join(location, name) }.find { |candidate| File.file?(candidate) }
21
+ else
22
+ location
23
+ end
24
+ return {} unless path
25
+ if path.end_with?(".js")
26
+ raise ConfigError.new("JavaScript MJML configuration is unsupported; use JSON and register Ruby components", path: path)
27
+ end
19
28
 
20
29
  raw = JSON.parse(File.read(path))
30
+ raise ArgumentError, "Configuration root must be an object" unless raw.is_a?(Hash)
21
31
  config = {}
22
-
32
+ if raw.key?("options") && !raw["options"].is_a?(Hash)
33
+ raise ArgumentError, "Configuration options must be an object"
34
+ end
35
+ config[:options] = Options.normalize(raw["options"]) if raw["options"]
23
36
  if raw["packages"].is_a?(Array)
24
- raw["packages"].each do |pkg_path|
25
- resolved = File.expand_path(pkg_path, dir)
26
- require resolved
37
+ raw["packages"].each do |package|
38
+ unless package.is_a?(String) && package.end_with?(".rb")
39
+ raise ArgumentError, "JavaScript component packages are unsupported; use a Ruby .rb component file"
40
+ end
41
+ require File.expand_path(package, File.dirname(path))
27
42
  end
28
43
  config[:packages_loaded] = raw["packages"]
29
44
  end
30
-
31
- if raw["options"].is_a?(Hash)
32
- config[:options] = raw["options"].each_with_object({}) do |(k, v), memo|
33
- memo[k.to_s.tr("-", "_").to_sym] = v
34
- end
35
- end
36
-
37
45
  config
38
46
  rescue JSON::ParserError => e
39
47
  raise ConfigError.new("Failed to parse #{path}: #{e.message}", path: path)
48
+ rescue ArgumentError, SystemCallError, LoadError => e
49
+ raise ConfigError.new("Failed to load #{path}: #{e.message}", path: path)
40
50
  end
41
51
  end
42
52
  end
@@ -14,6 +14,7 @@ module MjmlRb
14
14
  mj-raw
15
15
  mj-table
16
16
  mj-text
17
+ mj-social-element
17
18
  ]).freeze
18
19
 
19
20
  RULES = {