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,210 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Accordion < Base
6
+ TAGS = %w[
7
+ mj-accordion
8
+ mj-accordion-element
9
+ mj-accordion-title
10
+ mj-accordion-text
11
+ ].freeze
12
+
13
+ HEAD_STYLE = <<~CSS.freeze
14
+ noinput.mj-accordion-checkbox { display:block!important; }
15
+ @media yahoo, only screen and (min-width:0) {
16
+ .mj-accordion-element { display:block; }
17
+ input.mj-accordion-checkbox, .mj-accordion-less { display:none!important; }
18
+ input.mj-accordion-checkbox + * .mj-accordion-title { cursor:pointer; touch-action:manipulation; -webkit-user-select:none; -moz-user-select:none; user-select:none; }
19
+ input.mj-accordion-checkbox + * .mj-accordion-content { overflow:hidden; display:none; }
20
+ input.mj-accordion-checkbox + * .mj-accordion-more { display:block!important; }
21
+ input.mj-accordion-checkbox:checked + * .mj-accordion-content { display:block; }
22
+ input.mj-accordion-checkbox:checked + * .mj-accordion-more { display:none!important; }
23
+ input.mj-accordion-checkbox:checked + * .mj-accordion-less { display:block!important; }
24
+ }
25
+ .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-title { cursor:auto; touch-action:auto; -webkit-user-select:auto; -moz-user-select:auto; user-select:auto; }
26
+ .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-content { overflow:hidden; display:block; }
27
+ .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-ico { display:none; }
28
+ CSS
29
+
30
+ DEFAULTS = {
31
+ "border" => "2px solid black",
32
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
33
+ "icon-align" => "middle",
34
+ "icon-wrapped-url" => "https://i.imgur.com/bIXv1bk.png",
35
+ "icon-wrapped-alt" => "+",
36
+ "icon-unwrapped-url" => "https://i.imgur.com/w4uTygT.png",
37
+ "icon-unwrapped-alt" => "-",
38
+ "icon-position" => "right",
39
+ "icon-height" => "32px",
40
+ "icon-width" => "32px",
41
+ "padding" => "10px 25px"
42
+ }.freeze
43
+
44
+ TITLE_DEFAULTS = {
45
+ "font-size" => "13px",
46
+ "padding" => "16px"
47
+ }.freeze
48
+
49
+ TEXT_DEFAULTS = {
50
+ "font-size" => "13px",
51
+ "line-height" => "1",
52
+ "padding" => "16px"
53
+ }.freeze
54
+
55
+ def tags
56
+ TAGS
57
+ end
58
+
59
+ def head_style
60
+ HEAD_STYLE
61
+ end
62
+
63
+ def head_style_tags
64
+ ["mj-accordion"]
65
+ end
66
+
67
+ def render(tag_name:, node:, context:, attrs:, parent:)
68
+ case tag_name
69
+ when "mj-accordion"
70
+ render_accordion(node, context, attrs)
71
+ when "mj-accordion-element"
72
+ render_accordion_element(node, context, DEFAULTS.merge(attrs))
73
+ when "mj-accordion-title"
74
+ render_accordion_title(node, DEFAULTS.merge(attrs))
75
+ when "mj-accordion-text"
76
+ render_accordion_text(node, DEFAULTS.merge(attrs))
77
+ else
78
+ render_children(node, context, parent: parent)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def render_accordion(node, context, attrs)
85
+ accordion_attrs = DEFAULTS.merge(attrs)
86
+ outer_style = style_join(
87
+ "padding" => accordion_attrs["padding"],
88
+ "background-color" => accordion_attrs["container-background-color"]
89
+ )
90
+ table_style = style_join(
91
+ "width" => "100%",
92
+ "border-collapse" => "collapse",
93
+ "border" => accordion_attrs["border"],
94
+ "border-bottom" => "none",
95
+ "font-family" => accordion_attrs["font-family"]
96
+ )
97
+ inner = node.element_children.map do |child|
98
+ case child.tag_name
99
+ when "mj-accordion-element"
100
+ render_accordion_element(child, context, accordion_attrs)
101
+ when "mj-raw"
102
+ raw_inner(child)
103
+ else
104
+ render_node(child, context, parent: "mj-accordion")
105
+ end
106
+ end.join
107
+
108
+ %(<tr><td style="#{outer_style}"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" class="mj-accordion" style="#{table_style}"><tbody>#{inner}</tbody></table></td></tr>)
109
+ end
110
+
111
+ def render_accordion_element(node, context, parent_attrs)
112
+ attrs = parent_attrs.merge(resolved_attributes(node, context))
113
+ td_style = style_join(
114
+ "padding" => "0px",
115
+ "background-color" => attrs["background-color"]
116
+ )
117
+ label_style = style_join(
118
+ "font-size" => "13px",
119
+ "font-family" => attrs["font-family"] || parent_attrs["font-family"]
120
+ )
121
+
122
+ children = node.element_children
123
+ has_title = children.any? { |child| child.tag_name == "mj-accordion-title" }
124
+ has_text = children.any? { |child| child.tag_name == "mj-accordion-text" }
125
+ content = []
126
+ content << render_accordion_title(nil, attrs) unless has_title
127
+
128
+ children.each do |child|
129
+ case child.tag_name
130
+ when "mj-accordion-title"
131
+ child_attrs = attrs.merge(resolved_attributes(child, context))
132
+ content << render_accordion_title(child, child_attrs)
133
+ when "mj-accordion-text"
134
+ child_attrs = attrs.merge(resolved_attributes(child, context))
135
+ content << render_accordion_text(child, child_attrs)
136
+ when "mj-raw"
137
+ content << raw_inner(child)
138
+ end
139
+ end
140
+ content << render_accordion_text(nil, attrs) unless has_text
141
+
142
+ css_class = attrs["css-class"] ? %( class="#{escape_attr(attrs["css-class"])}") : ""
143
+ %(<tr#{css_class}><td style="#{td_style}"><label class="mj-accordion-element" style="#{label_style}"><input class="mj-accordion-checkbox" type="checkbox" style="display:none;" /><div>#{content.join("\n")}</div></label></td></tr>)
144
+ end
145
+
146
+ def render_accordion_title(node, attrs)
147
+ title_attrs = TITLE_DEFAULTS.merge(attrs)
148
+ td_style = style_join(
149
+ "width" => "100%",
150
+ "background-color" => title_attrs["background-color"],
151
+ "color" => title_attrs["color"],
152
+ "font-size" => title_attrs["font-size"],
153
+ "font-family" => title_attrs["font-family"] || DEFAULTS["font-family"],
154
+ "font-weight" => title_attrs["font-weight"],
155
+ "padding" => title_attrs["padding"],
156
+ "padding-bottom" => title_attrs["padding-bottom"],
157
+ "padding-left" => title_attrs["padding-left"],
158
+ "padding-right" => title_attrs["padding-right"],
159
+ "padding-top" => title_attrs["padding-top"]
160
+ )
161
+ td2_style = style_join(
162
+ "padding" => "16px",
163
+ "background" => title_attrs["background-color"],
164
+ "vertical-align" => title_attrs["icon-align"] || "middle"
165
+ )
166
+ icon_style = style_join(
167
+ "display" => "none",
168
+ "width" => title_attrs["icon-width"] || DEFAULTS["icon-width"],
169
+ "height" => title_attrs["icon-height"] || DEFAULTS["icon-height"]
170
+ )
171
+ table_style = style_join(
172
+ "width" => "100%",
173
+ "border-bottom" => title_attrs["border"] || DEFAULTS["border"]
174
+ )
175
+ title_content = node ? raw_inner(node) : ""
176
+ title_cell = %(<td style="#{td_style}">#{title_content}</td>)
177
+ 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>)
178
+ cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
179
+
180
+ %(<div class="mj-accordion-title"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr>#{cells}</tr></tbody></table></div>)
181
+ end
182
+
183
+ def render_accordion_text(node, attrs)
184
+ text_attrs = TEXT_DEFAULTS.merge(attrs)
185
+ td_style = style_join(
186
+ "background" => text_attrs["background-color"],
187
+ "font-size" => text_attrs["font-size"],
188
+ "font-family" => text_attrs["font-family"] || DEFAULTS["font-family"],
189
+ "font-weight" => text_attrs["font-weight"],
190
+ "letter-spacing" => text_attrs["letter-spacing"],
191
+ "line-height" => text_attrs["line-height"],
192
+ "color" => text_attrs["color"],
193
+ "padding" => text_attrs["padding"],
194
+ "padding-bottom" => text_attrs["padding-bottom"],
195
+ "padding-left" => text_attrs["padding-left"],
196
+ "padding-right" => text_attrs["padding-right"],
197
+ "padding-top" => text_attrs["padding-top"]
198
+ )
199
+ table_style = style_join(
200
+ "width" => "100%",
201
+ "border-bottom" => text_attrs["border"] || DEFAULTS["border"]
202
+ )
203
+ content = node ? raw_inner(node) : ""
204
+ css_class = text_attrs["css-class"] ? %( class="#{escape_attr(text_attrs["css-class"])}") : ""
205
+
206
+ %(<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>)
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,76 @@
1
+ module MjmlRb
2
+ module Components
3
+ class Base
4
+ class << self
5
+ def tags
6
+ const_defined?(:TAGS) ? const_get(:TAGS) : []
7
+ end
8
+
9
+ def allowed_attributes
10
+ const_defined?(:ALLOWED_ATTRIBUTES) ? const_get(:ALLOWED_ATTRIBUTES) : {}
11
+ end
12
+
13
+ def default_attributes
14
+ if const_defined?(:DEFAULT_ATTRIBUTES)
15
+ const_get(:DEFAULT_ATTRIBUTES)
16
+ elsif const_defined?(:DEFAULTS)
17
+ const_get(:DEFAULTS)
18
+ else
19
+ {}
20
+ end
21
+ end
22
+ end
23
+
24
+ def initialize(renderer)
25
+ @renderer = renderer
26
+ end
27
+
28
+ def tags
29
+ self.class.tags
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :renderer
35
+
36
+ def render_node(node, context, parent:)
37
+ renderer.send(:render_node, node, context, parent: parent)
38
+ end
39
+
40
+ def render_children(node, context, parent:)
41
+ renderer.send(:render_children, node, context, parent: parent)
42
+ end
43
+
44
+ def resolved_attributes(node, context)
45
+ renderer.send(:resolved_attributes, node, context)
46
+ end
47
+
48
+ def raw_inner(node)
49
+ renderer.send(:raw_inner, node)
50
+ end
51
+
52
+ # Like raw_inner but HTML-escapes text nodes. Use for components such as
53
+ # mj-text where the inner content is treated as HTML but bare text must
54
+ # be properly encoded (e.g. & -> &amp;).
55
+ def html_inner(node)
56
+ renderer.send(:html_inner, node)
57
+ end
58
+
59
+ def escape_html(value)
60
+ renderer.send(:escape_html, value)
61
+ end
62
+
63
+ def style_join(hash)
64
+ renderer.send(:style_join, hash)
65
+ end
66
+
67
+ def escape_attr(value)
68
+ renderer.send(:escape_attr, value)
69
+ end
70
+
71
+ def html_attrs(hash)
72
+ renderer.send(:html_attrs, hash)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,46 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Body < Base
6
+ TAGS = ["mj-body"].freeze
7
+
8
+ ALLOWED_ATTRIBUTES = {
9
+ "width" => "unit(px)",
10
+ "background-color" => "color"
11
+ }.freeze
12
+
13
+ DEFAULT_ATTRIBUTES = {
14
+ "width" => "600px"
15
+ }.freeze
16
+
17
+ def tags
18
+ TAGS
19
+ end
20
+
21
+ def render(tag_name:, node:, context:, attrs:, parent:)
22
+ return render_children(node, context, parent: parent) unless tag_name == "mj-body"
23
+
24
+ body_attrs = self.class.default_attributes.merge(attrs)
25
+ background_color = body_attrs["background-color"]
26
+ container_width = body_attrs["width"]
27
+
28
+ context[:background_color] = background_color
29
+ context[:container_width] = container_width
30
+
31
+ div_attributes = {
32
+ "aria-label" => context[:title].to_s.empty? ? nil : context[:title],
33
+ "aria-roledescription" => "email",
34
+ "class" => body_attrs["css-class"],
35
+ "role" => "article",
36
+ "lang" => context[:lang],
37
+ "dir" => context[:dir],
38
+ "style" => style_join("background-color" => background_color)
39
+ }
40
+ children = render_children(node, context, parent: "mj-body")
41
+
42
+ %(<div#{html_attrs(div_attributes)}>#{children}</div>)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Breakpoint < Base
6
+ TAGS = ["mj-breakpoint"].freeze
7
+
8
+ ALLOWED_ATTRIBUTES = {
9
+ "width" => "unit(px)"
10
+ }.freeze
11
+
12
+ DEFAULT_ATTRIBUTES = {
13
+ "width" => "480px"
14
+ }.freeze
15
+
16
+ def tags
17
+ TAGS
18
+ end
19
+
20
+ def render(tag_name:, node:, context:, attrs:, parent:)
21
+ ""
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,157 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Button < Base
6
+ TAGS = ["mj-button"].freeze
7
+
8
+ DEFAULTS = {
9
+ "align" => "center",
10
+ "background-color" => "#414141",
11
+ "border" => "none",
12
+ "border-radius" => "3px",
13
+ "color" => "#ffffff",
14
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
15
+ "font-size" => "13px",
16
+ "font-weight" => "normal",
17
+ "inner-padding" => "10px 25px",
18
+ "line-height" => "120%",
19
+ "padding" => "10px 25px",
20
+ "target" => "_blank",
21
+ "text-decoration" => "none",
22
+ "text-transform" => "none",
23
+ "vertical-align" => "middle"
24
+ }.freeze
25
+
26
+ def tags
27
+ TAGS
28
+ end
29
+
30
+ def render(tag_name:, node:, context:, attrs:, parent:)
31
+ a = DEFAULTS.merge(attrs)
32
+
33
+ bg_color = a["background-color"]
34
+ inner_padding = a["inner-padding"]
35
+ vertical_align = a["vertical-align"]
36
+ href = a["href"]
37
+ tag = href ? "a" : "p"
38
+
39
+ outer_td_style = style_join(
40
+ "background" => a["container-background-color"],
41
+ "font-size" => "0px",
42
+ "padding" => a["padding"],
43
+ "padding-top" => a["padding-top"],
44
+ "padding-right" => a["padding-right"],
45
+ "padding-bottom" => a["padding-bottom"],
46
+ "padding-left" => a["padding-left"],
47
+ "word-break" => "break-word"
48
+ )
49
+ outer_td_attrs = {
50
+ "align" => a["align"],
51
+ "vertical-align" => vertical_align,
52
+ "class" => a["css-class"],
53
+ "style" => outer_td_style
54
+ }
55
+
56
+ table_style = style_join(
57
+ "border-collapse" => "separate",
58
+ "width" => a["width"],
59
+ "line-height" => "100%"
60
+ )
61
+
62
+ td_style = style_join(
63
+ "border" => a["border"],
64
+ "border-bottom" => a["border-bottom"],
65
+ "border-left" => a["border-left"],
66
+ "border-radius" => a["border-radius"],
67
+ "border-right" => a["border-right"],
68
+ "border-top" => a["border-top"],
69
+ "cursor" => "auto",
70
+ "font-style" => a["font-style"],
71
+ "height" => a["height"],
72
+ "mso-padding-alt" => inner_padding,
73
+ "text-align" => a["text-align"],
74
+ "background" => bg_color == "none" ? nil : bg_color
75
+ )
76
+ td_attrs = {
77
+ "align" => "center",
78
+ "bgcolor" => bg_color == "none" ? nil : bg_color,
79
+ "role" => "presentation",
80
+ "style" => td_style,
81
+ "valign" => vertical_align
82
+ }
83
+
84
+ link_style = style_join(
85
+ "display" => "inline-block",
86
+ "width" => calculate_a_width(a),
87
+ "background" => bg_color == "none" ? nil : bg_color,
88
+ "color" => a["color"],
89
+ "font-family" => a["font-family"],
90
+ "font-size" => a["font-size"],
91
+ "font-style" => a["font-style"],
92
+ "font-weight" => a["font-weight"],
93
+ "line-height" => a["line-height"],
94
+ "letter-spacing" => a["letter-spacing"],
95
+ "margin" => "0",
96
+ "text-decoration" => a["text-decoration"],
97
+ "text-transform" => a["text-transform"],
98
+ "padding" => inner_padding,
99
+ "mso-padding-alt" => "0px",
100
+ "border-radius" => a["border-radius"]
101
+ )
102
+ link_attrs = { "style" => link_style }
103
+ if tag == "a"
104
+ link_attrs["href"] = escape_attr(href)
105
+ link_attrs["name"] = a["name"]
106
+ link_attrs["rel"] = a["rel"]
107
+ link_attrs["title"] = a["title"]
108
+ link_attrs["target"] = a["target"]
109
+ else
110
+ link_attrs["name"] = a["name"]
111
+ link_attrs["title"] = a["title"]
112
+ end
113
+
114
+ content = raw_inner(node)
115
+ inner_tag = %(<#{tag}#{html_attrs(link_attrs)}>#{content}</#{tag}>)
116
+ 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>)
117
+
118
+ %(<tr><td#{html_attrs(outer_td_attrs)}>#{table}</td></tr>)
119
+ end
120
+
121
+ private
122
+
123
+ def calculate_a_width(attrs)
124
+ width = attrs["width"]
125
+ return nil unless width
126
+ return nil unless width =~ /^(\d+(?:\.\d+)?)\s*px$/
127
+
128
+ parsed_width = ::Regexp.last_match(1).to_f
129
+ inner_padding = attrs["inner-padding"] || "10px 25px"
130
+ parts = inner_padding.split(/\s+/)
131
+ left_pad = shorthand_value(parts, :left).to_f
132
+ right_pad = shorthand_value(parts, :right).to_f
133
+ border_left = parse_border_width(attrs["border-left"] || attrs["border"] || "none")
134
+ border_right = parse_border_width(attrs["border-right"] || attrs["border"] || "none")
135
+
136
+ result = parsed_width - left_pad - right_pad - border_left - border_right
137
+ "#{result.to_i}px"
138
+ end
139
+
140
+ def shorthand_value(parts, side)
141
+ case parts.length
142
+ when 1 then parts[0]
143
+ when 2, 3 then parts[1]
144
+ when 4 then side == :left ? parts[3] : parts[1]
145
+ else "0"
146
+ end
147
+ end
148
+
149
+ def parse_border_width(border_str)
150
+ return 0 if border_str.nil? || border_str.strip == "none"
151
+
152
+ m = border_str.match(/(\d+(?:\.\d+)?)\s*px/)
153
+ m ? m[1].to_f : 0
154
+ end
155
+ end
156
+ end
157
+ end