mjml-rb 0.2.11 → 0.2.13

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: e37a1df063fbb80fad16b364acfd5f80623edeeabab31d3abd7264b486a287d7
4
- data.tar.gz: 7added84353440e4750caf6c1c343baa50f23bdac800b5418a8432dd4f48e950
3
+ metadata.gz: 9c54cb0ddf9071b81422853dfcca7e3748ed404b7ea1afba2a99ce9a42229d01
4
+ data.tar.gz: 7d2469230fa62b2dd52df43e3e8858f1a38aeb5964469af5ec8669182664e9f8
5
5
  SHA512:
6
- metadata.gz: ffc75edaa21c23dedaeacd1ef032fa7ac4fa561f3719b9aa6314e8c6ac14a08419d22f2dfe2bcce699b680d47280ad2c7743595115ac01337bfc79176080c2fd
7
- data.tar.gz: 91ff2e86ed6d191d8aec6fb438de9624d99150225f0626d8b88a26d3c8ae0d4c5e29f707498a27e6841a8740e80d4a254115a6387a959cab5d98a3f94eb44d31
6
+ metadata.gz: 8e349fdbec211923111e8ce2f7a593f46f18308aef3461c531dc3fa6ccfdf97d024c8b181e0289c0b8819db272d49fd3c45c2130298373a8867f6f8f19f14916
7
+ data.tar.gz: 9603224d7e49fc263441767a0d3a289bd5bbba4cdf93afebc07b1dd7dd0416f8cd5e0161843d50d31e2f21b6390be8a946569027c52f19ab4d6605442544507b
data/README.md CHANGED
@@ -69,8 +69,8 @@ The table below tracks current JS-to-Ruby migration status for MJML components i
69
69
  | `mj-preview` | migrated | Implemented in `head.rb`. |
70
70
  | `mj-style` | migrated | Implemented in `head.rb`, including inline-style registration. |
71
71
  | `mj-font` | migrated | Implemented in `head.rb`. |
72
- | `mj-carousel` | not migrated | Declared in dependency rules but no renderer implementation yet. |
73
- | `mj-carousel-image` | not migrated | Declared in dependency rules but no renderer implementation yet. |
72
+ | `mj-carousel` | migrated | Implemented in `carousel.rb`, including per-instance radio/thumbnail CSS, Outlook fallback rendering, and thumbnail/control output. |
73
+ | `mj-carousel-image` | migrated | Implemented in `carousel_image.rb`, including radio, thumbnail, and main image rendering helpers used by `mj-carousel`. |
74
74
  | `mj-breakpoint` | migrated | Supported in `mj-head` and used to control desktop column media-query widths. |
75
75
  | `mj-html-attributes` | migrated | Supported in `mj-head` and applied to the rendered HTML via CSS selectors. |
76
76
  | `mj-selector` | migrated | Supported as the selector container for `mj-html-attribute` rules. |
