emjay 0.1.2 → 0.2.0

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: 236a4d64e435fdf2e8616ef9212e7ce2cf4cf3d58c56e0fa5bb5b09783b807f7
4
- data.tar.gz: bbc45d8d1ce8f19fb991c7741e8ce819d38dc9aac9c42582ccd7105fffe914c7
3
+ metadata.gz: b7e892dd10debedb98375075acbc595f229f28a1cf7976a90444748f123236b8
4
+ data.tar.gz: adfb7062c1f9ca6d7a7926f9add058896e1d71078a795a64f0638cc036ad387e
5
5
  SHA512:
6
- metadata.gz: 7888e15b605b12cd5087271dea4982fa3b3a1a663bf97a9e93148982e94404e2b363928d5b0b66ab914d420bf9b37f0e7482fcba924db88f4184e98421e2dd19
7
- data.tar.gz: 70ab5c53cf4b2302dc940ca0d9664705437174e5d6d5a64acf6536f8d1cb2f418d4e97b18dc2f003dc81131d87cb4332b49da109d9bed6e0e846978ee9843386
6
+ metadata.gz: b337e7f6f0ebeece8666fed2347bd0f00a5e1b814312b25d9ec81c31021749f7e81d576e4c318f38cc2c81fcc8560bb9914a9a146cf77d5a8729ada51510f046
7
+ data.tar.gz: 5b3b358d75521a337ef0ee78af3b1d7929dd8cdcec194d2762332619f067558e9a04289686364d8627e50cf4963ec6bd192de72c1d78bd17c371388b972e56cd
data/README.md CHANGED
@@ -12,7 +12,7 @@ emjay implements the full set of standard MJML components:
12
12
 
13
13
  **Features:** CSS inlining via `<mj-style inline="inline">`, global default attributes, `mj-class`, `mj-html-attributes` with CSS selectors, custom fonts, responsive breakpoints, Outlook conditionals, `lang`/`dir` attributes.
14
14
 
