mjml-rb 0.2.12 → 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: b877cf70c778c4916d93167e650dd7238bc0a50044055b4784681cb9b72a2488
4
- data.tar.gz: 642a476a1142ae386d8f6af1b05b555e46638e69cf83dfabbd8247c2c09d8f7d
3
+ metadata.gz: 9c54cb0ddf9071b81422853dfcca7e3748ed404b7ea1afba2a99ce9a42229d01
4
+ data.tar.gz: 7d2469230fa62b2dd52df43e3e8858f1a38aeb5964469af5ec8669182664e9f8
5
5
  SHA512:
6
- metadata.gz: 9da675856ef3de4e27f421c37a5a54f0b89065a13b3613000439d97493959269269d77d46416e7edb4550516b88a8b645c8847328595baa408eaac9045eaea5a
7
- data.tar.gz: e14ef3402314fc2fbbb3b292945c5d7116340e5a14f8fe78d3f171054a25acdc43227396a00d6a12950e0492eefcfb04c7ea896618a9112f16a501ebe80b8921
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"
@@ -440,6 +442,8 @@ module MjmlRb
440
442
  register_component(registry, Components::Breakpoint.new(self))
441
443
  register_component(registry, Components::Accordion.new(self))
442
444
  register_component(registry, Components::Button.new(self))
445
+ register_component(registry, Components::Carousel.new(self))
446
+ register_component(registry, Components::CarouselImage.new(self))
443
447
  register_component(registry, Components::Hero.new(self))
444
448
  register_component(registry, Components::Image.new(self))
445
449
  register_component(registry, Components::Navbar.new(self))
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.12".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.12
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