mjml-rb 0.2.15 → 0.2.18

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: 990083fd9e656cfe50b74937926c14f37ea359adf49530e5ffbacf17ef0ea00a
4
- data.tar.gz: a03cc468ddcdb25fdde896f3d53bbd8febaad32db0b3aae3ccad69725f11920c
3
+ metadata.gz: b82bc910ae0c3ba34cd905a28e8d2f52e349d7a1b099981eb576592a3274ba5b
4
+ data.tar.gz: 6e8ffbc112105cb1d11b5ba46f7b7a44bd9377c52498edc34e97c1ee421d4ac2
5
5
  SHA512:
6
- metadata.gz: ee2bff6d7a36be6fe4f95b19eefd60e9ff295d62d681cd7e3131ff9e298859b66fea7f5d6887ae3f984d9f13fc6aeda4f3f58b8ee6e6cfd9b603d804cb0305a2
7
- data.tar.gz: fdd623a60697cb966591bfcad9fc973e71586ffc3776f6f3de5944fc63c2ea413324b3b6ca288303117b58fbfc12c8e2b13aca174b657a5e8c4a33eb73138e97
6
+ metadata.gz: 2eb6640d8405e3f8297a8fbd081530a8565fed4d410b297b78443d93bb1640db84d611aec0746beab99b5183a41d1d7b24d658ea048a2806f4899175c613bc95
7
+ data.tar.gz: 5f29c238409b28d5048e1a7b85aff199f93a975afcbf4d59f7d5562c9abadf3c4c2eb5e0768bb3e2da14e976bb9ad99e7743ddc6f3325f6820d5e06c63f0a414
@@ -7,6 +7,12 @@ module MjmlRb
7
7
 
8
8
  SECTION_ALLOWED_ATTRIBUTES = {
9
9
  "background-color" => "color",
10
+ "background-url" => "string",
11
+ "background-repeat" => "enum(repeat,no-repeat)",
12
+ "background-size" => "string",
13
+ "background-position" => "string",
14
+ "background-position-x" => "string",
15
+ "background-position-y" => "string",
10
16
  "border" => "string",
11
17
  "border-bottom" => "string",
12
18
  "border-left" => "string",
@@ -23,13 +29,17 @@ module MjmlRb
23
29
  }.freeze
24
30
 
25
31
  WRAPPER_ALLOWED_ATTRIBUTES = SECTION_ALLOWED_ATTRIBUTES.merge(
32
+ "gap" => "unit(px)",
26
33
  "full-width" => "enum(full-width)"
27
34
  ).freeze
28
35
 
29
36
  DEFAULT_ATTRIBUTES = {
30
- "direction" => "ltr",
31
- "padding" => "20px 0",
32
- "text-align" => "center"
37
+ "direction" => "ltr",
38
+ "padding" => "20px 0",
39
+ "text-align" => "center",
40
+ "background-repeat" => "repeat",
41
+ "background-size" => "auto",
42
+ "background-position" => "top center"
33
43
  }.freeze
34
44
 
35
45
  class << self
@@ -116,6 +126,166 @@ module MjmlRb
116
126
  " #{parts.join(' ')} "
117
127
  end
118
128
 