@@ -0,0 +1,449 @@
1
+ require "securerandom"
2
+
3
+ require_relative "base"
4
+
5
+ module MjmlRb
6
+ module Components
7
+ class Carousel < Base
8
+ TAGS = ["mj-carousel"].freeze
9
+
10
+ ALLOWED_ATTRIBUTES = {
11
+ "align" => "enum(left,center,right)",
12
+ "border-radius" => "unit(px,%){1,4}",
13
+ "container-background-color" => "color",
14
+ "icon-width" => "unit(px,%)",
15
+ "left-icon" => "string",
16
+ "padding" => "unit(px,%){1,4}",
17
+ "padding-top" => "unit(px,%)",
18
+ "padding-bottom" => "unit(px,%)",
19
+ "padding-left" => "unit(px,%)",
20
+ "padding-right" => "unit(px,%)",
21
+ "right-icon" => "string",
22
+ "thumbnails" => "enum(visible,hidden,supported)",
23
+ "tb-border" => "string",
24
+ "tb-border-radius" => "unit(px,%)",
25
+ "tb-hover-border-color" => "color",
26
+ "tb-selected-border-color" => "color",
27
+ "tb-width" => "unit(px,%)"
28
+ }.freeze
29
+
30
+ DEFAULT_ATTRIBUTES = {
31
+ "align" => "center",
32
+ "border-radius" => "6px",
33
+ "icon-width" => "44px",
34
+ "left-icon" => "https://i.imgur.com/xTh3hln.png",
35
+ "right-icon" => "https://i.imgur.com/os7o9kz.png",
36
+ "thumbnails" => "visible",
37
+ "tb-border" => "2px solid transparent",
38
+ "tb-border-radius" => "6px",
39
+ "tb-hover-border-color" => "#fead0d",
40
+ "tb-selected-border-color" => "#ccc"
41
+ }.freeze
42
+
43
+ def tags
44
+ TAGS
45
+ end
46
+
47
+ def render(tag_name:, node:, context:, attrs:, parent:)
48
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
49
+ children = carousel_images(node)
50
+ return "" if children.empty?
51
+
52
+ carousel_id = SecureRandom.hex(8)
53
+ context[:head_styles] << component_head_style(carousel_id, children.length, a)
54
+
55
+ outer_td_attrs = {
56
+ "align" => a["align"],
57
+ "class" => a["css-class"],
58
+ "style" => style_join(
59
+ "background" => a["container-background-color"],
60
+ "font-size" => "0px",
61
+ "padding" => a["padding"],
62
+ "padding-top" => a["padding-top"],
63
+ "padding-right" => a["padding-right"],
64
+ "padding-bottom" => a["padding-bottom"],
65
+ "padding-left" => a["padding-left"],
66
+ "word-break" => "break-word"
67
+ )
68
+ }
69
+
70
+ inner_container_width = content_width(context[:container_width], a)
71
+
72
+ content = <<~HTML
73
+ #{mso_conditional_tag(interactive_markup(node, children, context, a, carousel_id, inner_container_width), true)}
74
+ #{render_fallback(node, children.first, context, a, inner_container_width)}
75
+ HTML
76
+
77
+ %(<tr><td#{html_attrs(outer_td_attrs)}>#{content}</td></tr>)
78
+ end
79
+
80
+ private
81
+
82
+ def interactive_markup(node, children, context, attrs, carousel_id, inner_container_width)
83
+ carousel_classes = ["mj-carousel", attrs["css-class"]].compact.reject(&:empty?).join(" ")
84
+
85
+ <<~HTML
86
+ <div#{html_attrs("class" => carousel_classes)}>
87
+ #{generate_radios(children, carousel_id)}
88
+ <div#{html_attrs(
89
+ "class" => "mj-carousel-content mj-carousel-#{carousel_id}-content",
90
+ "style" => style_join(
91
+ "display" => "table",
92
+ "width" => "100%",
93
+ "table-layout" => "fixed",
94
+ "text-align" => "center",
95
+ "font-size" => "0px"
96
+ )
97
+ )}>
98
+ #{generate_thumbnails(node, children, context, attrs, carousel_id, inner_container_width)}
99
+ #{generate_carousel(node, children, context, attrs, carousel_id, inner_container_width)}
100
+ </div>
101
+ </div>
102
+ HTML
103
+ end
104
+
105
+ def generate_radios(children, carousel_id)
106
+ image_component = carousel_image_component
107
+ children.each_with_index.map do |child, index|
108
+ image_component.render_radio(index: index, carousel_id: carousel_id)
109
+ end.join
110
+ end
111
+
112
+ def generate_thumbnails(node, children, context, attrs, carousel_id, inner_container_width)
113
+ thumbnails = attrs["thumbnails"]
114
+ return "" unless %w[visible supported].include?(thumbnails)
115
+
116
+ image_component = carousel_image_component
117
+ tb_width = thumbnail_width(attrs, children.length, inner_container_width)
118
+
119
+ with_inherited_mj_class(context, node) do
120
+ children.each_with_index.map do |child, index|
121
+ child_attrs = child_pass_through_attributes(child, context, attrs, tb_width)
122
+ image_component.render_thumbnail(
123
+ child,
124
+ attrs: child_attrs,
125
+ index: index,
126
+ carousel_id: carousel_id,
127
+ thumbnails: thumbnails,
128
+ tb_width: tb_width
129
+ )
130
+ end.join
131
+ end
132
+ end
133
+
134
+ def generate_carousel(node, children, context, attrs, carousel_id, inner_container_width)
135
+ <<~HTML
136
+ <table#{html_attrs(
137
+ "style" => style_join(
138
+ "caption-side" => "top",
139
+ "display" => "table-caption",
140
+ "table-layout" => "fixed",
141
+ "width" => "100%"
142
+ ),
143
+ "border" => "0",
144
+ "cellpadding" => "0",
145
+ "cellspacing" => "0",
146
+ "width" => "100%",
147
+ "role" => "presentation",
148
+ "class" => "mj-carousel-main"
149
+ )}>
150
+ <tbody>
151
+ <tr>
152
+ #{generate_controls("previous", attrs["left-icon"], carousel_id, children.length, attrs["icon-width"])}
153
+ #{generate_images(node, children, context, attrs, inner_container_width)}
154
+ #{generate_controls("next", attrs["right-icon"], carousel_id, children.length, attrs["icon-width"])}
155
+ </tr>
156
+ </tbody>
157
+ </table>
158
+ HTML
159
+ end
160
+
161
+ def generate_controls(direction, icon, carousel_id, child_count, icon_width)
162
+ parsed_icon_width = parse_pixel_value(icon_width).to_i
163
+
164
+ labels = (1..child_count).map do |index|
165
+ <<~HTML
166
+ <label#{html_attrs(
167
+ "for" => "mj-carousel-#{carousel_id}-radio-#{index}",
168
+ "class" => "mj-carousel-#{direction} mj-carousel-#{direction}-#{index}"
169
+ )}>
170
+ <img#{html_attrs(
171
+ "src" => icon,
172
+ "alt" => direction,
173
+ "style" => style_join(
174
+ "display" => "block",
175
+ "width" => icon_width,
176
+ "height" => "auto"
177
+ ),
178
+ "width" => parsed_icon_width.to_s
179
+ )} />
180
+ </label>
181
+ HTML
182
+ end.join
183
+
184
+ <<~HTML
185
+ <td#{html_attrs(
186
+ "class" => "mj-carousel-#{carousel_id}-icons-cell",
187
+ "style" => style_join(
188
+ "font-size" => "0px",
189
+ "display" => "none",
190
+ "mso-hide" => "all",
191
+ "padding" => "0px"
192
+ )
193
+ )}>
194
+ <div#{html_attrs(
195
+ "class" => "mj-carousel-#{direction}-icons",
196
+ "style" => style_join(
197
+ "display" => "none",
198
+ "mso-hide" => "all"
199
+ )
200
+ )}>
201
+ #{labels}
202
+ </div>
203
+ </td>
204
+ HTML
205
+ end
206
+
207
+ def generate_images(node, children, context, attrs, inner_container_width)
208
+ image_component = carousel_image_component
209
+ images = with_inherited_mj_class(context, node) do
210
+ children.each_with_index.map do |child, index|
211
+ child_attrs = child_pass_through_attributes(child, context, attrs, nil)
212
+ image_component.render_item(
213
+ child,
214
+ attrs: child_attrs,
215
+ index: index,
216
+ container_width: inner_container_width,
217
+ visible: index.zero?
218
+ )
219
+ end.join
220
+ end
221
+
222
+ <<~HTML
223
+ <td#{html_attrs("style" => "padding:0px;")}>
224
+ <div#{html_attrs("class" => "mj-carousel-images")}>
225
+ #{images}
226
+ </div>
227
+ </td>
228
+ HTML
229
+ end
230
+
231
+ def render_fallback(node, first_child, context, attrs, inner_container_width)
232
+ return "" unless first_child
233
+
234
+ child_attrs = with_inherited_mj_class(context, node) do
235
+ child_pass_through_attributes(first_child, context, attrs, nil)
236
+ end
237
+ fallback = carousel_image_component.render_item(
238
+ first_child,
239
+ attrs: child_attrs,
240
+ index: 0,
241
+ container_width: inner_container_width,
242
+ visible: true
243
+ )
244
+ mso_conditional_tag(fallback)
245
+ end
246
+
247
+ def child_pass_through_attributes(child, context, parent_attrs, tb_width)
248
+ child_attrs = resolved_attributes(child, context)
249
+ pass_through = {
250
+ "border-radius" => parent_attrs["border-radius"],
251
+ "tb-border" => parent_attrs["tb-border"],
252
+ "tb-border-radius" => parent_attrs["tb-border-radius"]
253
+ }
254
+ pass_through["tb-width"] = tb_width if tb_width
255
+ pass_through.merge(child_attrs)
256
+ end
257
+
258
+ def carousel_images(node)
259
+ node.element_children.select { |child| child.tag_name == "mj-carousel-image" }
260
+ end
261
+
262
+ def carousel_image_component
263
+ renderer.send(:component_for, "mj-carousel-image")
264
+ end
265
+
266
+ def thumbnail_width(attrs, child_count, inner_container_width)
267
+ return attrs["tb-width"] if attrs["tb-width"] && !attrs["tb-width"].empty?
268
+ return "0px" if child_count.zero?
269
+
270
+ "#{[inner_container_width.to_f / child_count, 110].min}px"
271
+ end
272
+
273
+ def content_width(container_width, attrs)
274
+ total = parse_pixel_value(container_width || "600px")
275
+ total -= padding_side(attrs, "left")
276
+ total -= padding_side(attrs, "right")
277
+ [total, 0].max
278
+ end
279
+
280
+ def padding_side(attrs, side)
281
+ specific = attrs["padding-#{side}"]
282
+ return parse_pixel_value(specific) unless blank?(specific)
283
+
284
+ shorthand_padding_value(attrs["padding"], side)
285
+ end
286
+
287
+ def shorthand_padding_value(value, side)
288
+ return 0 if blank?(value)
289
+
290
+ parts = value.to_s.strip.split(/\s+/)
291
+ case parts.length
292
+ when 1
293
+ parse_pixel_value(parts[0])
294
+ when 2
295
+ %w[left right].include?(side) ? parse_pixel_value(parts[1]) : parse_pixel_value(parts[0])
296
+ when 3
297
+ %w[left right].include?(side) ? parse_pixel_value(parts[1]) : parse_pixel_value(side == "top" ? parts[0] : parts[2])
298
+ when 4
299
+ parse_pixel_value(parts[side == "left" ? 3 : 1])
300
+ else
301
+ 0
302
+ end
303
+ end
304
+
305
+ def component_head_style(carousel_id, length, attrs)
306
+ return "" if length.zero?
307
+
308
+ hide_non_selected = (0...length).map do |index|
309
+ ".mj-carousel-#{carousel_id}-radio:checked #{adjacent_siblings(index)}+ .mj-carousel-content .mj-carousel-image"
310
+ end.join(",\n")
311
+
312
+ show_selected = (0...length).map do |index|
313
+ ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-image-#{index + 1}"
314
+ end.join(",\n")
315
+
316
+ next_icons = (0...length).map do |index|
317
+ target = ((index + 1) % length) + 1
318
+ ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-next-#{target}"
319
+ end.join(",\n")
320
+
321
+ previous_icons = (0...length).map do |index|
322
+ target = ((index - 1) % length) + 1
323
+ ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-previous-#{target}"
324
+ end.join(",\n")
325
+
326
+ selected_thumbnail = (0...length).map do |index|
327
+ ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{index + 1}"
328
+ end.join(",\n")
329
+
330
+ show_thumbnails = (0...length).map do |index|
331
+ ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
332
+ end.join(",\n")
333
+
334
+ hide_on_hover = (0...length).map do |index|
335
+ ".mj-carousel-#{carousel_id}-thumbnail:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image"
336
+ end.join(",\n")
337
+
338
+ show_on_hover = (0...length).map do |index|
339
+ ".mj-carousel-#{carousel_id}-thumbnail-#{index + 1}:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image-#{index + 1}"
340
+ end.join(",\n")
341
+
342
+ <<~CSS
343
+ .mj-carousel {
344
+ -webkit-user-select: none;
345
+ -moz-user-select: none;
346
+ user-select: none;
347
+ }
348
+
349
+ .mj-carousel-#{carousel_id}-icons-cell {
350
+ display: table-cell !important;
351
+ width: #{attrs["icon-width"]} !important;
352
+ }
353
+
354
+ .mj-carousel-radio,
355
+ .mj-carousel-next,
356
+ .mj-carousel-previous {
357
+ display: none !important;
358
+ }
359
+
360
+ .mj-carousel-thumbnail,
361
+ .mj-carousel-next,
362
+ .mj-carousel-previous {
363
+ touch-action: manipulation;
364
+ }
365
+
366
+ #{hide_non_selected} {
367
+ display: none !important;
368
+ }
369
+
370
+ #{show_selected} {
371
+ display: block !important;
372
+ }
373
+
374
+ .mj-carousel-previous-icons,
375
+ .mj-carousel-next-icons,
376
+ #{next_icons},
377
+ #{previous_icons} {
378
+ display: block !important;
379
+ }
380
+
381
+ #{selected_thumbnail} {
382
+ border-color: #{attrs["tb-selected-border-color"]} !important;
383
+ }
384
+
385
+ #{show_thumbnails} {
386
+ display: inline-block !important;
387
+ }
388
+
389
+ .mj-carousel-image img + div,
390
+ .mj-carousel-thumbnail img + div {
391
+ display: none !important;
392
+ }
393
+
394
+ #{hide_on_hover} {
395
+ display: none !important;
396
+ }
397
+
398
+ .mj-carousel-thumbnail:hover {
399
+ border-color: #{attrs["tb-hover-border-color"]} !important;
400
+ }
401
+
402
+ #{show_on_hover} {
403
+ display: block !important;
404
+ }
405
+
406
+ .mj-carousel noinput { display:block !important; }
407
+ .mj-carousel noinput .mj-carousel-image-1 { display: block !important; }
408
+ .mj-carousel noinput .mj-carousel-arrows,
409
+ .mj-carousel noinput .mj-carousel-thumbnails { display: none !important; }
410
+
411
+ [owa] .mj-carousel-thumbnail { display: none !important; }
412
+
413
+ @media screen yahoo {
414
+ .mj-carousel-#{carousel_id}-icons-cell,
415
+ .mj-carousel-previous-icons,
416
+ .mj-carousel-next-icons {
417
+ display: none !important;
418
+ }
419
+
420
+ .mj-carousel-#{carousel_id}-radio-1:checked #{adjacent_siblings(length - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-1 {
421
+ border-color: transparent;
422
+ }
423
+ }
424
+ CSS
425
+ end
426
+
427
+ def adjacent_siblings(count)
428
+ "+ * " * count
429
+ end
430
+
431
+ def mso_conditional_tag(content, negation = false)
432
+ if negation
433
+ "<!--[if !mso]><!-->#{content}<!--<![endif]-->"
434
+ else
435
+ "<!--[if mso]>#{content}<![endif]-->"
436
+ end
437
+ end
438
+
439
+ def parse_pixel_value(value)
440
+ matched = value.to_s.match(/(-?\d+(?:\.\d+)?)/)
441
+ matched ? matched[1].to_f : 0.0
442
+ end
443
+
444
+ def blank?(value)
445
+ value.nil? || value.to_s.strip.empty?
446
+ end
447
+ end
448
+ end
449
+ end
@@ -0,0 +1,162 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class CarouselImage < Base
6
+ TAGS = ["mj-carousel-image"].freeze
7
+
8
+ ALLOWED_ATTRIBUTES = {
9
+ "alt" => "string",
10
+ "href" => "string",
11
+ "rel" => "string",
12
+ "target" => "string",
13
+ "title" => "string",
14
+ "src" => "string",
15
+ "thumbnails-src" => "string",
16
+ "border-radius" => "unit(px,%){1,4}",
17
+ "tb-border" => "string",
18
+ "tb-border-radius" => "unit(px,%){1,4}"
19
+ }.freeze
20
+
21
+ DEFAULT_ATTRIBUTES = {
22
+ "alt" => "",
23
+ "target" => "_blank"
24
+ }.freeze
25
+
26
+ def tags
27
+ TAGS
28
+ end
29
+
30
+ def render(tag_name:, node:, context:, attrs:, parent:)
31
+ render_item(
32
+ node,
33
+ attrs: DEFAULT_ATTRIBUTES.merge(attrs),
34
+ index: 0,
35
+ container_width: parse_pixel_value(context[:container_width] || "600px"),
36
+ visible: true
37
+ )
38
+ end
39
+
40
+ def render_radio(index:, carousel_id:)
41
+ input_attrs = {
42
+ "class" => "mj-carousel-radio mj-carousel-#{carousel_id}-radio mj-carousel-#{carousel_id}-radio-#{index + 1}",
43
+ "checked" => (index.zero? ? "checked" : nil),
44
+ "type" => "radio",
45
+ "name" => "mj-carousel-radio-#{carousel_id}",
46
+ "id" => "mj-carousel-#{carousel_id}-radio-#{index + 1}",
47
+ "style" => style_join(
48
+ "display" => "none",
49
+ "mso-hide" => "all"
50
+ )
51
+ }
52
+
53
+ %(<input#{html_attrs(input_attrs)} />)
54
+ end
55
+
56
+ def render_thumbnail(node, attrs:, index:, carousel_id:, thumbnails:, tb_width:)
57
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
58
+ css_class = suffix_css_classes(a["css-class"], "thumbnail")
59
+ link_classes = [
60
+ "mj-carousel-thumbnail",
61
+ "mj-carousel-#{carousel_id}-thumbnail",
62
+ "mj-carousel-#{carousel_id}-thumbnail-#{index + 1}",
63
+ css_class
64
+ ].compact.reject(&:empty?).join(" ")
65
+
66
+ link_attrs = {
67
+ "style" => style_join(
68
+ "border" => a["tb-border"],
69
+ "border-radius" => a["tb-border-radius"],
70
+ "display" => (thumbnails == "supported" ? "none" : "inline-block"),
71
+ "overflow" => "hidden",
72
+ "width" => tb_width
73
+ ),
74
+ "href" => "##{index + 1}",
75
+ "target" => a["target"],
76
+ "class" => link_classes
77
+ }
78
+ label_attrs = {
79
+ "for" => "mj-carousel-#{carousel_id}-radio-#{index + 1}"
80
+ }
81
+ image_attrs = {
82
+ "style" => style_join(
83
+ "display" => "block",
84
+ "width" => "100%",
85
+ "height" => "auto"
86
+ ),
87
+ "src" => a["thumbnails-src"] || a["src"],
88
+ "alt" => a["alt"],
89
+ "width" => parse_pixel_value(tb_width).to_i.to_s
90
+ }
91
+
92
+ <<~HTML.chomp
93
+ <a#{html_attrs(link_attrs)}>
94
+ <label#{html_attrs(label_attrs)}>
95
+ #{build_img_tag(image_attrs)}
96
+ </label>
97
+ </a>
98
+ HTML
99
+ end
100
+
101
+ def render_item(node, attrs:, index:, container_width:, visible:)
102
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
103
+ css_class = a["css-class"].to_s
104
+ classes = ["mj-carousel-image", "mj-carousel-image-#{index + 1}", css_class].reject(&:empty?).join(" ")
105
+ div_style = visible ? nil : style_join("display" => "none", "mso-hide" => "all")
106
+ img_attrs = {
107
+ "title" => a["title"],
108
+ "src" => a["src"],
109
+ "alt" => a["alt"],
110
+ "style" => style_join(
111
+ "border-radius" => a["border-radius"],
112
+ "display" => "block",
113
+ "width" => "#{container_width.to_i}px",
114
+ "max-width" => "100%",
115
+ "height" => "auto"
116
+ ),
117
+ "width" => container_width.to_i.to_s,
118
+ "border" => "0"
119
+ }
120
+ image_tag = build_img_tag(img_attrs)
121
+
122
+ content = if a["href"]
123
+ link_attrs = {
124
+ "href" => a["href"],
125
+ "rel" => a["rel"],
126
+ "target" => a["target"] || "_blank"
127
+ }
128
+ %(<a#{html_attrs(link_attrs)}>#{image_tag}</a>)
129
+ else
130
+ image_tag
131
+ end
132
+
133
+ %(<div#{html_attrs("class" => classes, "style" => div_style)}>#{content}</div>)
134
+ end
135
+
136
+ private
137
+
138
+ def parse_pixel_value(value)
139
+ matched = value.to_s.match(/(-?\d+(?:\.\d+)?)/)
140
+ matched ? matched[1].to_f : 0.0
141
+ end
142
+
143
+ def suffix_css_classes(classes, suffix)
144
+ return nil if classes.nil? || classes.empty?
145
+
146
+ classes.split(/\s+/).map { |klass| "#{klass}-#{suffix}" }.join(" ")
147
+ end
148
+
149
+ def build_img_tag(attrs)
150
+ ordered_keys = %w[title src alt style width border]
151
+ rendered = ordered_keys.filter_map do |key|
152
+ next unless attrs.key?(key)
153
+ next if key != "alt" && (attrs[key].nil? || attrs[key].to_s.empty?)
154
+
155
+ %(#{key}="#{escape_attr(attrs[key].to_s)}")
156
+ end
157
+
158
+ "<img #{rendered.join(' ')} />"
159
+ end
160
+ end
161
+ end
162
+ end
@@ -5,6 +5,8 @@ require_relative "components/attributes"
5
5
  require_relative "components/body"
6
6
  require_relative "components/breakpoint"
7
7
  require_relative "components/button"
8
+ require_relative "components/carousel"
9
+ require_relative "components/carousel_image"
8
10
  require_relative "components/head"
9
11
  require_relative "components/hero"
10
12
  require_relative "components/image"
@@ -367,9 +369,43 @@ module MjmlRb
367
369
  declarations.each do |property, value|
368
370
  existing[property] = value
369
371
  end
372
+ normalize_background_fallbacks!(node, existing)
370
373
  node["style"] = existing.map { |property, value| "#{property}: #{value}" }.join("; ")
371
374
  end
372
375
 
376
+ def normalize_background_fallbacks!(node, declarations)
377
+ background_color = declarations["background-color"]
378
+ return if background_color.nil? || background_color.empty?
379
+
380
+ if syncable_background?(declarations["background"])
381
+ declarations["background"] = background_color
382
+ end
383
+
384
+ return unless node.name == "td"
385
+ return unless node["bgcolor"]
386
+ return if %w[none transparent].include?(background_color.downcase)
387
+
388
+ node["bgcolor"] = background_color
389
+ end
390
+
391
+ def syncable_background?(value)
392
+ return true if value.nil? || value.empty?
393
+
394
+ normalized = value.downcase
395
+ !normalized.include?("url(") &&
396
+ !normalized.include?("gradient(") &&
397
+ !normalized.include?("/") &&
398
+ !normalized.include?(" no-repeat") &&
399
+ !normalized.include?(" repeat") &&
400
+ !normalized.include?(" fixed") &&
401
+ !normalized.include?(" scroll") &&
402
+ !normalized.include?(" center") &&
403
+ !normalized.include?(" top") &&
404
+ !normalized.include?(" bottom") &&
405
+ !normalized.include?(" left") &&
406
+ !normalized.include?(" right")
407
+ end
408
+
373
409
  def append_component_head_styles(document, context)
374
410
  component_registry.each_value.uniq.each do |component|
375
411
  next unless component.respond_to?(:head_style)
@@ -406,6 +442,8 @@ module MjmlRb
406
442
  register_component(registry, Components::Breakpoint.new(self))
407
443
  register_component(registry, Components::Accordion.new(self))
408
444
  register_component(registry, Components::Button.new(self))
445
+ register_component(registry, Components::Carousel.new(self))
446
+ register_component(registry, Components::CarouselImage.new(self))
409
447
  register_component(registry, Components::Hero.new(self))
410
448
  register_component(registry, Components::Image.new(self))
411
449
  register_component(registry, Components::Navbar.new(self))
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.11".freeze
2
+ VERSION = "0.2.13".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.2.11
4
+ version: 0.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -59,6 +59,8 @@ files:
59
59
  - lib/mjml-rb/components/body.rb
60
60
  - lib/mjml-rb/components/breakpoint.rb
61
61
  - lib/mjml-rb/components/button.rb
62
+ - lib/mjml-rb/components/carousel.rb
63
+ - lib/mjml-rb/components/carousel_image.rb
62
64
  - lib/mjml-rb/components/column.rb
63
65
  - lib/mjml-rb/components/divider.rb
64
66
  - lib/mjml-rb/components/head.rb