emjay 0.1.2 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -1
  3. data/lib/emjay/components/body/mj_accordion.rb +0 -3
  4. data/lib/emjay/components/body/mj_accordion_element.rb +0 -3
  5. data/lib/emjay/components/body/mj_accordion_text.rb +0 -3
  6. data/lib/emjay/components/body/mj_accordion_title.rb +0 -3
  7. data/lib/emjay/components/body/mj_body.rb +0 -3
  8. data/lib/emjay/components/body/mj_button.rb +0 -3
  9. data/lib/emjay/components/body/mj_carousel.rb +0 -3
  10. data/lib/emjay/components/body/mj_carousel_image.rb +0 -3
  11. data/lib/emjay/components/body/mj_column.rb +149 -9
  12. data/lib/emjay/components/body/mj_divider.rb +0 -3
  13. data/lib/emjay/components/body/mj_group.rb +0 -3
  14. data/lib/emjay/components/body/mj_hero.rb +0 -3
  15. data/lib/emjay/components/body/mj_image.rb +0 -3
  16. data/lib/emjay/components/body/mj_navbar.rb +0 -3
  17. data/lib/emjay/components/body/mj_navbar_link.rb +0 -3
  18. data/lib/emjay/components/body/mj_raw.rb +0 -3
  19. data/lib/emjay/components/body/mj_section.rb +4 -4
  20. data/lib/emjay/components/body/mj_social.rb +0 -3
  21. data/lib/emjay/components/body/mj_social_element.rb +3 -3
  22. data/lib/emjay/components/body/mj_spacer.rb +0 -3
  23. data/lib/emjay/components/body/mj_table.rb +0 -3
  24. data/lib/emjay/components/body/mj_text.rb +0 -3
  25. data/lib/emjay/components/body/mj_wrapper.rb +0 -2
  26. data/lib/emjay/components/head/mj_attributes.rb +0 -3
  27. data/lib/emjay/components/head/mj_breakpoint.rb +0 -3
  28. data/lib/emjay/components/head/mj_font.rb +0 -3
  29. data/lib/emjay/components/head/mj_head.rb +0 -3
  30. data/lib/emjay/components/head/mj_html_attributes.rb +0 -3
  31. data/lib/emjay/components/head/mj_preview.rb +0 -3
  32. data/lib/emjay/components/head/mj_style.rb +0 -3
  33. data/lib/emjay/components/head/mj_title.rb +0 -3
  34. data/lib/emjay/railtie.rb +12 -2
  35. data/lib/emjay/registry.rb +66 -6
  36. data/lib/emjay/renderer.rb +10 -5
  37. data/lib/emjay/version.rb +1 -1
  38. data/lib/emjay.rb +1 -34
  39. metadata +3 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236a4d64e435fdf2e8616ef9212e7ce2cf4cf3d58c56e0fa5bb5b09783b807f7
4
- data.tar.gz: bbc45d8d1ce8f19fb991c7741e8ce819d38dc9aac9c42582ccd7105fffe914c7
3
+ metadata.gz: 44b1f5f2d6b785a000c044f8910beb812ab3b851fa5aa9a9950bdafc14842cd3
4
+ data.tar.gz: ff1959f81bd0468820a03c87a64396a63a14fcc4b90cc11465021519e704bd70
5
5
  SHA512:
6
- metadata.gz: 7888e15b605b12cd5087271dea4982fa3b3a1a663bf97a9e93148982e94404e2b363928d5b0b66ab914d420bf9b37f0e7482fcba924db88f4184e98421e2dd19
7
- data.tar.gz: 70ab5c53cf4b2302dc940ca0d9664705437174e5d6d5a64acf6536f8d1cb2f418d4e97b18dc2f003dc81131d87cb4332b49da109d9bed6e0e846978ee9843386
6
+ metadata.gz: 2b137f248b18a2d4128e58626158b70e607d74e600f2096df7c0d920cfd9b4411c04912dc1e5a8e244aec4e201718edc62396c6ecb65bf53185c28e8e56204d6
7
+ data.tar.gz: ba4fceec3711cb71600ecc8eaee52865a873a34aabbf952ac40487903024bb266062689b95cf12b285fab4072eb6b52dcc4fc38156b7eb12b60ebec154353922
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.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -94,6 +93,4 @@ module Emjay
94
93
  end