129
+ # ── Background helpers ───────────────────────────────────────────────
130
+
131
+ VERTICAL_KEYWORDS = %w[top bottom].freeze
132
+ HORIZONTAL_KEYWORDS = %w[left right].freeze
133
+
134
+ def has_background?(a)
135
+ url = a["background-url"]
136
+ url && !url.to_s.strip.empty?
137
+ end
138
+
139
+ def parse_background_position(position_str)
140
+ tokens = position_str.to_s.strip.split(/\s+/)
141
+
142
+ case tokens.size
143
+ when 0
144
+ {x: "center", y: "top"}
145
+ when 1
146
+ if VERTICAL_KEYWORDS.include?(tokens[0])
147
+ {x: "center", y: tokens[0]}
148
+ else
149
+ {x: tokens[0], y: "center"}
150
+ end
151
+ when 2
152
+ first, second = tokens
153
+ if VERTICAL_KEYWORDS.include?(first) ||
154
+ (first == "center" && HORIZONTAL_KEYWORDS.include?(second))
155
+ {x: second, y: first}
156
+ else
157
+ {x: first, y: second}
158
+ end
159
+ else
160
+ {x: "center", y: "top"}
161
+ end
162
+ end
163
+
164
+ def get_background_position(a)
165
+ base = parse_background_position(a["background-position"] || "top center")
166
+ pos_x = a["background-position-x"]
167
+ pos_y = a["background-position-y"]
168
+ x = (pos_x && !pos_x.to_s.empty?) ? pos_x : base[:x]
169
+ y = (pos_y && !pos_y.to_s.empty?) ? pos_y : base[:y]
170
+ {x: x, y: y}
171
+ end
172
+
173
+ def get_background_string(a)
174
+ pos = get_background_position(a)
175
+ "#{pos[:x]} #{pos[:y]}"
176
+ end
177
+
178
+ def get_background(a)
179
+ bg_url = a["background-url"]
180
+ bg_color = a["background-color"]
181
+ bg_size = a["background-size"]
182
+ bg_repeat = a["background-repeat"]
183
+
184
+ if has_background?(a)
185
+ pos_str = get_background_string(a)
186
+ parts = []
187
+ parts << bg_color if bg_color && !bg_color.to_s.empty?
188
+ parts << "url('#{bg_url}')"
189
+ parts << pos_str
190
+ parts << "/ #{bg_size}"
191
+ parts << bg_repeat
192
+ parts.join(" ")
193
+ else
194
+ bg_color
195
+ end
196
+ end
197
+
198
+ # ── VML background for Outlook ───────────────────────────────────────
199
+
200
+ PERCENTAGE_RE = /\A\d+(\.\d+)?%\z/
201
+
202
+ VML_KEYWORD_TO_PERCENT = {
203
+ "left" => "0%", "top" => "0%",
204
+ "center" => "50%",
205
+ "right" => "100%", "bottom" => "100%"
206
+ }.freeze
207
+
208
+ def render_with_background(section_html, a, container_px)
209
+ bg_url = a["background-url"]
210
+ bg_color = a["background-color"]
211
+ bg_repeat = a["background-repeat"] || "repeat"
212
+ bg_size = a["background-size"] || "auto"
213
+ is_repeat = bg_repeat == "repeat"
214
+
215
+ pos = get_background_position(a)
216
+
217
+ # Normalize keywords to percentages
218
+ bg_pos_x = VML_KEYWORD_TO_PERCENT.fetch(pos[:x], nil) || (pos[:x] =~ PERCENTAGE_RE ? pos[:x] : "50%")
219
+ bg_pos_y = VML_KEYWORD_TO_PERCENT.fetch(pos[:y], nil) || (pos[:y] =~ PERCENTAGE_RE ? pos[:y] : "0%")
220
+
221
+ # Compute VML origin/position per axis
222
+ v_origin_x, v_pos_x = vml_axis_values(bg_pos_x, is_repeat, true)
223
+ v_origin_y, v_pos_y = vml_axis_values(bg_pos_y, is_repeat, false)
224
+
225
+ # VML size attributes
226
+ v_size_attrs = vml_size_attributes(bg_size)
227
+
228
+ # VML type
229
+ is_auto = bg_size == "auto"
230
+ vml_type = (!is_repeat && !is_auto) ? "frame" : "tile"
231
+
232
+ # Auto special case: force tile, reset position
233
+ if is_auto
234
+ v_origin_x = 0.5; v_pos_x = 0.5
235
+ v_origin_y = 0; v_pos_y = 0
236
+ end
237
+
238
+ # Build v:fill attributes
239
+ fill_pairs = [
240
+ ["origin", "#{v_origin_x}, #{v_origin_y}"],
241
+ ["position", "#{v_pos_x}, #{v_pos_y}"],
242
+ ["src", bg_url],
243
+ ["color", bg_color],
244
+ ["type", vml_type]
245
+ ]
246
+ fill_pairs << ["size", v_size_attrs[:size]] if v_size_attrs[:size]
247
+ fill_pairs << ["aspect", v_size_attrs[:aspect]] if v_size_attrs[:aspect]
248
+ fill_str = fill_pairs.map { |(k, v)| %(#{k}="#{escape_attr(v.to_s)}") }.join(" ")
249
+
250
+ %(<!--[if mso | IE]><v:rect style="mso-width-percent:1000;" xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false"><v:fill #{fill_str} /><v:textbox style="mso-fit-shape-to-text:true" inset="0,0,0,0"><![endif]-->) +
251
+ section_html +
252
+ %(<!--[if mso | IE]></v:textbox></v:rect><![endif]-->)
253
+ end
254
+
255
+ def vml_axis_values(pct_str, is_repeat, is_x)
256
+ if pct_str =~ PERCENTAGE_RE
257
+ decimal = pct_str.to_f / 100.0
258
+ if is_repeat
259
+ [decimal, decimal]
260
+ else
261
+ val = (-50 + decimal * 100) / 100.0
262
+ [val, val]
263
+ end
264
+ elsif is_repeat
265
+ [is_x ? 0.5 : 0, is_x ? 0.5 : 0]
266
+ else
267
+ [is_x ? 0 : -0.5, is_x ? 0 : -0.5]
268
+ end
269
+ end
270
+
271
+ def vml_size_attributes(bg_size)
272
+ case bg_size
273
+ when "cover"
274
+ {size: "1,1", aspect: "atleast"}
275
+ when "contain"
276
+ {size: "1,1", aspect: "atmost"}
277
+ when "auto"
278
+ {}
279
+ else
280
+ parts = bg_size.to_s.strip.split(/\s+/)
281
+ if parts.size == 1
282
+ {size: bg_size, aspect: "atmost"}
283
+ else
284
+ {size: parts.join(",")}
285
+ end
286
+ end
287
+ end
288
+
119
289
  # ── mj-section ─────────────────────────────────────────────────────────
120
290
 
121
291
  def render_section(node, context, attrs)
@@ -124,6 +294,8 @@ module MjmlRb
124
294
  css_class = a["css-class"]
125
295
  bg_color = a["background-color"]
126
296
  border_radius = a["border-radius"]
297
+ bg_has = has_background?(a)
298
+ wrapper_gap = context[:_wrapper_child_gap]
127
299
 
128
300
  # Box width: container minus horizontal padding and borders
129
301
  border_left = parse_border_width(a["border-left"] || a["border"])
@@ -141,53 +313,94 @@ module MjmlRb
141
313
  ["cellspacing", "0"],
142
314
  ["class", outlook_class],
143
315
  ["role", "presentation"],
144
- ["style", "width:#{container_px}px;"],
316
+ ["style", style_join("width" => "#{container_px}px", "padding-top" => wrapper_gap) + ";"],
145
317
  ["width", container_px.to_s]
146
318
  ]
147
319
  before_pairs << ["bgcolor", bg_color] if bg_color
148
320
 
149
321
  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
322
 
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
-
323
+ # Section div, table, td — styles differ based on background-url presence
159
324
  border_val = a["border"]
160
325
  border_val = nil if border_val.nil? || border_val.to_s.strip.empty? || border_val.to_s.strip == "none"
161
326
 
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
- )
327
+ if bg_has
328
+ bg_value = get_background(a)
329
+ bg_string = get_background_string(a)
330
+ bg_repeat = a["background-repeat"]
331
+ bg_size = a["background-size"]
332
+
333
+ div_style = style_join(
334
+ "background" => bg_value,
335
+ "background-position" => bg_string,
336
+ "background-repeat" => bg_repeat,
337
+ "background-size" => bg_size,
338
+ "margin" => "0px auto",
339
+ "margin-top" => wrapper_gap,
340
+ "max-width" => "#{container_px}px"
341
+ )
342
+ table_style = style_join(
343
+ "background" => bg_value,
344
+ "background-position" => bg_string,
345
+ "background-repeat" => bg_repeat,
346
+ "background-size" => bg_size,
347
+ "border-radius" => border_radius,
348
+ "width" => "100%"
349
+ )
350
+ td_style = style_join(
351
+ "border" => border_val,
352
+ "border-top" => a["border-top"],
353
+ "border-right" => a["border-right"],
354
+ "border-bottom" => a["border-bottom"],
355
+ "border-left" => a["border-left"],
356
+ "border-radius" => border_radius,
357
+ "direction" => a["direction"],
358
+ "font-size" => "0px",
359
+ "padding" => a["padding"],
360
+ "padding-top" => a["padding-top"],
361
+ "padding-right" => a["padding-right"],
362
+ "padding-bottom" => a["padding-bottom"],
363
+ "padding-left" => a["padding-left"],
364
+ "text-align" => a["text-align"]
365
+ )
366
+ else
367
+ div_style = style_join(
368
+ "background" => bg_color,
369
+ "background-color" => bg_color,
370
+ "margin" => "0px auto",
371
+ "margin-top" => wrapper_gap,
372
+ "max-width" => "#{container_px}px"
373
+ )
374
+ table_style = style_join(
375
+ "background" => bg_color,
376
+ "background-color" => bg_color,
377
+ "border-radius" => border_radius,
378
+ "width" => "100%"
379
+ )
380
+ td_style = style_join(
381
+ "border" => border_val,
382
+ "border-top" => a["border-top"],
383
+ "border-right" => a["border-right"],
384
+ "border-bottom" => a["border-bottom"],
385
+ "border-left" => a["border-left"],
386
+ "border-radius" => border_radius,
387
+ "background" => bg_color,
388
+ "background-color" => bg_color,
389
+ "direction" => a["direction"],
390
+ "font-size" => "0px",
391
+ "padding" => a["padding"],
392
+ "padding-top" => a["padding-top"],
393
+ "padding-right" => a["padding-right"],
394
+ "padding-bottom" => a["padding-bottom"],
395
+ "padding-left" => a["padding-left"],
396
+ "text-align" => a["text-align"]
397
+ )
398
+ end
187
399
 
188
400
  div_attrs = {"class" => css_class, "style" => div_style}
189
401
  table_attrs = {
190
402
  "align" => "center",
403
+ "background" => bg_has ? a["background-url"] : nil,
191
404
  "border" => "0",
192
405
  "cellpadding" => "0",
193
406
  "cellspacing" => "0",
@@ -202,14 +415,19 @@ module MjmlRb
202
415
  }
203
416
  inner = merge_outlook_conditionals(render_section_columns(node, context, box_width))
204
417
 
418
+ # Wrap in innerDiv when background image is present (prevents Yahoo whitespace gaps)
419
+ inner_content = bg_has ? %(<div style="line-height:0;font-size:0">#{inner}</div>) : inner
420
+
205
421
  section_html =
206
422
  %(<div#{html_attrs(div_attrs)}>) +
207
423
  %(<table#{html_attrs(table_attrs)}>) +
208
- %(<tbody><tr><td#{html_attrs(td_attrs)}>#{inner}</td></tr></tbody></table></div>)
424
+ %(<tbody><tr><td#{html_attrs(td_attrs)}>#{inner_content}</td></tr></tbody></table></div>)
209
425
 
210
426
  render_after = %(<!--[if mso | IE]></td></tr></table><![endif]-->)
211
427
 
212
- "#{render_before}\n#{section_html}\n#{render_after}"
428
+ body = bg_has ? render_with_background(section_html, a, container_px) : section_html
429
+
430
+ "#{render_before}\n#{body}\n#{render_after}"
213
431
  end
214
432
 
215
433
  # Generate Outlook IE conditional wrappers around each column/group.
@@ -251,6 +469,7 @@ module MjmlRb
251
469
  css_class = a["css-class"]
252
470
  bg_color = a["background-color"]
253
471
  full_width = a["full-width"] == "full-width"
472
+ wrapper_gap = context[:_wrapper_child_gap]
254
473
 
255
474
  # renderBefore — same structure as section
256
475
  outlook_class = css_class ? "#{css_class}-outlook" : ""
@@ -261,7 +480,7 @@ module MjmlRb
261
480
  ["cellspacing", "0"],
262
481
  ["class", outlook_class],
263
482
  ["role", "presentation"],
264
- ["style", "width:#{container_px}px;"],
483
+ ["style", style_join("width" => "#{container_px}px", "padding-top" => wrapper_gap) + ";"],
265
484
  ["width", container_px.to_s]
266
485
  ]
267
486
  before_pairs << ["bgcolor", bg_color] if bg_color
@@ -272,6 +491,7 @@ module MjmlRb
272
491
  "background" => bg_color,
273
492
  "background-color" => bg_color,
274
493
  "margin" => "0px auto",
494
+ "margin-top" => wrapper_gap,
275
495
  "max-width" => (full_width ? nil : "#{container_px}px")
276
496
  )
277
497
 
@@ -293,7 +513,7 @@ module MjmlRb
293
513
  )
294
514
 
295
515
  div_attrs = {"class" => css_class, "style" => div_style}
296
- inner = merge_outlook_conditionals(render_wrapped_children_wrapper(node, context, container_px))
516
+ inner = merge_outlook_conditionals(render_wrapped_children_wrapper(node, context, container_px, a["gap"]))
297
517
 
298
518
  wrapper_html =
299
519
  %(<div#{html_attrs(div_attrs)}>) +
@@ -306,7 +526,7 @@ module MjmlRb
306
526
  end
307
527
 
308
528
  # Wrap each child mj-section/mj-wrapper in an Outlook conditional <td>.
309
- def render_wrapped_children_wrapper(node, context, container_px)
529
+ def render_wrapped_children_wrapper(node, context, container_px, gap)
310
530
  children = node.element_children.select { |e| %w[mj-section mj-wrapper].include?(e.tag_name) }
311
531
  return render_children(node, context, parent: "mj-wrapper") if children.empty?
312
532
 
@@ -316,16 +536,26 @@ module MjmlRb
316
536
  close_table = %(<!--[if mso | IE]></table><![endif]-->)
317
537
 
318
538
  section_parts = with_inherited_mj_class(context, node) do
319
- children.map do |child|
539
+ children.each_with_index.map do |child, index|
320
540
  td_open = %(<!--[if mso | IE]><td class="" width="#{container_px}px" ><![endif]-->)
321
541
  td_close = %(<!--[if mso | IE]></td><![endif]-->)
322
- child_html = render_node(child, context, parent: "mj-wrapper")
542
+ child_html = with_wrapper_child_gap(context, index.zero? ? nil : gap) do
543
+ render_node(child, context, parent: "mj-wrapper")
544
+ end
323
545
  "#{td_open}\n#{child_html}\n#{td_close}"
324
546
  end
325
547
  end
326
548
 
327
549
  ([open_table, open_tr] + section_parts + [close_tr, close_table]).join("\n")
328
550
  end
551
+
552
+ def with_wrapper_child_gap(context, gap)
553
+ previous = context[:_wrapper_child_gap]
554
+ context[:_wrapper_child_gap] = gap
555
+ yield
556
+ ensure
557
+ context[:_wrapper_child_gap] = previous
558
+ end
329
559
  end
330
560
  end
331
561
  end
@@ -5,6 +5,28 @@ module MjmlRb
5
5
  class Text < Base
6
6
  TAGS = ["mj-text"].freeze
7
7
 
8
+ ALLOWED_ATTRIBUTES = {
9
+ "align" => "enum(left,right,center,justify)",
10
+ "background-color" => "color",
11
+ "color" => "color",
12
+ "container-background-color" => "color",
13
+ "font-family" => "string",
14
+ "font-size" => "string",
15
+ "font-style" => "string",
16
+ "font-weight" => "string",
17
+ "height" => "string",
18
+ "letter-spacing" => "string",
19
+ "line-height" => "string",
20
+ "padding" => "unit(px,%){1,4}",
21
+ "padding-top" => "unit(px,%)",
22
+ "padding-right" => "unit(px,%)",
23
+ "padding-bottom" => "unit(px,%)",
24
+ "padding-left" => "unit(px,%)",
25
+ "text-decoration" => "string",
26
+ "text-transform" => "string",
27
+ "vertical-align" => "enum(top,bottom,middle)"
28
+ }.freeze
29
+
8
30
  DEFAULTS = {
9
31
  "align" => "left",
10
32
  "color" => "#000000",
@@ -40,6 +62,7 @@ module MjmlRb
40
62
  }
41
63
 
42
64
  div_style = style_join(
65
+ "background-color" => a["background-color"],
43
66
  "font-family" => a["font-family"],
44
67
  "font-size" => a["font-size"],
45
68
  "font-style" => a["font-style"],
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.15".freeze
2
+ VERSION = "0.2.18".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.15
4
+ version: 0.2.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk