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