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,331 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Section < Base
6
+ TAGS = %w[mj-section mj-wrapper].freeze
7
+
8
+ SECTION_ALLOWED_ATTRIBUTES = {
9
+ "background-color" => "color",
10
+ "border" => "string",
11
+ "border-bottom" => "string",
12
+ "border-left" => "string",
13
+ "border-radius" => "unit(px,%){1,4}",
14
+ "border-right" => "string",
15
+ "border-top" => "string",
16
+ "direction" => "enum(ltr,rtl)",
17
+ "padding" => "unit(px,%){1,4}",
18
+ "padding-bottom" => "unit(px,%)",
19
+ "padding-left" => "unit(px,%)",
20
+ "padding-right" => "unit(px,%)",
21
+ "padding-top" => "unit(px,%)",
22
+ "text-align" => "enum(left,center,right)"
23
+ }.freeze
24
+
25
+ WRAPPER_ALLOWED_ATTRIBUTES = SECTION_ALLOWED_ATTRIBUTES.merge(
26
+ "full-width" => "enum(full-width)"
27
+ ).freeze
28
+
29
+ DEFAULT_ATTRIBUTES = {
30
+ "direction" => "ltr",
31
+ "padding" => "20px 0",
32
+ "text-align" => "center"
33
+ }.freeze
34
+
35
+ class << self
36
+ def allowed_attributes_for(tag_name)
37
+ tag_name == "mj-wrapper" ? WRAPPER_ALLOWED_ATTRIBUTES : SECTION_ALLOWED_ATTRIBUTES
38
+ end
39
+
40
+ def allowed_attributes
41
+ SECTION_ALLOWED_ATTRIBUTES
42
+ end
43
+ end
44
+
45
+ def tags
46
+ TAGS
47
+ end
48
+
49
+ def render(tag_name:, node:, context:, attrs:, parent:)
50
+ case tag_name
51
+ when "mj-wrapper"
52
+ render_wrapper(node, context, attrs)
53
+ else
54
+ render_section(node, context, attrs)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # ── Shared helpers ──────────────────────────────────────────────────────
61
+
62
+ def parse_px(value)
63
+ value.to_s.to_i
64
+ end
65
+
66
+ def parse_border_width(border_str)
67
+ return 0 if border_str.nil? || border_str.to_s.strip.empty? || border_str.to_s.strip == "none"
68
+
69
+ border_str =~ /(\d+(?:\.\d+)?)\s*px/ ? $1.to_i : 0
70
+ end
71
+
72
+ def parse_padding_value(str)
73
+ return 0 if str.nil? || str.to_s.strip.empty?
74
+
75
+ str =~ /(\d+(?:\.\d+)?)\s*px/ ? $1.to_i : 0
76
+ end
77
+
78
+ def parse_padding_side(attrs, side)
79
+ specific = attrs["padding-#{side}"]
80
+ return parse_padding_value(specific) if specific && !specific.to_s.empty?
81
+
82
+ shorthand = attrs["padding"]
83
+ return 0 unless shorthand
84
+
85
+ parts = shorthand.to_s.strip.split(/\s+/)
86
+ case parts.size
87
+ when 1 then parse_padding_value(parts[0])
88
+ when 2
89
+ side == "left" || side == "right" ? parse_padding_value(parts[1]) : parse_padding_value(parts[0])
90
+ when 3
91
+ case side
92
+ when "top" then parse_padding_value(parts[0])
93
+ when "right", "left" then parse_padding_value(parts[1])
94
+ when "bottom" then parse_padding_value(parts[2])
95
+ else 0
96
+ end
97
+ when 4
98
+ idx = {"top" => 0, "right" => 1, "bottom" => 2, "left" => 3}[side]
99
+ parse_padding_value(parts[idx] || "0")
100
+ else
101
+ 0
102
+ end
103
+ end
104
+
105
+ # Merge adjacent Outlook conditional comments. Applied locally within
106
+ # each section/wrapper to avoid incorrectly merging across sibling sections.
107
+ def merge_outlook_conditionals(html)
108
+ html.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
109
+ end
110
+
111
+ # Build an attribute string for Outlook conditional comment tags.
112
+ # Always renders every pair (including class="") and appends a trailing
113
+ # space before the > to match MJML's htmlAttributes output.
114
+ def outlook_attrs(pairs)
115
+ parts = pairs.map { |(key, value)| %(#{key}="#{escape_attr(value.to_s)}") }
116
+ " #{parts.join(' ')} "
117
+ end
118
+
119
+ # ── mj-section ─────────────────────────────────────────────────────────
120
+
121
+ def render_section(node, context, attrs)
122
+ a = self.class.default_attributes.merge(attrs)
123
+ container_px = parse_px(context[:container_width] || "600px")
124
+ css_class = a["css-class"]
125
+ bg_color = a["background-color"]
126
+ border_radius = a["border-radius"]
127
+
128
+ # Box width: container minus horizontal padding and borders
129
+ border_left = parse_border_width(a["border-left"] || a["border"])
130
+ border_right = parse_border_width(a["border-right"] || a["border"])
131
+ pad_left = parse_padding_side(a, "left")
132
+ pad_right = parse_padding_side(a, "right")
133
+ box_width = container_px - pad_left - pad_right - border_left - border_right
134
+
135
+ # renderBefore — Outlook outer wrapper table
136
+ outlook_class = css_class ? "#{css_class}-outlook" : ""
137
+ before_pairs = [
138
+ ["align", "center"],
139
+ ["border", "0"],
140
+ ["cellpadding", "0"],
141
+ ["cellspacing", "0"],
142
+ ["class", outlook_class],
143
+ ["role", "presentation"],
144
+ ["style", "width:#{container_px}px;"],
145
+ ["width", container_px.to_s]
146
+ ]
147
+ before_pairs << ["bgcolor", bg_color] if bg_color
148
+
149
+ render_before = %(<!--[if mso | IE]><table#{outlook_attrs(before_pairs)}><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->)
150
+
151
+ # Section div, table, td
152
+ div_style = style_join(
153
+ "background" => bg_color,
154
+ "background-color" => bg_color,
155
+ "margin" => "0px auto",
156
+ "max-width" => "#{container_px}px"
157
+ )
158
+
159
+ border_val = a["border"]
160
+ border_val = nil if border_val.nil? || border_val.to_s.strip.empty? || border_val.to_s.strip == "none"
161
+
162
+ td_style = style_join(
163
+ "border" => border_val,
164
+ "border-top" => a["border-top"],
165
+ "border-right" => a["border-right"],
166
+ "border-bottom" => a["border-bottom"],
167
+ "border-left" => a["border-left"],
168
+ "border-radius" => border_radius,
169
+ "background" => bg_color,
170
+ "background-color" => bg_color,
171
+ "direction" => a["direction"],
172
+ "font-size" => "0px",
173
+ "padding" => a["padding"],
174
+ "padding-top" => a["padding-top"],
175
+ "padding-right" => a["padding-right"],
176
+ "padding-bottom" => a["padding-bottom"],
177
+ "padding-left" => a["padding-left"],
178
+ "text-align" => a["text-align"]
179
+ )
180
+
181
+ table_style = style_join(
182
+ "background" => bg_color,
183
+ "background-color" => bg_color,
184
+ "border-radius" => border_radius,
185
+ "width" => "100%"
186
+ )
187
+
188
+ div_attrs = {"class" => css_class, "style" => div_style}
189
+ table_attrs = {
190
+ "align" => "center",
191
+ "border" => "0",
192
+ "cellpadding" => "0",
193
+ "cellspacing" => "0",
194
+ "role" => "presentation",
195
+ "style" => table_style,
196
+ "width" => "100%"
197
+ }
198
+ td_attrs = {
199
+ "align" => a["text-align"],
200
+ "bgcolor" => bg_color,
201
+ "style" => td_style
202
+ }
203
+ inner = merge_outlook_conditionals(render_section_columns(node, context, box_width))
204
+
205
+ section_html =
206
+ %(<div#{html_attrs(div_attrs)}>) +
207
+ %(<table#{html_attrs(table_attrs)}>) +
208
+ %(<tbody><tr><td#{html_attrs(td_attrs)}>#{inner}</td></tr></tbody></table></div>)
209
+
210
+ render_after = %(<!--[if mso | IE]></td></tr></table><![endif]-->)
211
+
212
+ "#{render_before}\n#{section_html}\n#{render_after}"
213
+ end
214
+
215
+ # Generate Outlook IE conditional wrappers around each column/group.
216
+ def render_section_columns(node, context, box_width)
217
+ columns = node.element_children.select { |e| %w[mj-column mj-group].include?(e.tag_name) }
218
+ return render_children(node, context, parent: "mj-section") if columns.empty?
219
+
220
+ widths = renderer.send(:compute_column_widths, columns, context)
221
+
222
+ open_table = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><![endif]-->)
223
+ open_tr = %(<!--[if mso | IE]><tr><![endif]-->)
224
+ close_tr = %(<!--[if mso | IE]></tr><![endif]-->)
225
+ close_table = %(<!--[if mso | IE]></table><![endif]-->)
226
+
227
+ col_parts = columns.each_with_index.map do |col, i|
228
+ col_attrs = resolved_attributes(col, context)
229
+ v_align = col_attrs["vertical-align"] || "top"
230
+ col_px = (box_width.to_f * widths[i] / 100.0).round
231
+
232
+ td_open = %(<!--[if mso | IE]><td class="" style="vertical-align:#{v_align};width:#{col_px}px;" ><![endif]-->)
233
+ td_close = %(<!--[if mso | IE]></td><![endif]-->)
234
+
235
+ col_html = if col.tag_name == "mj-group"
236
+ renderer.send(:render_group, col, context, widths[i])
237
+ else
238
+ context[:_column_width_pct] = widths[i]
239
+ render_node(col, context, parent: "mj-section")
240
+ end
241
+
242
+ "#{td_open}\n#{col_html}\n#{td_close}"
243
+ end
244
+
245
+ ([open_table, open_tr] + col_parts + [close_tr, close_table]).join("\n")
246
+ end
247
+
248
+ # ── mj-wrapper ─────────────────────────────────────────────────────────
249
+
250
+ def render_wrapper(node, context, attrs)
251
+ a = self.class.default_attributes.merge(attrs)
252
+ container_px = parse_px(context[:container_width] || "600px")
253
+ css_class = a["css-class"]
254
+ bg_color = a["background-color"]
255
+ full_width = a["full-width"] == "full-width"
256
+
257
+ # renderBefore — same structure as section
258
+ outlook_class = css_class ? "#{css_class}-outlook" : ""
259
+ before_pairs = [
260
+ ["align", "center"],
261
+ ["border", "0"],
262
+ ["cellpadding", "0"],
263
+ ["cellspacing", "0"],
264
+ ["class", outlook_class],
265
+ ["role", "presentation"],
266
+ ["style", "width:#{container_px}px;"],
267
+ ["width", container_px.to_s]
268
+ ]
269
+ before_pairs << ["bgcolor", bg_color] if bg_color
270
+
271
+ render_before = %(<!--[if mso | IE]><table#{outlook_attrs(before_pairs)}><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->)
272
+
273
+ div_style = style_join(
274
+ "background" => bg_color,
275
+ "background-color" => bg_color,
276
+ "margin" => "0px auto",
277
+ "max-width" => (full_width ? nil : "#{container_px}px")
278
+ )
279
+
280
+ table_style = style_join(
281
+ "background" => bg_color,
282
+ "background-color" => bg_color,
283
+ "width" => "100%"
284
+ )
285
+
286
+ td_style = style_join(
287
+ "direction" => a["direction"],
288
+ "font-size" => "0px",
289
+ "padding" => a["padding"],
290
+ "padding-top" => a["padding-top"],
291
+ "padding-right" => a["padding-right"],
292
+ "padding-bottom" => a["padding-bottom"],
293
+ "padding-left" => a["padding-left"],
294
+ "text-align" => a["text-align"]
295
+ )
296
+
297
+ div_attrs = {"class" => css_class, "style" => div_style}
298
+ inner = merge_outlook_conditionals(render_wrapped_children_wrapper(node, context, container_px))
299
+
300
+ wrapper_html =
301
+ %(<div#{html_attrs(div_attrs)}>) +
302
+ %(<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{table_style}" width="100%">) +
303
+ %(<tbody><tr><td style="#{td_style}">#{inner}</td></tr></tbody></table></div>)
304
+
305
+ render_after = %(<!--[if mso | IE]></td></tr></table><![endif]-->)
306
+
307
+ "#{render_before}\n#{wrapper_html}\n#{render_after}"
308
+ end
309
+
310
+ # Wrap each child mj-section/mj-wrapper in an Outlook conditional <td>.
311
+ def render_wrapped_children_wrapper(node, context, container_px)
312
+ children = node.element_children.select { |e| %w[mj-section mj-wrapper].include?(e.tag_name) }
313
+ return render_children(node, context, parent: "mj-wrapper") if children.empty?
314
+
315
+ open_table = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><![endif]-->)
316
+ open_tr = %(<!--[if mso | IE]><tr><![endif]-->)
317
+ close_tr = %(<!--[if mso | IE]></tr><![endif]-->)
318
+ close_table = %(<!--[if mso | IE]></table><![endif]-->)
319
+
320
+ section_parts = children.map do |child|
321
+ td_open = %(<!--[if mso | IE]><td class="" width="#{container_px}px" ><![endif]-->)
322
+ td_close = %(<!--[if mso | IE]></td><![endif]-->)
323
+ child_html = render_node(child, context, parent: "mj-wrapper")
324
+ "#{td_open}\n#{child_html}\n#{td_close}"
325
+ end
326
+
327
+ ([open_table, open_tr] + section_parts + [close_tr, close_table]).join("\n")
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,303 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Social < Base
6
+ TAGS = %w[mj-social mj-social-element].freeze
7
+
8
+ IMG_BASE_URL = "https://www.mailjet.com/images/theme/v1/icons/ico-social/".freeze
9
+
10
+ SOCIAL_NETWORKS = {
11
+ "facebook" => { "share-url" => "https://www.facebook.com/sharer/sharer.php?u=[[URL]]", "background-color" => "#3b5998", "src" => "#{IMG_BASE_URL}facebook.png" },
12
+ "twitter" => { "share-url" => "https://twitter.com/intent/tweet?url=[[URL]]", "background-color" => "#55acee", "src" => "#{IMG_BASE_URL}twitter.png" },
13
+ "x" => { "share-url" => "https://twitter.com/intent/tweet?url=[[URL]]", "background-color" => "#000000", "src" => "#{IMG_BASE_URL}twitter-x.png" },
14
+ "google" => { "share-url" => "https://plus.google.com/share?url=[[URL]]", "background-color" => "#dc4e41", "src" => "#{IMG_BASE_URL}google-plus.png" },
15
+ "pinterest" => { "share-url" => "https://pinterest.com/pin/create/button/?url=[[URL]]&media=&description=", "background-color" => "#bd081c", "src" => "#{IMG_BASE_URL}pinterest.png" },
16
+ "linkedin" => { "share-url" => "https://www.linkedin.com/shareArticle?mini=true&url=[[URL]]&title=&summary=&source=", "background-color" => "#0077b5", "src" => "#{IMG_BASE_URL}linkedin.png" },
17
+ "instagram" => { "background-color" => "#3f729b", "src" => "#{IMG_BASE_URL}instagram.png" },
18
+ "web" => { "background-color" => "#4BADE9", "src" => "#{IMG_BASE_URL}web.png" },
19
+ "snapchat" => { "background-color" => "#FFFA54", "src" => "#{IMG_BASE_URL}snapchat.png" },
20
+ "youtube" => { "background-color" => "#EB3323", "src" => "#{IMG_BASE_URL}youtube.png" },
21
+ "tumblr" => { "share-url" => "https://www.tumblr.com/widgets/share/tool?canonicalUrl=[[URL]]", "background-color" => "#344356", "src" => "#{IMG_BASE_URL}tumblr.png" },
22
+ "github" => { "background-color" => "#000000", "src" => "#{IMG_BASE_URL}github.png" },
23
+ "xing" => { "share-url" => "https://www.xing.com/app/user?op=share&url=[[URL]]", "background-color" => "#296366", "src" => "#{IMG_BASE_URL}xing.png" },
24
+ "vimeo" => { "background-color" => "#53B4E7", "src" => "#{IMG_BASE_URL}vimeo.png" },
25
+ "medium" => { "background-color" => "#000000", "src" => "#{IMG_BASE_URL}medium.png" },
26
+ "soundcloud" => { "background-color" => "#EF7F31", "src" => "#{IMG_BASE_URL}soundcloud.png" },
27
+ "dribbble" => { "background-color" => "#D95988", "src" => "#{IMG_BASE_URL}dribbble.png" }
28
+ }.tap do |networks|
29
+ # Build -noshare variants (share-url becomes [[URL]] i.e. pass-through)
30
+ networks.keys.each do |key|
31
+ networks["#{key}-noshare"] = networks[key].merge("share-url" => "[[URL]]")
32
+ end
33
+ end.freeze
34
+
35
+ # Attributes Social parent passes down to its mj-social-element children
36
+ INHERITED_ATTRS = %w[
37
+ border-radius color font-family font-size font-weight font-style
38
+ icon-size icon-height icon-padding text-padding line-height text-decoration
39
+ ].freeze
40
+
41
+ SOCIAL_DEFAULTS = {
42
+ "align" => "center",
43
+ "border-radius" => "3px",
44
+ "color" => "#333333",
45
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
46
+ "font-size" => "13px",
47
+ "icon-size" => "20px",
48
+ "line-height" => "22px",
49
+ "mode" => "horizontal",
50
+ "padding" => "10px 25px",
51
+ "text-decoration" => "none"
52
+ }.freeze
53
+
54
+ ELEMENT_DEFAULTS = {
55
+ "alt" => "",
56
+ "align" => "left",
57
+ "icon-position" => "left",
58
+ "color" => "#000",
59
+ "border-radius" => "3px",
60
+ "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
61
+ "font-size" => "13px",
62
+ "line-height" => "1",
63
+ "padding" => "4px",
64
+ "text-padding" => "4px 4px 4px 0",
65
+ "target" => "_blank",
66
+ "text-decoration" => "none",
67
+ "vertical-align" => "middle"
68
+ }.freeze
69
+
70
+ def tags
71
+ TAGS
72
+ end
73
+
74
+ def render(tag_name:, node:, context:, attrs:, parent:)
75
+ case tag_name
76
+ when "mj-social"
77
+ render_social(node, context, attrs)
78
+ when "mj-social-element"
79
+ # Direct dispatch (no parent attrs merging) — fallback for standalone use
80
+ render_social_element(node, ELEMENT_DEFAULTS.merge(attrs))
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # ── mj-social ──────────────────────────────────────────────────────────
87
+
88
+ def render_social(node, context, attrs)
89
+ a = SOCIAL_DEFAULTS.merge(attrs)
90
+
91
+ outer_td_style = style_join(
92
+ "background" => a["container-background-color"],
93
+ "font-size" => "0px",
94
+ "padding" => a["padding"],
95
+ "padding-top" => a["padding-top"],
96
+ "padding-right" => a["padding-right"],
97
+ "padding-bottom" => a["padding-bottom"],
98
+ "padding-left" => a["padding-left"],
99
+ "word-break" => "break-word"
100
+ )
101
+ outer_td_attrs = {
102
+ "align" => a["align"],
103
+ "vertical-align" => a["vertical-align"],
104
+ "class" => a["css-class"],
105
+ "style" => outer_td_style
106
+ }
107
+
108
+ mode = a["mode"] == "vertical" ? "vertical" : "horizontal"
109
+ inner = if mode == "vertical"
110
+ render_vertical(node, context, a)
111
+ else
112
+ render_horizontal(node, context, a)
113
+ end
114
+
115
+ %(<tr><td#{html_attrs(outer_td_attrs)}>#{inner}</td></tr>)
116
+ end
117
+
118
+ def social_element_children(node)
119
+ node.element_children.select { |c| c.tag_name == "mj-social-element" }
120
+ end
121
+
122
+ # Compute the attributes Social passes down to its children
123
+ def inherited_attrs(social_attrs)
124
+ base = {}
125
+ base["padding"] = social_attrs["inner-padding"] if social_attrs["inner-padding"]
126
+
127
+ INHERITED_ATTRS.each_with_object(base) do |attr, h|
128
+ val = social_attrs[attr]
129
+ h[attr] = val unless val.nil?
130
+ end
131
+ end
132
+
133
+ def render_horizontal(node, context, social_attrs)
134
+ align = social_attrs["align"]
135
+ inherited = inherited_attrs(social_attrs)
136
+ elements = social_element_children(node)
137
+ return "" if elements.empty?
138
+
139
+ outlook_open = %(<!--[if mso | IE]><table align="#{escape_attr(align)}" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr>)
140
+ outlook_close = %(</tr></table><![endif]-->)
141
+
142
+ children_html = elements.map.with_index do |child, idx|
143
+ child_attrs = resolved_attributes(child, context)
144
+ merged_attrs = ELEMENT_DEFAULTS.merge(inherited).merge(child_attrs)
145
+ el_html = render_social_element(child, merged_attrs)
146
+
147
+ outlook_td_open = idx == 0 ? "<td>" : "</td><td>"
148
+ %(#{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]>)
149
+ end.join
150
+
151
+ %(#{outlook_open}#{children_html}</td>#{outlook_close})
152
+ end
153
+
154
+ def render_vertical(node, context, social_attrs)
155
+ inherited = inherited_attrs(social_attrs)
156
+ elements = social_element_children(node)
157
+
158
+ children_html = elements.map do |child|
159
+ child_attrs = resolved_attributes(child, context)
160
+ merged_attrs = ELEMENT_DEFAULTS.merge(inherited).merge(child_attrs)
161
+ render_social_element(child, merged_attrs)
162
+ end.join
163
+
164
+ %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0px;"><tbody>#{children_html}</tbody></table>)
165
+ end
166
+
167
+ # ── mj-social-element ──────────────────────────────────────────────────
168
+
169
+ def render_social_element(node, attrs)
170
+ a = attrs # already merged with defaults by caller
171
+ net_name = node.attributes["name"]
172
+ network = SOCIAL_NETWORKS[net_name] || {}
173
+
174
+ # Resolve href: if network has a share-url, substitute [[URL]] with the raw href
175
+ raw_href = a["href"]
176
+ share_url = network["share-url"]
177
+ final_href = if raw_href && share_url
178
+ share_url.gsub("[[URL]]", raw_href)
179
+ else
180
+ raw_href
181
+ end
182
+ has_link = !raw_href.nil?
183
+
184
+ # Resolve icon attrs (element overrides network defaults)
185
+ icon_size = a["icon-size"] || network["icon-size"]
186
+ icon_height = a["icon-height"] || network["icon-height"]
187
+ bg_color = a["background-color"] || network["background-color"]
188
+ src = a["src"] || network["src"]
189
+ srcset = a["srcset"] || network["srcset"]
190
+ sizes_attr = a["sizes"] || network["sizes"]
191
+ icon_width = icon_size ? icon_size.to_i.to_s : nil
192
+
193
+ border_radius = a["border-radius"]
194
+ icon_position = a["icon-position"] || "left"
195
+ padding = a["padding"]
196
+ text_padding = a["text-padding"]
197
+ vertical_align = a["vertical-align"]
198
+ align_val = a["align"]
199
+ alt_val = a["alt"] || ""
200
+ target_val = a["target"]
201
+ rel_val = a["rel"]
202
+ title_val = a["title"]
203
+ icon_padding = a["icon-padding"]
204
+ icon_h = icon_height || icon_size
205
+
206
+ td_style = style_join(
207
+ "padding" => padding,
208
+ "padding-top" => a["padding-top"],
209
+ "padding-right" => a["padding-right"],
210
+ "padding-bottom" => a["padding-bottom"],
211
+ "padding-left" => a["padding-left"],
212
+ "vertical-align" => vertical_align
213
+ )
214
+
215
+ inner_table_style = style_join(
216
+ "background" => bg_color,
217
+ "border-radius" => border_radius,
218
+ "width" => icon_size
219
+ )
220
+
221
+ icon_td_style = style_join(
222
+ "padding" => icon_padding,
223
+ "font-size" => "0",
224
+ "height" => icon_h,
225
+ "vertical-align" => "middle",
226
+ "width" => icon_size
227
+ )
228
+
229
+ img_style = style_join(
230
+ "border-radius" => border_radius,
231
+ "display" => "block"
232
+ )
233
+
234
+ # alt must always be rendered (even as alt=""), so build img tag manually
235
+ img_tag = build_img_tag(
236
+ alt: alt_val, title: title_val, src: src,
237
+ style: img_style, width: icon_width, sizes: sizes_attr, srcset: srcset
238
+ )
239
+
240
+ if has_link
241
+ link_attrs_str = html_attrs({ "href" => final_href, "rel" => rel_val, "target" => target_val })
242
+ icon_content = %(<a#{link_attrs_str}>#{img_tag}</a>)
243
+ else
244
+ icon_content = img_tag
245
+ end
246
+
247
+ icon_td = %(<td style="#{icon_td_style}">#{icon_content}</td>)
248
+
249
+ icon_cell = <<~HTML.strip
250
+ <td style="#{td_style}"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{inner_table_style}"><tbody><tr>#{icon_td}</tr></tbody></table></td>
251
+ HTML
252
+
253
+ # Content cell (text)
254
+ content = node.text_content.strip
255
+ content_cell = if content.empty?
256
+ ""
257
+ else
258
+ td_text_style = style_join(
259
+ "vertical-align" => "middle",
260
+ "padding" => text_padding,
261
+ "text-align" => align_val
262
+ )
263
+ text_style = style_join(
264
+ "color" => a["color"],
265
+ "font-size" => a["font-size"],
266
+ "font-weight" => a["font-weight"],
267
+ "font-style" => a["font-style"],
268
+ "font-family" => a["font-family"],
269
+ "line-height" => a["line-height"],
270
+ "text-decoration" => a["text-decoration"]
271
+ )
272
+
273
+ inner_text = " #{escape_html(content)} "
274
+ text_elem = if has_link
275
+ link_attrs_str = html_attrs({ "href" => final_href, "style" => text_style, "rel" => rel_val, "target" => target_val })
276
+ %(<a#{link_attrs_str}>#{inner_text}</a>)
277
+ else
278
+ %(<span style="#{text_style}">#{inner_text}</span>)
279
+ end
280
+ %(<td style="#{td_text_style}">#{text_elem}</td>)
281
+ end
282
+
283
+ cells = icon_position == "right" ? "#{content_cell} #{icon_cell}" : "#{icon_cell} #{content_cell}"
284
+ css_class = a["css-class"]
285
+ tr_class = css_class ? %( class="#{escape_attr(css_class)}") : ""
286
+
287
+ %(<tr#{tr_class}>#{cells}</tr>)
288
+ end
289
+
290
+ # Build an <img> tag where alt is always rendered (even as alt="")
291
+ def build_img_tag(alt:, title:, src:, style:, width:, sizes:, srcset:)
292
+ parts = [%( alt="#{escape_attr(alt.to_s)}")]
293
+ parts << %( title="#{escape_attr(title)}") unless title.nil? || title.to_s.empty?
294
+ parts << %( src="#{escape_attr(src)}") unless src.nil? || src.to_s.empty?
295
+ parts << %( style="#{style}") unless style.nil? || style.empty?
296
+ parts << %( width="#{escape_attr(width)}") unless width.nil? || width.to_s.empty?
297
+ parts << %( sizes="#{escape_attr(sizes)}") unless sizes.nil? || sizes.to_s.empty?
298
+ parts << %( srcset="#{escape_attr(srcset)}") unless srcset.nil? || srcset.to_s.empty?
299
+ "<img#{parts.join} />"
300
+ end
301
+ end
302
+ end
303
+ end