15
- All components and features from the [MJML documentation](https://documentation.mjml.io/) should work as described. The [MJML templates and examples](https://mjml.io/templates) are a good starting point for building your own emails. Output is tested against the upstream [MJML 4 JavaScript implementation](https://github.com/mjmlio/mjml) using fixture-based comparison and backported behavioral tests. If you find a case where emjay produces different output from the reference MJML implementation, please [open an issue](https://github.com/julik/emjay/issues).
15
+ All components and features from the [MJML documentation](https://documentation.mjml.io/) should work as described. The [MJML templates and examples](https://mjml.io/templates) are a good starting point for building your own emails. Output is tested against the upstream [MJML 5.4.0 JavaScript implementation](https://github.com/mjmlio/mjml/tree/5.4.0) using fixture-based comparison and backported behavioral tests. If you find a case where emjay produces different output from the reference MJML implementation, please [open an issue](https://github.com/julik/emjay/issues).
16
16
 
17
17
  ## Installation
18
18
 
@@ -141,6 +141,10 @@ MJML partials work with the standard `render` helper. Use them to share common s
141
141
  </mj-section>
142
142
  ```
143
143
 
144
+ ### mj-include is not supported
145
+
146
+ emjay does not implement MJML's `<mj-include />` file-inclusion directive. In a Rails app, use standard ERB partials via `<%= render "shared/header" %>` (as shown above) — partials also accept locals, which `mj-include` does not.
147
+
144
148
  ### How it works
145
149
 
146
150
  The `.mjml` template handler is an ERB passthrough — it does not compile MJML itself. Instead, an ActionMailer interceptor (`Emjay::Rails::MailInterceptor`) compiles the assembled MJML to HTML after Rails has applied layouts and partials. This means `yield` in a layout receives MJML (not HTML), and the full document is compiled in one pass.
@@ -71,6 +71,8 @@ module Emjay
71
71
  def get_styles
72
72
  has_br = has_border_radius?
73
73
  has_ibr = has_inner_border_radius?
74
+ outlook_gutter = get_outlook_gutter_styles
75
+ mobile_gutter = get_mobile_gutter_styles
74
76
 
75
77
  table_style = {
76
78
  "background-color" => get_attribute("background-color"),
@@ -91,7 +93,8 @@ module Emjay
91
93
  "direction" => get_attribute("direction"),
92
94
  "display" => "inline-block",
93
95
  "vertical-align" => get_attribute("vertical-align"),
94
- "width" => get_mobile_width
96
+ "width" => get_mobile_width,
97
+ **mobile_gutter
95
98
  },
96
99
  table: {
97
100
  **(has_gutter? ? {
@@ -107,7 +110,8 @@ module Emjay
107
110
  },
108
111
  tdOutlook: {
109
112
  "vertical-align" => get_attribute("vertical-align"),
110
- "width" => get_width_as_pixel
113
+ "width" => get_width_as_pixel,
114
+ **outlook_gutter
111
115
  },
112
116
  gutter: {
113
117
  **table_style,
@@ -121,7 +125,9 @@ module Emjay
121
125
  end
122
126
 
123
127
  def render
124
- classes_name = "#{get_column_class} mj-outlook-group-fix"
128
+ classes_name = get_column_class.dup
129
+ classes_name += " #{get_desktop_gutter_class_name}" if has_column_gutter?
130
+ classes_name += " mj-outlook-group-fix"
125
131
  css_class = get_attribute("css-class")
126
132
  classes_name += " #{css_class}" if css_class
127
133
 
@@ -180,15 +186,20 @@ module Emjay
180
186
  def get_column_class
181
187
  add_media_query = @context[:add_media_query]
182
188
 
183
- parsed = get_parsed_width
184
- formatted = format_float(parsed[:parsed_width]).to_s.tr(".", "-")
189
+ parsed = has_column_gutter? ? get_desktop_width : get_parsed_width
190
+ normalized = (parsed[:unit] == "px") ? normalize_px_value(parsed[:parsed_width]) : parsed[:parsed_width]
191
+ formatted = format_float(normalized).to_s.tr(".", "-")
185
192
 
186
193
  class_name = case parsed[:unit]
187
194
  when "%" then "mj-column-per-#{formatted}"
188
195
  else "mj-column-px-#{formatted}"
189
196
  end
190
197
 
191
- add_media_query&.call(class_name, parsed)
198
+ add_media_query&.call(class_name, {unit: parsed[:unit], parsed_width: normalized})
199
+
200
+ if has_column_gutter? && !@context[:is_in_group]
201
+ add_media_query&.call(get_desktop_gutter_class_name, {padding: get_desktop_padding})
202
+ end
192
203
 
193
204
  class_name
194
205
  end
@@ -198,6 +209,138 @@ module Emjay
198
209
  (value == value.to_i) ? value.to_i : value
199
210
  end
200
211
 
212
+ def has_column_gutter?
213
+ gutter = @context[:gutter]
214
+ !gutter.nil? && gutter != ""
215
+ end
216
+
217
+ def get_normalized_gutter_value(target_unit)
218
+ gutter = @context[:gutter]
219
+ return 0 if gutter.nil? || gutter == ""
220
+
221
+ parsed = WidthParser.call(gutter, parse_float_to_int: false)
222
+ parent_width = @context[:container_width]
223
+
224
+ return parsed[:parsed_width] if parsed[:unit] == target_unit
225
+
226
+ if target_unit == "%" && parsed[:unit] == "px"
227
+ (parsed[:parsed_width] / parent_width.to_f) * 100
228
+ elsif target_unit == "px" && parsed[:unit] == "%"
229
+ (parent_width.to_f * parsed[:parsed_width]) / 100
230
+ else
231
+ parsed[:parsed_width]
232
+ end
233
+ end
234
+
235
+ def get_desktop_unit
236
+ get_parsed_width[:unit]
237
+ end
238
+
239
+ def get_desktop_width
240
+ parsed = get_parsed_width
241
+ return parsed unless has_column_gutter?
242
+
243
+ sibling = @props[:sibling] || 1
244
+ index = @props[:index] || 0
245
+ unit = parsed[:unit]
246
+ gutter = get_normalized_gutter_value(unit)
247
+ reduction = (gutter * (sibling - 1)) / sibling.to_f
248
+ reduced = [0, normalize_unit_value(parsed[:parsed_width] - reduction)].max
249
+
250
+ if unit == "px"
251
+ floor_width = reduced.floor
252
+ fractional = reduced - floor_width
253
+ extra = [0, [sibling, (sibling * fractional).round].min].max
254
+ {parsed_width: floor_width + ((index < extra) ? 1 : 0), unit: unit}
255
+ else
256
+ {parsed_width: reduced, unit: unit}
257
+ end
258
+ end
259
+
260
+ def get_desktop_gutter_class_name
261
+ unit = get_desktop_unit
262
+ gutter = get_normalized_gutter_value(unit)
263
+ unit_token = (unit == "%") ? "per" : unit
264
+ direction_token = (@context[:direction] == "rtl") ? "-rtl" : ""
265
+ normalized = (unit == "px") ? normalize_px_value(gutter) : gutter
266
+ gutter_token = normalize_unit_value(normalized).to_s.tr(".", "-")
267
+ sibling = @props[:sibling] || 1
268
+ index = @props[:index] || 0
269
+ "mj-column-gutter-#{sibling}-#{index + 1}-#{unit_token}-#{gutter_token}#{direction_token}"
270
+ end
271
+
272
+ def get_desktop_padding_values(unit)
273
+ first = @props[:first]
274
+ last = @props[:last]
275
+ sibling = @props[:sibling] || 1
276
+ direction = @context[:direction]
277
+ gutter = get_normalized_gutter_value(unit)
278
+ normalized = (unit == "px") ? normalize_px_value(gutter) : gutter
279
+ is_px = unit == "px"
280
+ half_leading = is_px ? (normalized / 2.0).ceil : normalized / 2.0
281
+ half_trailing = is_px ? (normalized / 2.0).floor : normalized / 2.0
282
+ is_rtl = direction == "rtl"
283
+
284
+ return {top: 0, right: 0, bottom: 0, left: 0} if sibling == 1
285
+
286
+ if is_rtl
287
+ {top: 0, right: first ? 0 : half_trailing, bottom: 0, left: last ? 0 : half_leading}
288
+ else
289
+ {top: 0, right: last ? 0 : half_leading, bottom: 0, left: first ? 0 : half_trailing}
290
+ end
291
+ end
292
+
293
+ def get_mobile_padding_values
294
+ first = @props[:first]
295
+ last = @props[:last]
296
+ gutter = get_normalized_gutter_value("%")
297
+ half = gutter / 2.0
298
+ {top: first ? 0 : half, right: 0, bottom: last ? 0 : half, left: 0}
299
+ end
300
+
301
+ def format_padding(top, right, bottom, left, unit)
302
+ if unit == "px"
303
+ "#{normalize_px_value(top)}px #{normalize_px_value(right)}px #{normalize_px_value(bottom)}px #{normalize_px_value(left)}px"
304
+ else
305
+ "#{normalize_unit_value(top)}#{unit} #{normalize_unit_value(right)}#{unit} #{normalize_unit_value(bottom)}#{unit} #{normalize_unit_value(left)}#{unit}"
306
+ end
307
+ end
308
+
309
+ def get_desktop_padding
310
+ unit = get_desktop_unit
311
+ v = get_desktop_padding_values(unit)
312
+ format_padding(v[:top], v[:right], v[:bottom], v[:left], unit)
313
+ end
314
+
315
+ def get_mobile_padding
316
+ v = get_mobile_padding_values
317
+ format_padding(v[:top], v[:right], v[:bottom], v[:left], "%")
318
+ end
319
+
320
+ def get_outlook_gutter_styles
321
+ return {} unless has_column_gutter?
322
+ {"padding" => get_desktop_padding_values("px").then { |v| format_padding(v[:top], v[:right], v[:bottom], v[:left], "px") }}
323
+ end
324
+
325
+ def get_mobile_gutter_styles
326
+ return {} unless has_column_gutter?
327
+
328
+ if @context[:is_in_group]
329
+ {"padding" => get_desktop_padding}
330
+ else
331
+ {"padding" => get_mobile_padding}
332
+ end
333
+ end
334
+
335
+ def normalize_unit_value(value)
336
+ rounded = value.to_f.round(6)
337
+ (rounded == rounded.to_i) ? rounded.to_i : rounded
338
+ end
339
+
340
+ def normalize_px_value(value)
341
+ value.to_f.round
342
+ end
343
+
201
344
  def has_border_radius?
202
345
  br = get_attribute("border-radius")
203
346
  br && !br.empty?
@@ -28,6 +28,7 @@ module Emjay
28
28
  "border-top" => "string",
29
29
  "direction" => "enum(ltr,rtl)",
30
30
  "full-width" => "enum(full-width,false,)",
31
+ "gutter" => "unit(px,%)",
31
32
  "padding" => "unit(px,%){1,4}",
32
33
  "padding-top" => "unit(px,%)",
33
34
  "padding-bottom" => "unit(px,%)",
@@ -54,7 +55,9 @@ module Emjay
54
55
  widths = get_box_widths
55
56
  @context.merge(
56
57
  container_width: "#{widths[:box]}px",
57
- gap: get_attribute("gap")
58
+ gap: get_attribute("gap"),
59
+ gutter: get_attribute("gutter"),
60
+ direction: get_attribute("direction")
58
61
  )
59
62
  end
60
63
 
@@ -48,6 +48,7 @@ module Emjay
48
48
  "align" => "left",
49
49
  "icon-position" => "left",
50
50
  "color" => "#000",
51
+ "border" => "0",
51
52
  "border-radius" => "3px",
52
53
  "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
53
54
  "font-size" => "13px",
@@ -66,6 +67,7 @@ module Emjay
66
67
  "icon-position" => "enum(left,right)",
67
68
  "background-color" => "color",
68
69
  "color" => "color",
70
+ "border" => "string",
69
71
  "border-radius" => "string",
70
72
  "font-family" => "string",
71
73
  "font-size" => "unit(px)",
@@ -123,6 +125,7 @@ module Emjay
123
125
  "width" => icon_size
124
126
  },
125
127
  img: {
128
+ "border" => get_attribute("border"),
126
129
  "border-radius" => get_attribute("border-radius"),
127
130
  "display" => "block"
128
131
  },
@@ -90,10 +90,13 @@ module Emjay
90
90
  global_data: global_data,
91
91
  container_width: "600px",
92
92
  add_media_query: ->(class_name, data) {
93
- pw = data[:parsed_width]
94
- pw = pw.to_i if pw == pw.to_i
95
- global_data.media_queries[class_name] =
93
+ global_data.media_queries[class_name] = if data.key?(:padding)
94
+ "{ padding:#{data[:padding]} !important; }"
95
+ else
96
+ pw = data[:parsed_width]
97
+ pw = pw.to_i if pw == pw.to_i
96
98
  "{ width:#{pw}#{data[:unit]} !important; max-width: #{pw}#{data[:unit]}; }"
99
+ end
97
100
  },
98
101
  add_head_style: ->(identifier, head_style_fn) {
99
102
  global_data.head_style[identifier] = head_style_fn
data/lib/emjay/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Emjay
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emjay
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-20 00:00:00.000000000 Z
10
+ date: 2026-08-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: nokogiri
@@ -108,7 +108,7 @@ licenses:
108
108
  metadata:
109
109
  homepage_uri: https://github.com/julik/emjay
110
110
  source_code_uri: https://github.com/julik/emjay
111
- changelog_uri: https://github.com/julik/emjay/commits/main
111
+ changelog_uri: https://github.com/julik/emjay/releases
112
112
  rdoc_options: []
113
113
  require_paths:
114
114
  - lib