95
94
  end
96
95
  end
97
-
98
- Registry.register(Components::MjAccordion)
99
96
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
 
7
6
  module Emjay
@@ -122,6 +121,4 @@ module Emjay
122
121
  end
123
122
  end
124
123
  end
125
-
126
- Registry.register(Components::MjAccordionElement)
127
124
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -118,6 +117,4 @@ module Emjay
118
117
  end
119
118
  end
120
119
  end
121
-
122
- Registry.register(Components::MjAccordionText)
123
120
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
 
7
6
  module Emjay
@@ -166,6 +165,4 @@ module Emjay
166
165
  end
167
166
  end
168
167
  end
169
-
170
- Registry.register(Components::MjAccordionTitle)
171
168
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -65,6 +64,4 @@ module Emjay
65
64
  end
66
65
  end
67
66
  end
68
-
69
- Registry.register(Components::MjBody)
70
67
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -193,6 +192,4 @@ module Emjay
193
192
  end
194
193
  end
195
194
  end
196
-
197
- Registry.register(Components::MjButton)
198
195
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
  require_relative "../../helpers/gen_random_hex_string"
7
6
 
@@ -405,6 +404,4 @@ module Emjay
405
404
  end
406
405
  end
407
406
  end
408
-
409
- Registry.register(Components::MjCarousel)
410
407
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/suffix_css_classes"
6
5
 
7
6
  module Emjay
@@ -183,6 +182,4 @@ module Emjay
183
182
  end
184
183
  end
185
184
  end
186
-
187
- Registry.register(Components::MjCarouselImage)
188
185
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -71,6 +70,8 @@ module Emjay
71
70
  def get_styles
72
71
  has_br = has_border_radius?
73
72
  has_ibr = has_inner_border_radius?
73
+ outlook_gutter = get_outlook_gutter_styles
74
+ mobile_gutter = get_mobile_gutter_styles
74
75
 
75
76
  table_style = {
76
77
  "background-color" => get_attribute("background-color"),
@@ -91,7 +92,8 @@ module Emjay
91
92
  "direction" => get_attribute("direction"),
92
93
  "display" => "inline-block",
93
94
  "vertical-align" => get_attribute("vertical-align"),
94
- "width" => get_mobile_width
95
+ "width" => get_mobile_width,
96
+ **mobile_gutter
95
97
  },
