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 +4 -4
- data/README.md +3 -3
- data/lib/mjml-rb/components/carousel.rb +449 -0
- data/lib/mjml-rb/components/carousel_image.rb +162 -0
- data/lib/mjml-rb/components/group.rb +134 -0
- data/lib/mjml-rb/components/section.rb +2 -6
- data/lib/mjml-rb/renderer.rb +7 -18
- data/lib/mjml-rb/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d1dc49d39635e3f4530f642a8699646f3bff8e545213387f91d95bb461ea67d
|
|
4
|
+
data.tar.gz: 15139737ec96a78601bd7d0e0a992b8743060d4923664eea05462a76e70dc7d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
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` |
|
|
73
|
-
| `mj-carousel-image` |
|
|
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
|
-
|
|
237
|
-
|
|
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
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -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))
|
data/lib/mjml-rb/version.rb
CHANGED
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.
|
|
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
|