mjml-rb 0.2.12 → 0.2.14

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: 2d1dc49d39635e3f4530f642a8699646f3bff8e545213387f91d95bb461ea67d
4
+ data.tar.gz: 15139737ec96a78601bd7d0e0a992b8743060d4923664eea05462a76e70dc7d3
5
5
  SHA512:
6
- metadata.gz: 9da675856ef3de4e27f421c37a5a54f0b89065a13b3613000439d97493959269269d77d46416e7edb4550516b88a8b645c8847328595baa408eaac9045eaea5a
7
- data.tar.gz: e14ef3402314fc2fbbb3b292945c5d7116340e5a14f8fe78d3f171054a25acdc43227396a00d6a12950e0492eefcfb04c7ea896618a9112f16a501ebe80b8921
6
+ metadata.gz: 486e95fb106f03ebb1ae929ec548996f9d53868b360fcff23cfd0aa28d9e4240e3a3181f611b3270837c9012e523c32f3089c0ebe3a82e9bd6e067735a955bec
7
+ data.tar.gz: 5bf0eb72f669aed9eb59dc1b8fbe16563082392bbc8d05e2f8d4ce4098a492606303cea65692757fdf5b8eed2c0fc2be7080daeac73f6f242b0a1dfc1c0d5551
data/README.md CHANGED
@@ -44,7 +44,7 @@ The table below tracks current JS-to-Ruby migration status for MJML components i
44
44
  | `mj-section` | migrated | Implemented in `section.rb`. |
45
45
  | `mj-wrapper` | migrated | Implemented via `section.rb`. |
46
46
  | `mj-column` | migrated | Implemented in `column.rb`. |
47
- | `mj-group` | migrated | Rendered directly by the renderer. |
47
+ | `mj-group` | migrated | Implemented in `group.rb`, including width-aware child rendering and Outlook table wrappers. |
48
48
  | `mj-text` | migrated | Implemented in `text.rb`. |
49
49
  | `mj-image` | migrated | Implemented in `image.rb`. |
50
50
  | `mj-button` | migrated | Implemented in `button.rb`. |