96
98
  table: {
97
99
  **(has_gutter? ? {
@@ -107,7 +109,8 @@ module Emjay
107
109
  },
108
110
  tdOutlook: {
109
111
  "vertical-align" => get_attribute("vertical-align"),
110
- "width" => get_width_as_pixel
112
+ "width" => get_width_as_pixel,
113
+ **outlook_gutter
111
114
  },
112
115
  gutter: {
113
116
  **table_style,
@@ -121,7 +124,9 @@ module Emjay
121
124
  end
122
125
 
123
126
  def render
124
- classes_name = "#{get_column_class} mj-outlook-group-fix"
127
+ classes_name = get_column_class.dup
128
+ classes_name += " #{get_desktop_gutter_class_name}" if has_column_gutter?
129
+ classes_name += " mj-outlook-group-fix"
125
130
  css_class = get_attribute("css-class")
126
131
  classes_name += " #{css_class}" if css_class
127
132
 
@@ -180,15 +185,20 @@ module Emjay
180
185
  def get_column_class
181
186
  add_media_query = @context[:add_media_query]
182
187
 
183
- parsed = get_parsed_width
184
- formatted = format_float(parsed[:parsed_width]).to_s.tr(".", "-")
188
+ parsed = has_column_gutter? ? get_desktop_width : get_parsed_width
189
+ normalized = (parsed[:unit] == "px") ? normalize_px_value(parsed[:parsed_width]) : parsed[:parsed_width]
190
+ formatted = format_float(normalized).to_s.tr(".", "-")
185
191
 
186
192
  class_name = case parsed[:unit]
187
193
  when "%" then "mj-column-per-#{formatted}"
188
194
  else "mj-column-px-#{formatted}"
189
195
  end
190
196
 
191
- add_media_query&.call(class_name, parsed)
197
+ add_media_query&.call(class_name, {unit: parsed[:unit], parsed_width: normalized})
198
+
199
+ if has_column_gutter? && !@context[:is_in_group]
200
+ add_media_query&.call(get_desktop_gutter_class_name, {padding: get_desktop_padding})
201
+ end
192
202
 
193
203
  class_name
194
204
  end
@@ -198,6 +208,138 @@ module Emjay
198
208
  (value == value.to_i) ? value.to_i : value
199
209
  end
200
210
 
211
+ def has_column_gutter?
212
+ gutter = @context[:gutter]
213
+ !gutter.nil? && gutter != ""
214
+ end
215
+
216
+ def get_normalized_gutter_value(target_unit)
217
+ gutter = @context[:gutter]
218
+ return 0 if gutter.nil? || gutter == ""
219
+
220
+ parsed = WidthParser.call(gutter, parse_float_to_int: false)
221
+ parent_width = @context[:container_width]
222
+
223
+ return parsed[:parsed_width] if parsed[:unit] == target_unit
224
+
225
+ if target_unit == "%" && parsed[:unit] == "px"
226
+ (parsed[:parsed_width] / parent_width.to_f) * 100
227
+ elsif target_unit == "px" && parsed[:unit] == "%"
228
+ (parent_width.to_f * parsed[:parsed_width]) / 100
229
+ else
230
+ parsed[:parsed_width]
231
+ end
232
+ end
233
+
234
+ def get_desktop_unit
235
+ get_parsed_width[:unit]
236
+ end
237
+
238
+ def get_desktop_width
239
+ parsed = get_parsed_width
240
+ return parsed unless has_column_gutter?
241
+
242
+ sibling = @props[:sibling] || 1
243
+ index = @props[:index] || 0
244
+ unit = parsed[:unit]
245
+ gutter = get_normalized_gutter_value(unit)
246
+ reduction = (gutter * (sibling - 1)) / sibling.to_f
247
+ reduced = [0, normalize_unit_value(parsed[:parsed_width] - reduction)].max
248
+
249
+ if unit == "px"
250
+ floor_width = reduced.floor
251
+ fractional = reduced - floor_width
252
+ extra = (sibling * fractional).round.clamp(0, sibling)
253
+ {parsed_width: floor_width + ((index < extra) ? 1 : 0), unit: unit}
254
+ else
255
+ {parsed_width: reduced, unit: unit}
256
+ end
257
+ end
258
+
259
+ def get_desktop_gutter_class_name
260
+ unit = get_desktop_unit
261
+ gutter = get_normalized_gutter_value(unit)
262
+ unit_token = (unit == "%") ? "per" : unit
263
+ direction_token = (@context[:direction] == "rtl") ? "-rtl" : ""
264
+ normalized = (unit == "px") ? normalize_px_value(gutter) : gutter
265
+ gutter_token = normalize_unit_value(normalized).to_s.tr(".", "-")
266
+ sibling = @props[:sibling] || 1
267
+ index = @props[:index] || 0
268
+ "mj-column-gutter-#{sibling}-#{index + 1}-#{unit_token}-#{gutter_token}#{direction_token}"
269
+ end
270
+
271
+ def get_desktop_padding_values(unit)
272
+ first = @props[:first]
273
+ last = @props[:last]
274
+ sibling = @props[:sibling] || 1
275
+ direction = @context[:direction]
276
+ gutter = get_normalized_gutter_value(unit)
277
+ normalized = (unit == "px") ? normalize_px_value(gutter) : gutter
278
+ is_px = unit == "px"
279
+ half_leading = is_px ? (normalized / 2.0).ceil : normalized / 2.0
280
+ half_trailing = is_px ? (normalized / 2.0).floor : normalized / 2.0
281
+ is_rtl = direction == "rtl"
282
+
283
+ return {top: 0, right: 0, bottom: 0, left: 0} if sibling == 1
284
+
285
+ if is_rtl
286
+ {top: 0, right: first ? 0 : half_trailing, bottom: 0, left: last ? 0 : half_leading}
287
+ else
288
+ {top: 0, right: last ? 0 : half_leading, bottom: 0, left: first ? 0 : half_trailing}
289
+ end
290
+ end
291
+
292
+ def get_mobile_padding_values
293
+ first = @props[:first]
294
+ last = @props[:last]
295
+ gutter = get_normalized_gutter_value("%")
296
+ half = gutter / 2.0
297
+ {top: first ? 0 : half, right: 0, bottom: last ? 0 : half, left: 0}
298
+ end
299
+
300
+ def format_padding(top, right, bottom, left, unit)
301
+ if unit == "px"
302
+ "#{normalize_px_value(top)}px #{normalize_px_value(right)}px #{normalize_px_value(bottom)}px #{normalize_px_value(left)}px"
303
+ else
304
+ "#{normalize_unit_value(top)}#{unit} #{normalize_unit_value(right)}#{unit} #{normalize_unit_value(bottom)}#{unit} #{normalize_unit_value(left)}#{unit}"
305
+ end
306
+ end
307
+
308
+ def get_desktop_padding
309
+ unit = get_desktop_unit
310
+ v = get_desktop_padding_values(unit)
311
+ format_padding(v[:top], v[:right], v[:bottom], v[:left], unit)
312
+ end
313
+
314
+ def get_mobile_padding
315
+ v = get_mobile_padding_values
316
+ format_padding(v[:top], v[:right], v[:bottom], v[:left], "%")
317
+ end
318
+
319
+ def get_outlook_gutter_styles
320
+ return {} unless has_column_gutter?
321
+ {"padding" => get_desktop_padding_values("px").then { |v| format_padding(v[:top], v[:right], v[:bottom], v[:left], "px") }}
322
+ end
323
+
324
+ def get_mobile_gutter_styles
325
+ return {} unless has_column_gutter?
326
+
327
+ if @context[:is_in_group]
328
+ {"padding" => get_desktop_padding}
329
+ else
330
+ {"padding" => get_mobile_padding}
331
+ end
332
+ end
333
+
334
+ def normalize_unit_value(value)
335
+ rounded = value.to_f.round(6)
336
+ (rounded == rounded.to_i) ? rounded.to_i : rounded
337
+ end
338
+
339
+ def normalize_px_value(value)
340
+ value.to_f.round
341
+ end
342
+
201
343
  def has_border_radius?
202
344
  br = get_attribute("border-radius")
203
345
  br && !br.empty?
@@ -282,6 +424,4 @@ module Emjay
282
424
  end
283
425
  end
284
426
  end
285
-
286
- Registry.register(Components::MjColumn)
287
427
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -115,6 +114,4 @@ module Emjay
115
114
  end
116
115
  end
117
116
  end
118
-
119
- Registry.register(Components::MjDivider)
120
117
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -191,6 +190,4 @@ module Emjay
191
190
  end
192
191
  end
193
192
  end
194
-
195
- Registry.register(Components::MjGroup)
196
193
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -377,6 +376,4 @@ module Emjay
377
376
  end
378
377
  end
379
378
  end
380
-
381
- Registry.register(Components::MjHero)
382
379
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
  require_relative "../../helpers/make_lower_breakpoint"
7
6
 
@@ -183,6 +182,4 @@ module Emjay
183
182
  end
184
183
  end
185
184
  end
186
-
187
- Registry.register(Components::MjImage)
188
185
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
  require_relative "../../helpers/make_lower_breakpoint"
7
6
  require_relative "../../helpers/gen_random_hex_string"
@@ -182,6 +181,4 @@ module Emjay
182
181
  end
183
182
  end
184
183
  end
185
-
186
- Registry.register(Components::MjNavbar)
187
184
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
  require_relative "../../helpers/suffix_css_classes"
7
6
 
@@ -124,6 +123,4 @@ module Emjay
124
123
  end
125
124
  end
126
125
  end
127
-
128
- Registry.register(Components::MjNavbarLink)
129
126
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -29,6 +28,4 @@ module Emjay
29
28
  end
30
29
  end
31
30
  end
32
-
33
- Registry.register(Components::MjRaw)
34
31
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/suffix_css_classes"
6
5
 
7
6
  module Emjay
@@ -28,6 +27,7 @@ module Emjay
28
27
  "border-top" => "string",
29
28
  "direction" => "enum(ltr,rtl)",
30
29
  "full-width" => "enum(full-width,false,)",
30
+ "gutter" => "unit(px,%)",
31
31
  "padding" => "unit(px,%){1,4}",
32
32
  "padding-top" => "unit(px,%)",
33
33
  "padding-bottom" => "unit(px,%)",
@@ -54,7 +54,9 @@ module Emjay
54
54
  widths = get_box_widths
55
55
  @context.merge(
56
56
  container_width: "#{widths[:box]}px",
57
- gap: get_attribute("gap")
57
+ gap: get_attribute("gap"),
58
+ gutter: get_attribute("gutter"),
59
+ direction: get_attribute("direction")
58
60
  )
59
61
  end
60
62
 
@@ -437,6 +439,4 @@ module Emjay
437
439
  end
438
440
  end
439
441
  end
440
-
441
- Registry.register(Components::MjSection)
442
442
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -169,6 +168,4 @@ module Emjay
169
168
  end
170
169
  end
171
170
  end
172
-
173
- Registry.register(Components::MjSocial)
174
171
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -48,6 +47,7 @@ module Emjay
48
47
  "align" => "left",
49
48
  "icon-position" => "left",
50
49
  "color" => "#000",
50
+ "border" => "0",
51
51
  "border-radius" => "3px",
52
52
  "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
53
53
  "font-size" => "13px",
@@ -66,6 +66,7 @@ module Emjay
66
66
  "icon-position" => "enum(left,right)",
67
67
  "background-color" => "color",
68
68
  "color" => "color",
69
+ "border" => "string",
69
70
  "border-radius" => "string",
70
71
  "font-family" => "string",
71
72
  "font-size" => "unit(px)",
@@ -123,6 +124,7 @@ module Emjay
123
124
  "width" => icon_size
124
125
  },
125
126
  img: {
127
+ "border" => get_attribute("border"),
126
128
  "border-radius" => get_attribute("border-radius"),
127
129
  "display" => "block"
128
130
  },
@@ -267,6 +269,4 @@ module Emjay
267
269
  end
268
270
  end
269
271
  end
270
-
271
- Registry.register(Components::MjSocialElement)
272
272
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -52,6 +51,4 @@ module Emjay
52
51
  end
53
52
  end
54
53
  end
55
-
56
- Registry.register(Components::MjSpacer)
57
54
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/width_parser"
6
5
 
7
6
  module Emjay
@@ -108,6 +107,4 @@ module Emjay
108
107
  end
109
108
  end
110
109
  end
111
-
112
- Registry.register(Components::MjTable)
113
110
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../body_component"
4
- require_relative "../../registry"
5
4
  require_relative "../../helpers/conditional_tag"
6
5
 
7
6
  module Emjay
@@ -95,6 +94,4 @@ module Emjay
95
94
  end
96
95
  end
97
96
  end
98
-
99
- Registry.register(Components::MjText)
100
97
  end
@@ -51,6 +51,4 @@ module Emjay
51
51
  end
52
52
  end
53
53
  end
54
-
55
- Registry.register(Components::MjWrapper)
56
54
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -33,6 +32,4 @@ module Emjay
33
32
  end
34
33
  end
35
34
  end
36
-
37
- Registry.register(Components::MjAttributes)
38
35
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -23,6 +22,4 @@ module Emjay
23
22
  end
24
23
  end
25
24
  end
26
-
27
- Registry.register(Components::MjBreakpoint)
28
25
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -19,6 +18,4 @@ module Emjay
19
18
  end
20
19
  end
21
20
  end
22
-
23
- Registry.register(Components::MjFont)
24
21
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -15,6 +14,4 @@ module Emjay
15
14
  end
16
15
  end
17
16
  end
18
-
19
- Registry.register(Components::MjHead)
20
17
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -28,6 +27,4 @@ module Emjay
28
27
  end
29
28
  end
30
29
  end
31
-
32
- Registry.register(Components::MjHtmlAttributes)
33
30
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -19,6 +18,4 @@ module Emjay
19
18
  end
20
19
  end
21
20
  end
22
-
23
- Registry.register(Components::MjPreview)
24
21
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -29,6 +28,4 @@ module Emjay
29
28
  end
30
29
  end
31
30
  end
32
-
33
- Registry.register(Components::MjStyle)
34
31
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../head_component"
4
- require_relative "../../registry"
5
4
 
6
5
  module Emjay
7
6
  module Components
@@ -19,6 +18,4 @@ module Emjay
19
18
  end
20
19
  end
21
20
  end
22
-
23
- Registry.register(Components::MjTitle)
24
21
  end
data/lib/emjay/railtie.rb CHANGED
@@ -2,11 +2,21 @@
2
2
 
3
3
  module Emjay
4
4
  class Railtie < Rails::Railtie
5
- initializer "emjay.register_template_handler_and_interceptor" do
6
- ActiveSupport.on_load(:action_mailer) do
5
+ # Register the template handler on :action_view load, not :action_mailer.
6
+ # ActionView's FileSystemResolver builds its PathParser regex from the
7
+ # registered extensions on first template lookup; if ActionView loads
8
+ # before ActionMailer (e.g. a controller renders a view first), the
9
+ # regex is cached without :mjml and .html.mjml templates stay invisible
10
+ # for the rest of the process.
11
+ initializer "emjay.register_template_handler" do
12
+ ActiveSupport.on_load(:action_view) do
7
13
  require "emjay/rails/template_handler"
8
14
  ActionView::Template.register_template_handler(:mjml, Emjay::Rails::TemplateHandler)
15
+ end
16
+ end
9
17
 
18
+ initializer "emjay.register_mail_interceptor" do
19
+ ActiveSupport.on_load(:action_mailer) do
10
20
  require "emjay/rails/mail_interceptor"
11
21
  interceptor = Emjay::Rails::MailInterceptor
12
22
  ActionMailer::Base.register_interceptor(interceptor)
@@ -1,19 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Emjay
4
+ # Lazy autoloader for MJML components. Scans `lib/emjay/components/**/*.rb`
5
+ # once at load time, then derives both the MJML tag name and the Ruby class
6
+ # from each filename:
7
+ #
8
+ # components/body/mj_button.rb → tag "mj-button" → Emjay::Components::MjButton
9
+ #
10
+ # Files are `require`d and constants resolved on first `find`.
4
11
  module Registry
5
- @components = {}
12
+ class UnknownComponent < StandardError; end
6
13
 
7
- def self.register(component_class)
8
- @components[component_class.component_name] = component_class
9
- end
14
+ COMPONENTS_ROOT = File.expand_path("components", __dir__)
15
+
16
+ PATHS = Dir.glob("{head,body}/*.rb", base: COMPONENTS_ROOT).sort.each_with_object({}) do |rel, h|
17
+ basename = File.basename(rel, ".rb") # e.g. "mj_html_attributes"
18
+ tag = basename.tr("_", "-") # → "mj-html-attributes"
19
+ h[tag] = File.join(COMPONENTS_ROOT, rel)
20
+ end.freeze
21
+
22
+ @loaded = {}
23
+ @mutex = Mutex.new
10
24
 
11
25
  def self.find(tag_name)
12
- @components[tag_name]
26
+ @loaded[tag_name] ||= load_component(tag_name)
13
27
  end
14
28
 
15
29
  def self.components
16
- @components
30
+ @components ||= LazyComponents.new
31
+ end
32
+
33
+ # Kept for backwards compatibility with callers that eagerly register.
34
+ def self.register(component_class)
35
+ @loaded[component_class.component_name] = component_class
36
+ end
37
+
38
+ def self.load_component(tag_name)
39
+ path = PATHS[tag_name] or return nil
40
+ @mutex.synchronize do
41
+ return @loaded[tag_name] if @loaded[tag_name]
42
+ require path
43
+ const_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
44
+ Emjay::Components.const_get(const_name)
45
+ end
46
+ end
47
+
48
+ # The renderer treats `Registry.components` as a hash keyed by tag name.
49
+ # This proxy resolves each lookup through `Registry.find` so components
50
+ # only load when actually referenced.
51
+ class LazyComponents
52
+ include Enumerable
53
+
54
+ def [](tag_name)
55
+ component = Registry.find(tag_name)
56
+ # We only reach here on render paths (never during parsing, which uses
57
+ # Registry.find directly and tolerates nil). If a tag looks like MJML
58
+ # but resolves to nothing, it's almost certainly a typo like
59
+ # `<mj-nonesuch>` — surface it instead of silently rendering blank.
60
+ if component.nil? && tag_name.to_s.start_with?("mj-")
61
+ raise UnknownComponent, "Unknown MJML component #{tag_name.inspect}"
62
+ end
63
+ component
64
+ end
65
+
66
+ def each
67
+ PATHS.each_key { |tag| yield tag, Registry.find(tag) }
68
+ end
69
+
70
+ def keys
71
+ PATHS.keys
72
+ end
73
+
74
+ def values
75
+ PATHS.each_key.map { |tag| Registry.find(tag) }
76
+ end
17
77
  end
18
78
  end
19
79
  end
@@ -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
@@ -182,9 +185,11 @@ module Emjay
182
185
 
183
186
  children = element.children.select(&:element?).map { |child| nokogiri_to_hash(child) }
184
187
 
185
- # For ending-tag components, content is the inner HTML
188
+ # For ending-tag components, content is the inner HTML. Only MJML tags
189
+ # can be components — plain HTML (e.g. <a>, <b> nested in mj-text) never
190
+ # is, so don't waste a Registry lookup on it.
186
191
  tag_name = element.name
187
- component_class = Registry.find(tag_name)
192
+ component_class = tag_name.start_with?("mj-") ? Registry.find(tag_name) : nil
188
193
  content = if component_class&.ending_tag?
189
194
  inner = element.inner_html.strip
190
195
  # Restore escaped bare < characters (from template syntax in mj-raw etc.)
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.1"
5
5
  end
data/lib/emjay.rb CHANGED
@@ -22,40 +22,7 @@ require_relative "emjay/helpers/styles"
22
22
  require_relative "emjay/helpers/make_lower_breakpoint"
23
23
  require_relative "emjay/helpers/gen_random_hex_string"
24
24
 
25
- # Head components
26
- require_relative "emjay/components/head/mj_head"
27
- require_relative "emjay/components/head/mj_attributes"
28
- require_relative "emjay/components/head/mj_style"
29
- require_relative "emjay/components/head/mj_font"
30
- require_relative "emjay/components/head/mj_title"
31
- require_relative "emjay/components/head/mj_preview"
32
- require_relative "emjay/components/head/mj_breakpoint"
33
- require_relative "emjay/components/head/mj_html_attributes"
34
-
35
- # Body components
36
- require_relative "emjay/components/body/mj_body"
37
- require_relative "emjay/components/body/mj_section"
38
- require_relative "emjay/components/body/mj_column"
39
- require_relative "emjay/components/body/mj_text"
40
- require_relative "emjay/components/body/mj_wrapper"
41
- require_relative "emjay/components/body/mj_group"
42
- require_relative "emjay/components/body/mj_image"
43
- require_relative "emjay/components/body/mj_button"
44
- require_relative "emjay/components/body/mj_divider"
45
- require_relative "emjay/components/body/mj_spacer"
46
- require_relative "emjay/components/body/mj_table"
47
- require_relative "emjay/components/body/mj_raw"
48
- require_relative "emjay/components/body/mj_hero"
49
- require_relative "emjay/components/body/mj_social"
50
- require_relative "emjay/components/body/mj_social_element"
51
- require_relative "emjay/components/body/mj_navbar"
52
- require_relative "emjay/components/body/mj_navbar_link"
53
- require_relative "emjay/components/body/mj_accordion"
54
- require_relative "emjay/components/body/mj_accordion_element"
55
- require_relative "emjay/components/body/mj_accordion_title"
56
- require_relative "emjay/components/body/mj_accordion_text"
57
- require_relative "emjay/components/body/mj_carousel"
58
- require_relative "emjay/components/body/mj_carousel_image"
25
+ # Components are autoloaded on demand via Emjay::Registry — see registry.rb.
59
26
 
60
27
  module Emjay
61
28
  def self.to_html(mjml_string, options = {})
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.1
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-27 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