mjml-rb 0.2.1

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.
@@ -0,0 +1,63 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Spacer < Base
6
+ TAGS = ["mj-spacer"].freeze
7
+
8
+ ALLOWED_ATTRIBUTES = {
9
+ "border" => "string",
10
+ "border-bottom" => "string",
11
+ "border-left" => "string",
12
+ "border-right" => "string",
13
+ "border-top" => "string",
14
+ "container-background-color" => "color",
15
+ "padding-bottom" => "unit(px,%)",
16
+ "padding-left" => "unit(px,%)",
17
+ "padding-right" => "unit(px,%)",
18
+ "padding-top" => "unit(px,%)",
19
+ "padding" => "unit(px,%){1,4}",
20
+ "height" => "unit(px,%)"
21
+ }.freeze
22
+
23
+ DEFAULT_ATTRIBUTES = {
24
+ "height" => "20px"
25
+ }.freeze
26
+
27
+ def tags
28
+ TAGS
29
+ end
30
+
31
+ def render(tag_name:, node:, context:, attrs:, parent:)
32
+ a = self.class.default_attributes.merge(attrs)
33
+ height = a["height"]
34
+ outer_td_attrs = {
35
+ "class" => a["css-class"],
36
+ "style" => style_join(
37
+ "background" => a["container-background-color"],
38
+ "border" => a["border"],
39
+ "border-bottom" => a["border-bottom"],
40
+ "border-left" => a["border-left"],
41
+ "border-right" => a["border-right"],
42
+ "border-top" => a["border-top"],
43
+ "padding" => a["padding"],
44
+ "padding-top" => a["padding-top"],
45
+ "padding-right" => a["padding-right"],
46
+ "padding-bottom" => a["padding-bottom"],
47
+ "padding-left" => a["padding-left"],
48
+ "word-break" => "break-word"
49
+ )
50
+ }
51
+ div_attrs = {
52
+ "style" => style_join(
53
+ "height" => height,
54
+ "line-height" => height,
55
+ "font-size" => "0"
56
+ )
57
+ }
58
+
59
+ %(<tr><td#{html_attrs(outer_td_attrs)}><div#{html_attrs(div_attrs)}>&#8202;</div></td></tr>)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,162 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Table < Base
6
+ TAGS = ["mj-table"].freeze
7
+
8
+ DEFAULTS = {
9
+ "align" => "left",
10
+ "border" => "none",
11
+ "cellpadding" => "0",
12
+ "cellspacing" => "0",
13
+ "color" => "#000000",
14
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
15
+ "font-size" => "13px",
16
+ "line-height" => "22px",
17
+ "padding" => "10px 25px",
18
+ "table-layout" => "auto",
19
+ "width" => "100%"
20
+ }.freeze
21
+
22
+ def tags
23
+ TAGS
24
+ end
25
+
26
+ def render(tag_name:, node:, context:, attrs:, parent:)
27
+ a = DEFAULTS.merge(attrs)
28
+
29
+ outer_td_style = style_join(
30
+ "background" => a["container-background-color"],
31
+ "font-size" => "0px",
32
+ "font-family" => "inherit",
33
+ "padding" => a["padding"],
34
+ "padding-top" => a["padding-top"],
35
+ "padding-right" => a["padding-right"],
36
+ "padding-bottom" => a["padding-bottom"],
37
+ "padding-left" => a["padding-left"],
38
+ "word-break" => "break-word"
39
+ )
40
+ outer_td_attrs = {
41
+ "align" => a["align"],
42
+ "vertical-align" => a["vertical-align"],
43
+ "class" => a["css-class"],
44
+ "style" => outer_td_style
45
+ }
46
+
47
+ cellspacing_has_value = has_cellspacing?(a["cellspacing"])
48
+ table_style = style_join(
49
+ "color" => a["color"],
50
+ "font-family" => a["font-family"],
51
+ "font-size" => a["font-size"],
52
+ "line-height" => a["line-height"],
53
+ "table-layout" => a["table-layout"],
54
+ "width" => a["width"],
55
+ "border" => a["border"],
56
+ "border-collapse" => (cellspacing_has_value ? "separate" : nil)
57
+ )
58
+
59
+ # Attribute order matches official mjml output: cellpadding, cellspacing, role, width, border, style
60
+ table_attrs = {
61
+ "cellpadding" => a["cellpadding"],
62
+ "cellspacing" => a["cellspacing"],
63
+ "role" => a["role"],
64
+ "width" => get_width(a["width"]),
65
+ "border" => "0",
66
+ "style" => table_style
67
+ }
68
+
69
+ content = serialize_table_children(node)
70
+ table_html = %(<table#{html_attrs(table_attrs)}>#{content}</table>)
71
+
72
+ %(<tr><td#{html_attrs(outer_td_attrs)}>#{table_html}</td></tr>)
73
+ end
74
+
75
+ private
76
+
77
+ def get_width(width)
78
+ return width if width == "auto"
79
+
80
+ if width =~ /^(\d+(?:\.\d+)?)\s*%$/
81
+ width # keep as "100%"
82
+ elsif width =~ /^(\d+(?:\.\d+)?)\s*px$/
83
+ ::Regexp.last_match(1) # return just the number e.g. "300"
84
+ else
85
+ width
86
+ end
87
+ end
88
+
89
+ def has_cellspacing?(cellspacing)
90
+ return false if cellspacing.nil? || cellspacing.to_s.strip.empty?
91
+
92
+ num = cellspacing.to_s.gsub(/[^\d.]/, "").to_f
93
+ num > 0
94
+ end
95
+
96
+ def serialize_table_children(node)
97
+ node.children.map { |child| serialize_table_node(child) }.join
98
+ end
99
+
100
+ def serialize_table_node(node)
101
+ return node.content.to_s if node.text?
102
+ return "" if node.comment?
103
+
104
+ attrs = normalize_table_node_attributes(node)
105
+ attr_html = attrs.map { |key, value| %( #{key}="#{escape_attr(value)}") }.join
106
+
107
+ return "<#{node.tag_name}#{attr_html} />" if node.children.empty?
108
+
109
+ inner = node.children.map { |child| serialize_table_node(child) }.join
110
+ "<#{node.tag_name}#{attr_html}>#{inner}</#{node.tag_name}>"
111
+ end
112
+
113
+ def normalize_table_node_attributes(node)
114
+ attrs = node.attributes.dup
115
+ style_map = parse_style_map(attrs["style"])
116
+
117
+ if %w[table td th a].include?(node.tag_name)
118
+ style_map["font-family"] ||= "inherit"
119
+ end
120
+
121
+ if node.tag_name == "table"
122
+ unless attrs.key?("width") || style_map.key?("width")
123
+ attrs["width"] = "100%"
124
+ style_map["width"] = "100%"
125
+ end
126
+ end
127
+
128
+ if %w[td th].include?(node.tag_name)
129
+ attrs["width"] ||= style_to_html_width(style_map["width"])
130
+ attrs["align"] ||= style_map["text-align"] if style_map["text-align"]
131
+ attrs["valign"] ||= style_map["vertical-align"] if style_map["vertical-align"]
132
+ end
133
+
134
+ attrs["style"] = serialize_style_map(style_map) unless style_map.empty?
135
+ attrs.delete("style") if style_map.empty?
136
+ attrs.compact
137
+ end
138
+
139
+ def parse_style_map(style)
140
+ return {} if style.nil? || style.strip.empty?
141
+
142
+ style.split(";").each_with_object({}) do |declaration, memo|
143
+ key, value = declaration.split(":", 2).map { |part| part&.strip }
144
+ next if key.nil? || key.empty? || value.nil? || value.empty?
145
+
146
+ memo[key] = value
147
+ end
148
+ end
149
+
150
+ def serialize_style_map(style_map)
151
+ style_map.map { |key, value| "#{key}: #{value}" }.join("; ")
152
+ end
153
+
154
+ def style_to_html_width(value)
155
+ return if value.nil?
156
+
157
+ match = value.match(/\A(\d+(?:\.\d+)?)px\z/)
158
+ match ? match[1] : value
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,71 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Text < Base
6
+ TAGS = ["mj-text"].freeze
7
+
8
+ DEFAULTS = {
9
+ "align" => "left",
10
+ "color" => "#000000",
11
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
12
+ "font-size" => "13px",
13
+ "line-height" => "1",
14
+ "padding" => "10px 25px"
15
+ }.freeze
16
+
17
+ def tags
18
+ TAGS
19
+ end
20
+
21
+ def render(tag_name:, node:, context:, attrs:, parent:)
22
+ a = DEFAULTS.merge(attrs)
23
+ height = a["height"]
24
+
25
+ outer_td_style = style_join(
26
+ "background" => a["container-background-color"],
27
+ "font-size" => "0px",
28
+ "padding" => a["padding"],
29
+ "padding-top" => a["padding-top"],
30
+ "padding-right" => a["padding-right"],
31
+ "padding-bottom" => a["padding-bottom"],
32
+ "padding-left" => a["padding-left"],
33
+ "word-break" => "break-word"
34
+ )
35
+ outer_td_attrs = {
36
+ "align" => a["align"],
37
+ "vertical-align" => a["vertical-align"],
38
+ "class" => a["css-class"],
39
+ "style" => outer_td_style
40
+ }
41
+
42
+ div_style = style_join(
43
+ "font-family" => a["font-family"],
44
+ "font-size" => a["font-size"],
45
+ "font-style" => a["font-style"],
46
+ "font-weight" => a["font-weight"],
47
+ "letter-spacing" => a["letter-spacing"],
48
+ "line-height" => a["line-height"],
49
+ "text-align" => a["align"],
50
+ "text-decoration" => a["text-decoration"],
51
+ "text-transform" => a["text-transform"],
52
+ "color" => a["color"],
53
+ "height" => height
54
+ )
55
+
56
+ content = html_inner(node)
57
+ inner_div = %(<div style="#{div_style}">#{content}</div>)
58
+
59
+ body = if height
60
+ outlook_open = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td height="#{escape_attr(height)}" style="vertical-align:top;height:#{escape_attr(height)};"><![endif]-->)
61
+ outlook_close = %(<!--[if mso | IE]></td></tr></table><![endif]-->)
62
+ "#{outlook_open}#{inner_div}#{outlook_close}"
63
+ else
64
+ inner_div
65
+ end
66
+
67
+ %(<tr><td#{html_attrs(outer_td_attrs)}>#{body}</td></tr>)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ module MjmlRb
2
+ module Dependencies
3
+ RULES = {
4
+ "mjml" => ["mj-body", "mj-head", "mj-raw"],
5
+ "mj-accordion" => ["mj-accordion-element", "mj-raw"],
6
+ "mj-accordion-element" => ["mj-accordion-title", "mj-accordion-text", "mj-raw"],
7
+ "mj-accordion-title" => [],
8
+ "mj-accordion-text" => [],
9
+ "mj-attributes" => [/.*/],
10
+ "mj-body" => ["mj-raw", "mj-section", "mj-wrapper", "mj-hero"],
11
+ "mj-button" => [],
12
+ "mj-carousel" => ["mj-carousel-image"],
13
+ "mj-carousel-image" => [],
14
+ "mj-column" => [
15
+ "mj-accordion",
16
+ "mj-button",
17
+ "mj-carousel",
18
+ "mj-divider",
19
+ "mj-image",
20
+ "mj-raw",
21
+ "mj-social",
22
+ "mj-spacer",
23
+ "mj-table",
24
+ "mj-text",
25
+ "mj-navbar"
26
+ ],
27
+ "mj-html-attribute" => [],
28
+ "mj-html-attributes" => ["mj-selector"],
29
+ "mj-divider" => [],
30
+ "mj-group" => ["mj-column", "mj-raw"],
31
+ "mj-head" => [
32
+ "mj-attributes",
33
+ "mj-breakpoint",
34
+ "mj-html-attributes",
35
+ "mj-font",
36
+ "mj-preview",
37
+ "mj-style",
38
+ "mj-title",
39
+ "mj-raw"
40
+ ],
41
+ "mj-hero" => [
42
+ "mj-accordion",
43
+ "mj-button",
44
+ "mj-carousel",
45
+ "mj-divider",
46
+ "mj-image",
47
+ "mj-social",
48
+ "mj-spacer",
49
+ "mj-table",
50
+ "mj-text",
51
+ "mj-navbar",
52
+ "mj-raw"
53
+ ],
54
+ "mj-image" => [],
55
+ "mj-navbar" => ["mj-navbar-link", "mj-raw"],
56
+ "mj-raw" => [/^(?!mj-).+/],
57
+ "mj-section" => ["mj-column", "mj-group", "mj-raw"],
58
+ "mj-selector" => ["mj-html-attribute"],
59
+ "mj-social" => ["mj-social-element", "mj-raw"],
60
+ "mj-social-element" => [],
61
+ "mj-spacer" => [],
62
+ "mj-table" => [/^(?!mj-).+/],
63
+ "mj-text" => [/^(?!mj-).+/],
64
+ "mj-wrapper" => ["mj-hero", "mj-raw", "mj-section"]
65
+ }.freeze
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ module MjmlRb
2
+ class Migrator
3
+ TAG_RENAMES = {
4
+ "mj-container" => "mj-body"
5
+ }.freeze
6
+
7
+ def migrate(mjml)
8
+ output = mjml.to_s.dup
9
+
10
+ TAG_RENAMES.each do |from, to|
11
+ output.gsub!("<#{from}", "<#{to}")
12
+ output.gsub!("</#{from}>", "</#{to}>")
13
+ end
14
+
15
+ output
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,169 @@
1
+ require "rexml/document"
2
+ require "rexml/xpath"
3
+
4
+ require_relative "ast_node"
5
+
6
+ module MjmlRb
7
+ class Parser
8
+ include REXML
9
+ HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
10
+
11
+ class ParseError < StandardError
12
+ attr_reader :line
13
+
14
+ def initialize(message, line: nil)
15
+ super(message)
16
+ @line = line
17
+ end
18
+ end
19
+
20
+ def parse(mjml, options = {})
21
+ opts = normalize_options(options)
22
+ xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
23
+ xml = normalize_html_void_tags(xml)
24
+ xml = expand_includes(xml, opts) unless opts[:ignore_includes]
25
+
26
+ doc = Document.new(sanitize_bare_ampersands(xml))
27
+ element_to_ast(doc.root, keep_comments: opts[:keep_comments])
28
+ rescue ParseException => e
29
+ raise ParseError.new("XML parse error: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def normalize_options(options)
35
+ {
36
+ keep_comments: options[:keep_comments],
37
+ preprocessors: Array(options[:preprocessors]),
38
+ ignore_includes: !!options[:ignore_includes],
39
+ file_path: options[:file_path] || ".",
40
+ actual_path: options[:actual_path] || "."
41
+ }
42
+ end
43
+
44
+ def apply_preprocessors(xml, preprocessors)
45
+ preprocessors.reduce(xml) do |current, preprocessor|
46
+ preprocessor.respond_to?(:call) ? preprocessor.call(current).to_s : current
47
+ end
48
+ end
49
+
50
+ def expand_includes(xml, options)
51
+ xml = normalize_html_void_tags(xml)
52
+ doc = Document.new(sanitize_bare_ampersands(xml))
53
+ includes = XPath.match(doc, "//mj-include")
54
+ return xml if includes.empty?
55
+
56
+ includes.reverse_each do |include_node|
57
+ path_attr = include_node.attributes["path"]
58
+ raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
59
+
60
+ include_type = include_node.attributes["type"].to_s
61
+ resolved_path = resolve_include_path(path_attr, options[:actual_path], options[:file_path])
62
+ include_content = File.read(resolved_path)
63
+
64
+ replacement = if include_type == "html"
65
+ %(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
66
+ else
67
+ normalize_html_void_tags(strip_xml_declaration(include_content))
68
+ end
69
+
70
+ fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
71
+ parent = include_node.parent
72
+ insert_before = include_node
73
+ fragment.root.children.each do |child|
74
+ parent.insert_before(insert_before, deep_clone(child))
75
+ end
76
+ parent.delete(include_node)
77
+ end
78
+
79
+ output = +""
80
+ doc.write(output)
81
+ output
82
+ rescue Errno::ENOENT => e
83
+ raise ParseError, "Cannot read included file: #{e.message}"
84
+ rescue ParseException => e
85
+ raise ParseError, "Failed to parse included content: #{e.message}"
86
+ end
87
+
88
+ def strip_xml_declaration(content)
89
+ content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
90
+ end
91
+
92
+ def normalize_html_void_tags(content)
93
+ # Remove closing tags for void elements (e.g. </br>, </hr>).
94
+ # These are invalid in both HTML and XML but appear in legacy content.
95
+ content = content.gsub(/<\/(#{HTML_VOID_TAGS.join("|")})\s*>/i, "")
96
+
97
+ # Self-close opening void tags that aren't already self-closed.
98
+ pattern = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i
99
+ content.gsub(pattern) do |tag|
100
+ tag.end_with?("/>") ? tag : tag.sub(/>$/, " />")
101
+ end
102
+ end
103
+
104
+ def escape_cdata(content)
105
+ content.to_s.gsub("]]>", "]]]]><![CDATA[>")
106
+ end
107
+
108
+ # Escape bare "&" that are not part of a valid XML entity reference
109
+ # (e.g. &amp; &#123; &#x1F;). This lets REXML parse HTML-ish content
110
+ # such as "Terms & Conditions" which is common in email templates.
111
+ def sanitize_bare_ampersands(content)
112
+ content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&amp;")
113
+ end
114
+
115
+ def resolve_include_path(include_path, actual_path, file_path)
116
+ include_path = include_path.to_s
117
+ return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
118
+
119
+ candidates = []
120
+ candidates << File.expand_path(include_path, File.dirname(actual_path.to_s))
121
+ candidates << File.expand_path(include_path, file_path.to_s)
122
+ candidates << File.expand_path(include_path, Dir.pwd)
123
+
124
+ existing = candidates.find { |candidate| File.file?(candidate) }
125
+ return existing if existing
126
+
127
+ raise Errno::ENOENT, include_path
128
+ end
129
+
130
+ def deep_clone(node)
131
+ case node
132
+ when Element
133
+ clone = Element.new(node.name)
134
+ node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
135
+ node.children.each { |child| clone.add(deep_clone(child)) }
136
+ clone
137
+ when Text
138
+ Text.new(node.value)
139
+ when Comment
140
+ Comment.new(node.string)
141
+ else
142
+ node
143
+ end
144
+ end
145
+
146
+ def element_to_ast(element, keep_comments:)
147
+ raise ParseError, "Missing XML root element" unless element
148
+
149
+ children = element.children.each_with_object([]) do |child, memo|
150
+ case child
151
+ when Element
152
+ memo << element_to_ast(child, keep_comments: keep_comments)
153
+ when Text
154
+ text = child.value
155
+ memo << AstNode.new(tag_name: "#text", content: text) unless text.strip.empty?
156
+ when Comment
157
+ memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
158
+ end
159
+ end
160
+
161
+ AstNode.new(
162
+ tag_name: element.name,
163
+ attributes: element.attributes.each_with_object({}) { |(name, val), h| h[name] = val },
164
+ children: children,
165
+ line: nil
166
+ )
167
+ end
168
+ end
169
+ end