@@ -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
@@ -0,0 +1,134 @@
1
+ require_relative "base"
2
+
3
+ module MjmlRb
4
+ module Components
5
+ class Group < Base
6
+ TAGS = ["mj-group"].freeze
7
+
8
+ ALLOWED_ATTRIBUTES = {
9
+ "background-color" => "color",
10
+ "direction" => "enum(ltr,rtl)",
11
+ "vertical-align" => "enum(top,bottom,middle)",
12
+ "width" => "unit(px,%)"
13
+ }.freeze
14
+
15
+ DEFAULT_ATTRIBUTES = {
16
+ "direction" => "ltr"
17
+ }.freeze
18
+
19
+ def tags
20
+ TAGS
21
+ end
22
+
23
+ def render(tag_name:, node:, context:, attrs:, parent:)
24
+ width_pct = context.delete(:_column_width_pct) || 100.0
25
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
26
+ css_class = a["css-class"]
27
+
28
+ pct_str = width_pct.to_f.to_s.sub(/\.?0+$/, "")
29
+ class_suffix = pct_str.gsub(".", "-")
30
+ context[:column_widths][class_suffix] = pct_str if context[:column_widths]
31
+
32
+ group_class = "mj-column-per-#{class_suffix} mj-outlook-group-fix"
33
+ group_class = "#{group_class} #{css_class}" if css_class && !css_class.empty?
34
+
35
+ group_width = group_container_width(context, a, width_pct)
36
+ div_style = style_join(
37
+ "font-size" => "0",
38
+ "line-height" => "0",
39
+ "text-align" => "left",
40
+ "display" => "inline-block",
41
+ "width" => "100%",
42
+ "direction" => a["direction"],
43
+ "vertical-align" => a["vertical-align"],
44
+ "background-color" => a["background-color"]
45
+ )
46
+
47
+ inner = with_group_container_width(context, group_width) do
48
+ render_group_children(node, context, a, group_width)
49
+ end
50
+
51
+ %(<div class="#{escape_attr(group_class)}" style="#{div_style}">#{inner}</div>)
52
+ end
53
+
54
+ private
55
+
56
+ def render_group_children(node, context, attrs, group_width)
57
+ columns = node.element_children.select { |child| child.tag_name == "mj-column" }
58
+ widths = renderer.send(:compute_column_widths, columns, context)
59
+ group_width_px = parse_pixel_value(group_width)
60
+ group_bg = attrs["background-color"]
61
+ table_attrs = {
62
+ "bgcolor" => (group_bg == "none" ? nil : group_bg),
63
+ "border" => "0",
64
+ "cellpadding" => "0",
65
+ "cellspacing" => "0",
66
+ "role" => "presentation"
67
+ }
68
+
69
+ open_table = %(<!--[if mso | IE]><table#{html_attrs(table_attrs)}><tr><![endif]-->)
70
+ close_table = %(<!--[if mso | IE]></tr></table><![endif]-->)
71
+ column_index = 0
72
+
73
+ body = with_inherited_mj_class(context, node) do
74
+ node.children.map do |child|
75
+ case child.tag_name
76
+ when "mj-column"
77
+ width_pct = widths[column_index] || 100.0
78
+ column_index += 1
79
+ context[:_column_width_pct] = width_pct
80
+ td_style = style_join(
81
+ "vertical-align" => resolved_attributes(child, context)["vertical-align"] || "top",
82
+ "width" => "#{(group_width_px * width_pct / 100.0).round}px"
83
+ )
84
+ td_open = %(<!--[if mso | IE]><td#{html_attrs("style" => td_style)}><![endif]-->)
85
+ td_close = %(<!--[if mso | IE]></td><![endif]-->)
86
+ "#{td_open}#{render_node(child, context, parent: "mj-group")}#{td_close}"
87
+ when "mj-raw"
88
+ render_node(child, context, parent: "mj-group")
89
+ else
90
+ render_node(child, context, parent: "mj-group")
91
+ end
92
+ end.join("\n")
93
+ end
94
+
95
+ "#{open_table}#{body}#{close_table}"
96
+ end
97
+
98
+ def with_group_container_width(context, width)
99
+ previous = context[:container_width]
100
+ context[:container_width] = width
101
+ yield
102
+ ensure
103
+ context[:container_width] = previous
104
+ end
105
+
106
+ def group_container_width(context, attrs, width_pct)
107
+ parent_width = parse_pixel_value(context[:container_width] || "600px")
108
+ width = attrs["width"]
109
+
110
+ raw_width =
111
+ if present_attr?(width) && width.end_with?("%")
112
+ parent_width * parse_pixel_value(width) / 100.0
113
+ elsif present_attr?(width) && width.end_with?("px")
114
+ parse_pixel_value(width)
115
+ else
116
+ parent_width * width_pct / 100.0
117
+ end
118
+
119
+ "#{[raw_width, 0].max}px"
120
+ end
121
+
122
+ def present_attr?(value)
123
+ value && !value.empty?
124
+ end
125
+
126
+ def parse_pixel_value(value)
127
+ return 0.0 unless present_attr?(value)
128
+
129
+ matched = value.to_s.match(/(-?\d+(?:\.\d+)?)/)
130
+ matched ? matched[1].to_f : 0.0
131
+ end
132
+ end
133
+ end
134
+ end
@@ -233,12 +233,8 @@ module MjmlRb
233
233
  td_open = %(<!--[if mso | IE]><td class="" style="vertical-align:#{v_align};width:#{col_px}px;" ><![endif]-->)
234
234
  td_close = %(<!--[if mso | IE]></td><![endif]-->)
235
235
 
236
- col_html = if col.tag_name == "mj-group"
237
- renderer.send(:render_group, col, context, widths[i])
238
- else
239
- context[:_column_width_pct] = widths[i]
240
- render_node(col, context, parent: "mj-section")
241
- end
236
+ context[:_column_width_pct] = widths[i]
237
+ col_html = render_node(col, context, parent: "mj-section")
242
238
 
243
239
  "#{td_open}\n#{col_html}\n#{td_close}"
244
240
  end
@@ -5,6 +5,9 @@ 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"
10
+ require_relative "components/group"
8
11
  require_relative "components/head"
9
12
  require_relative "components/hero"
10
13
  require_relative "components/image"
@@ -161,24 +164,7 @@ module MjmlRb
161
164
  if (component = component_for(node.tag_name))
162
165
  return component.render(tag_name: node.tag_name, node: node, context: context, attrs: attrs, parent: parent)
163
166
  end
164
-
165
- case node.tag_name
166
- when "mj-group"
167
- render_group(node, context)
168
- else
169
- render_children(node, context, parent: node.tag_name)
170
- end
171
- end
172
-
173
- def render_group(node, context, width_pct = 100)
174
- items = node.element_children.select { |e| e.tag_name == "mj-column" }
175
- widths = compute_column_widths(items, context)
176
- with_inherited_mj_class(context, node) do
177
- items.each_with_index.map do |item, i|
178
- context[:_column_width_pct] = widths[i]
179
- render_node(item, context, parent: "mj-group")
180
- end.join("\n")
181
- end
167
+ render_children(node, context, parent: node.tag_name)
182
168
  end
183
169
 
184
170
  def compute_column_widths(columns, context)
@@ -440,6 +426,9 @@ module MjmlRb
440
426
  register_component(registry, Components::Breakpoint.new(self))
441
427
  register_component(registry, Components::Accordion.new(self))
442
428
  register_component(registry, Components::Button.new(self))
429
+ register_component(registry, Components::Carousel.new(self))
430
+ register_component(registry, Components::CarouselImage.new(self))
431
+ register_component(registry, Components::Group.new(self))
443
432
  register_component(registry, Components::Hero.new(self))
444
433
  register_component(registry, Components::Image.new(self))
445
434
  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.14".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.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -59,8 +59,11 @@ 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
66
+ - lib/mjml-rb/components/group.rb
64
67
  - lib/mjml-rb/components/head.rb
65
68
  - lib/mjml-rb/components/hero.rb
66
69
  - lib/mjml-rb/components/html_attributes.rb