emjay 0.1.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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +158 -0
  4. data/lib/emjay/body_component.rb +142 -0
  5. data/lib/emjay/component.rb +61 -0
  6. data/lib/emjay/components/body/mj_accordion.rb +99 -0
  7. data/lib/emjay/components/body/mj_accordion_element.rb +127 -0
  8. data/lib/emjay/components/body/mj_accordion_text.rb +123 -0
  9. data/lib/emjay/components/body/mj_accordion_title.rb +171 -0
  10. data/lib/emjay/components/body/mj_body.rb +70 -0
  11. data/lib/emjay/components/body/mj_button.rb +198 -0
  12. data/lib/emjay/components/body/mj_carousel.rb +410 -0
  13. data/lib/emjay/components/body/mj_carousel_image.rb +188 -0
  14. data/lib/emjay/components/body/mj_column.rb +287 -0
  15. data/lib/emjay/components/body/mj_divider.rb +120 -0
  16. data/lib/emjay/components/body/mj_group.rb +196 -0
  17. data/lib/emjay/components/body/mj_hero.rb +382 -0
  18. data/lib/emjay/components/body/mj_image.rb +188 -0
  19. data/lib/emjay/components/body/mj_navbar.rb +187 -0
  20. data/lib/emjay/components/body/mj_navbar_link.rb +129 -0
  21. data/lib/emjay/components/body/mj_raw.rb +34 -0
  22. data/lib/emjay/components/body/mj_section.rb +442 -0
  23. data/lib/emjay/components/body/mj_social.rb +174 -0
  24. data/lib/emjay/components/body/mj_social_element.rb +272 -0
  25. data/lib/emjay/components/body/mj_spacer.rb +57 -0
  26. data/lib/emjay/components/body/mj_table.rb +113 -0
  27. data/lib/emjay/components/body/mj_text.rb +100 -0
  28. data/lib/emjay/components/body/mj_wrapper.rb +56 -0
  29. data/lib/emjay/components/head/mj_attributes.rb +38 -0
  30. data/lib/emjay/components/head/mj_breakpoint.rb +28 -0
  31. data/lib/emjay/components/head/mj_font.rb +24 -0
  32. data/lib/emjay/components/head/mj_head.rb +20 -0
  33. data/lib/emjay/components/head/mj_html_attributes.rb +33 -0
  34. data/lib/emjay/components/head/mj_preview.rb +24 -0
  35. data/lib/emjay/components/head/mj_style.rb +34 -0
  36. data/lib/emjay/components/head/mj_title.rb +24 -0
  37. data/lib/emjay/global_data.rb +64 -0
  38. data/lib/emjay/head_component.rb +37 -0
  39. data/lib/emjay/helpers/conditional_tag.rb +24 -0
  40. data/lib/emjay/helpers/fonts.rb +34 -0
  41. data/lib/emjay/helpers/gen_random_hex_string.rb +9 -0
  42. data/lib/emjay/helpers/make_lower_breakpoint.rb +17 -0
  43. data/lib/emjay/helpers/media_queries.rb +47 -0
  44. data/lib/emjay/helpers/merge_outlook_conditionals.rb +11 -0
  45. data/lib/emjay/helpers/minify_outlook_conditionals.rb +18 -0
  46. data/lib/emjay/helpers/shorthand_parser.rb +33 -0
  47. data/lib/emjay/helpers/styles.rb +34 -0
  48. data/lib/emjay/helpers/suffix_css_classes.rb +12 -0
  49. data/lib/emjay/helpers/width_parser.rb +26 -0
  50. data/lib/emjay/rails/mail_interceptor.rb +37 -0
  51. data/lib/emjay/rails/template_handler.rb +16 -0
  52. data/lib/emjay/railtie.rb +21 -0
  53. data/lib/emjay/registry.rb +19 -0
  54. data/lib/emjay/renderer.rb +302 -0
  55. data/lib/emjay/skeleton.rb +80 -0
  56. data/lib/emjay/version.rb +5 -0
  57. data/lib/emjay.rb +66 -0
  58. data/llms.txt +130 -0
  59. metadata +129 -0
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require_relative "global_data"
5
+ require_relative "registry"
6
+ require_relative "skeleton"
7
+ require_relative "helpers/merge_outlook_conditionals"
8
+ require_relative "helpers/minify_outlook_conditionals"
9
+
10
+ module Emjay
11
+ module Renderer
12
+ # HTML void elements that need self-closing conversion for XML parsing.
13
+ VOID_ELEMENTS_RE = /(<(?:br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)(?:\s[^>]*)?)>/i
14
+
15
+ def self.call(mjml_string, options = {})
16
+ # 1. Pre-process and parse MJML string with Nokogiri::XML.
17
+ # Two fixups are needed to bridge MJML (which embeds HTML content) and XML:
18
+ # a) Convert HTML void elements to self-closing (e.g. <br> → <br/>)
19
+ # so XML parsing doesn't treat them as unclosed tags
20
+ # b) Escape bare < characters from template syntax in mj-raw
21
+ # (e.g. { if item < 5 }) so they don't break XML parsing
22
+ preprocessed = mjml_string.gsub(VOID_ELEMENTS_RE, '\1/>')
23
+ preprocessed = preprocessed.gsub(/<(?![a-zA-Z\/!?])/, RAW_LT_PLACEHOLDER)
24
+ doc = Nokogiri::XML(preprocessed)
25
+ mjml_root = doc.at_xpath("//mjml") || doc.root
26
+
27
+ # 2. Build GlobalData
28
+ global_data = GlobalData.new(options)
29
+
30
+ # Extract lang/dir from root element
31
+ if mjml_root
32
+ owa = mjml_root["owa"]
33
+ global_data.force_owa_desktop = (owa == "desktop") if owa
34
+ global_data.lang = mjml_root["lang"] || "und"
35
+ global_data.dir = mjml_root["dir"] || "auto"
36
+ end
37
+
38
+ # 3. Find mj-head and mj-body
39
+ mj_head_el = mjml_root&.at_xpath("mj-head")
40
+ mj_body_el = mjml_root&.at_xpath("mj-body")
41
+
42
+ # Convert Nokogiri elements to our internal data structures (matching JS parsed format)
43
+ components = Registry.components
44
+
45
+ # Processing function (matches JS processing)
46
+ apply_attributes = method(:apply_attributes_fn).curry[global_data]
47
+
48
+ processing = lambda { |node, ctx|
49
+ return unless node
50
+ node = apply_attributes.call(node) if node.is_a?(Hash)
51
+
52
+ component_class = ctx[:components]&.[](node[:tag_name])
53
+ return unless component_class
54
+
55
+ component = component_class.new(
56
+ attributes: node[:attributes] || {},
57
+ children: node[:children] || [],
58
+ content: node[:content] || "",
59
+ context: ctx,
60
+ global_attributes: node[:global_attributes] || {},
61
+ raw_attrs: node[:raw_attrs] || {}
62
+ )
63
+
64
+ if component.respond_to?(:handler)
65
+ return component.handler
66
+ end
67
+
68
+ if component.respond_to?(:render)
69
+ component.render
70
+ end
71
+ }
72
+
73
+ # Head helpers context
74
+ head_helpers = {
75
+ components: components,
76
+ global_data: global_data,
77
+ add: ->(attr, *params) { global_data.add(attr, *params) }
78
+ }
79
+
80
+ # Process head
81
+ if mj_head_el
82
+ head_node = nokogiri_to_hash(mj_head_el)
83
+ head_result = processing.call(head_node, head_helpers)
84
+ global_data.head_raw = head_result if head_result.is_a?(Array)
85
+ end
86
+
87
+ # Body helpers context
88
+ body_helpers = {
89
+ components: components,
90
+ global_data: global_data,
91
+ container_width: "600px",
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] =
96
+ "{ width:#{pw}#{data[:unit]} !important; max-width: #{pw}#{data[:unit]}; }"
97
+ },
98
+ add_head_style: ->(identifier, head_style_fn) {
99
+ global_data.head_style[identifier] = head_style_fn
100
+ },
101
+ add_component_head_style: ->(head_style_fn) {
102
+ global_data.components_head_style << head_style_fn
103
+ },
104
+ processing: ->(node, ctx) {
105
+ node = apply_attributes.call(node) if node.is_a?(Hash)
106
+ processing.call(node, ctx)
107
+ }
108
+ }
109
+
110
+ # Process body
111
+ content = nil
112
+ if mj_body_el
113
+ body_node = nokogiri_to_hash(mj_body_el)
114
+ body_node = apply_attributes.call(body_node)
115
+ content = processing.call(body_node, body_helpers)
116
+ end
117
+
118
+ raise "Malformed MJML. Check that your structure is correct and enclosed in <mjml> tags." unless content
119
+
120
+ # Minify outlook conditionals
121
+ content = MinifyOutlookConditionals.call(content)
122
+
123
+ # Handle mj-raw outside body (before-doctype)
124
+ mjml_root&.xpath("mj-raw")&.each do |raw_el|
125
+ if raw_el["position"] == "file-start"
126
+ global_data.before_doctype += raw_el.inner_html.gsub(RAW_LT_PLACEHOLDER, "<")
127
+ end
128
+ end
129
+
130
+ # Apply html_attributes via Nokogiri CSS selectors.
131
+ # Use XML fragment for parsing (preserves table structure, unlike HTML5
132
+ # which foster-parents text out of tables). Protect bare < from template
133
+ # syntax (mj-raw) with placeholders so XML parsing succeeds. Use custom
134
+ # serializer so text nodes (including > in templates) pass through raw.
135
+ unless global_data.html_attributes.empty?
136
+ escaped = content.gsub(/<(?![a-zA-Z\/!?])/, RAW_LT_PLACEHOLDER)
137
+ content_doc = Nokogiri::XML.fragment(escaped)
138
+ global_data.html_attributes.each do |selector, data|
139
+ content_doc.css(selector).each do |node|
140
+ data.each do |attr_name, value|
141
+ node[attr_name] = value || ""
142
+ end
143
+ end
144
+ end
145
+ content = serialize_fragment(content_doc).gsub(RAW_LT_PLACEHOLDER, "<")
146
+ end
147
+
148
+ # Wrap in skeleton
149
+ content = Skeleton.call(
150
+ before_doctype: global_data.before_doctype,
151
+ breakpoint: global_data.breakpoint,
152
+ content: content,
153
+ fonts: global_data.fonts,
154
+ media_queries: global_data.media_queries,
155
+ head_style: global_data.head_style,
156
+ components_head_style: global_data.components_head_style,
157
+ head_raw: global_data.head_raw.is_a?(Array) ? global_data.head_raw : [],
158
+ title: global_data.title,
159
+ style: global_data.style,
160
+ force_owa_desktop: global_data.force_owa_desktop,
161
+ printer_support: options[:printer_support] || false,
162
+ inline_style: global_data.inline_style,
163
+ lang: global_data.lang,
164
+ dir: global_data.dir
165
+ )
166
+
167
+ # CSS inlining for <mj-style inline="inline">
168
+ if global_data.inline_style.any?
169
+ content = inline_css(content, global_data.inline_style.join("\n"))
170
+ end
171
+
172
+ # Merge outlook conditionals
173
+ MergeOutlookConditionals.call(content)
174
+ end
175
+
176
+ # Converts a Nokogiri element to a hash matching the JS parsed format
177
+ def self.nokogiri_to_hash(element)
178
+ return nil unless element
179
+
180
+ attrs = {}
181
+ element.attributes.each { |name, attr| attrs[name] = attr.value }
182
+
183
+ children = element.children.select(&:element?).map { |child| nokogiri_to_hash(child) }
184
+
185
+ # For ending-tag components, content is the inner HTML
186
+ tag_name = element.name
187
+ component_class = Registry.find(tag_name)
188
+ content = if component_class&.ending_tag?
189
+ inner = element.inner_html.strip
190
+ # Restore escaped bare < characters (from template syntax in mj-raw etc.)
191
+ inner.gsub(RAW_LT_PLACEHOLDER, "<")
192
+ else
193
+ element.children.select(&:text?).map(&:text).join.strip
194
+ end
195
+
196
+ {
197
+ tag_name: tag_name,
198
+ attributes: attrs,
199
+ children: children,
200
+ content: content
201
+ }
202
+ end
203
+
204
+ # Port of the JS applyAttributes function — merges global defaults, classes, etc.
205
+ def self.apply_attributes_fn(global_data, node, parent_mj_class = "")
206
+ return node unless node.is_a?(Hash)
207
+
208
+ attrs = node[:attributes] || {}
209
+ tag_name = node[:tag_name]
210
+
211
+ # Resolve mj-class
212
+ classes = (attrs["mj-class"] || "").split(" ")
213
+ attributes_classes = classes.each_with_object({}) do |cls, acc|
214
+ mj_class_values = global_data.classes[cls] || {}
215
+ if acc["css-class"] && mj_class_values["css-class"]
216
+ acc.merge!(mj_class_values)
217
+ acc["css-class"] = "#{acc["css-class"]} #{mj_class_values["css-class"]}"
218
+ else
219
+ acc.merge!(mj_class_values)
220
+ end
221
+ end
222
+
223
+ # Default attributes for parent mj-class
224
+ default_attrs_for_classes = parent_mj_class.split(" ").each_with_object({}) do |cls, acc|
225
+ class_defaults = global_data.classes_default.dig(cls, tag_name)
226
+ acc.merge!(class_defaults) if class_defaults
227
+ end
228
+
229
+ next_parent_mj_class = attrs["mj-class"] || parent_mj_class
230
+
231
+ # Merge: global defaults -> class attrs -> class default attrs -> element attrs (minus mj-class)
232
+ element_attrs = attrs.except("mj-class")
233
+ merged_attrs = (global_data.default_attributes[tag_name] || {})
234
+ .merge(attributes_classes)
235
+ .merge(default_attrs_for_classes)
236
+ .merge(element_attrs)
237
+
238
+ # Recurse into children
239
+ merged_children = (node[:children] || []).map { |child|
240
+ apply_attributes_fn(global_data, child, next_parent_mj_class)
241
+ }
242
+
243
+ node.merge(
244
+ attributes: merged_attrs,
245
+ raw_attrs: element_attrs,
246
+ global_attributes: global_data.default_attributes["mj-all"] || {},
247
+ children: merged_children
248
+ )
249
+ end
250
+
251
+ # Inlines extra CSS rules into element style attributes using premailer.
252
+ # Mirrors the JS behavior: only the extra CSS (from <mj-style inline="inline">)
253
+ # is inlined; existing <style> tags in the document are preserved as-is.
254
+ def self.inline_css(html, extra_css)
255
+ require "premailer"
256
+
257
+ premailer = Premailer.new(
258
+ html,
259
+ with_html_string: true,
260
+ css_string: extra_css,
261
+ include_style_tags: false,
262
+ include_link_tags: false,
263
+ preserve_styles: true,
264
+ adapter: :nokogiri
265
+ )
266
+ premailer.to_inline_css
267
+ end
268
+
269
+ RAW_LT_PLACEHOLDER = "___MJML_RAW_LT___"
270
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
271
+
272
+ # Custom serializer that preserves raw text content (no entity encoding
273
+ # for >, <, etc. in text nodes). Needed because mj-raw injects template
274
+ # syntax like { if item < 5 } that must pass through literally.
275
+ def self.serialize_fragment(node)
276
+ node.children.map { |c| serialize_node(c) }.join
277
+ end
278
+
279
+ def self.serialize_node(node)
280
+ if node.text?
281
+ node.text
282
+ elsif node.comment?
283
+ "<!--#{node.content}-->"
284
+ elsif node.element?
285
+ attrs = node.attributes.values.map { |a|
286
+ val = a.value.gsub("&", "&amp;").gsub('"', "&quot;")
287
+ " #{a.name}=\"#{val}\""
288
+ }.join
289
+ if VOID_ELEMENTS.include?(node.name) && node.children.empty?
290
+ "<#{node.name}#{attrs}>"
291
+ else
292
+ "<#{node.name}#{attrs}>#{serialize_fragment(node)}</#{node.name}>"
293
+ end
294
+ else
295
+ node.to_html
296
+ end
297
+ end
298
+
299
+ private_class_method :nokogiri_to_hash, :apply_attributes_fn, :inline_css,
300
+ :serialize_fragment, :serialize_node
301
+ end
302
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers/fonts"
4
+ require_relative "helpers/media_queries"
5
+ require_relative "helpers/styles"
6
+
7
+ module Emjay
8
+ module Skeleton
9
+ # Builds the full HTML document skeleton. Port of skeleton.js.
10
+ def self.call(options)
11
+ before_doctype = options[:before_doctype] || ""
12
+ breakpoint = options[:breakpoint] || "480px"
13
+ content = options[:content] || ""
14
+ fonts = options[:fonts] || {}
15
+ media_queries = options[:media_queries] || {}
16
+ head_style = options[:head_style] || {}
17
+ components_head_style = options[:components_head_style] || []
18
+ head_raw = options[:head_raw] || []
19
+ title = options[:title] || ""
20
+ style = options[:style] || []
21
+ force_owa_desktop = options[:force_owa_desktop] || false
22
+ printer_support = options[:printer_support] || false
23
+ inline_style = options[:inline_style] || []
24
+ lang = options[:lang] || "und"
25
+ dir = options[:dir] || "auto"
26
+
27
+ before_doctype_str = before_doctype.empty? ? "" : "#{before_doctype}\n"
28
+
29
+ fonts_tags = Fonts.build_tags(content, inline_style, fonts)
30
+ media_query_tags = MediaQueries.build_tags(breakpoint, media_queries,
31
+ force_owa_desktop: force_owa_desktop,
32
+ printer_support: printer_support)
33
+ component_styles = Styles.build_from_components(breakpoint, components_head_style, head_style)
34
+ tag_styles = Styles.build_from_tags(breakpoint, style)
35
+ raw = head_raw.compact.join("\n")
36
+
37
+ <<~HTML
38
+ #{before_doctype_str}<!doctype html>
39
+ <html lang="#{lang}" dir="#{dir}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
40
+ <head>
41
+ <title>#{title}</title>
42
+ <!--[if !mso]><!-->
43
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
44
+ <!--<![endif]-->
45
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1">
47
+ <style type="text/css">
48
+ #outlook a { padding:0; }
49
+ body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
50
+ table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
51
+ img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
52
+ p { display:block;margin:13px 0; }
53
+ </style>
54
+ <!--[if mso]>
55
+ <noscript>
56
+ <xml>
57
+ <o:OfficeDocumentSettings>
58
+ <o:AllowPNG/>
59
+ <o:PixelsPerInch>96</o:PixelsPerInch>
60
+ </o:OfficeDocumentSettings>
61
+ </xml>
62
+ </noscript>
63
+ <![endif]-->
64
+ <!--[if lte mso 11]>
65
+ <style type="text/css">
66
+ .mj-outlook-group-fix { width:100% !important; }
67
+ </style>
68
+ <![endif]-->
69
+ #{fonts_tags}
70
+ #{media_query_tags}
71
+ #{component_styles}
72
+ #{tag_styles}
73
+ #{raw}
74
+ </head>
75
+ #{content}
76
+ </html>
77
+ HTML
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ VERSION = "0.1.0"
5
+ end
data/lib/emjay.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "emjay/version"
4
+ require_relative "emjay/registry"
5
+ require_relative "emjay/component"
6
+ require_relative "emjay/body_component"
7
+ require_relative "emjay/head_component"
8
+ require_relative "emjay/global_data"
9
+ require_relative "emjay/renderer"
10
+ require_relative "emjay/skeleton"
11
+
12
+ # Helpers
13
+ require_relative "emjay/helpers/shorthand_parser"
14
+ require_relative "emjay/helpers/width_parser"
15
+ require_relative "emjay/helpers/conditional_tag"
16
+ require_relative "emjay/helpers/suffix_css_classes"
17
+ require_relative "emjay/helpers/merge_outlook_conditionals"
18
+ require_relative "emjay/helpers/minify_outlook_conditionals"
19
+ require_relative "emjay/helpers/fonts"
20
+ require_relative "emjay/helpers/media_queries"
21
+ require_relative "emjay/helpers/styles"
22
+ require_relative "emjay/helpers/make_lower_breakpoint"
23
+ require_relative "emjay/helpers/gen_random_hex_string"
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"
59
+
60
+ module Emjay
61
+ def self.to_html(mjml_string, options = {})
62
+ Renderer.call(mjml_string, options)
63
+ end
64
+ end
65
+
66
+ require "emjay/railtie" if defined?(Rails::Railtie)
data/llms.txt ADDED
@@ -0,0 +1,130 @@
1
+ # emjay
2
+
3
+ > Pure-Ruby MJML renderer. Converts MJML email markup to responsive HTML.
4
+
5
+ emjay is a Ruby implementation of MJML (https://mjml.io). It takes an MJML string and returns a complete HTML email with responsive tables, Outlook conditionals, and inlined CSS. No Node.js or native extensions required.
6
+
7
+ ## API
8
+
9
+ The core API is a single method:
10
+
11
+ Emjay.to_html(mjml_string) -> html_string
12
+
13
+ ## Rails integration
14
+
15
+ emjay registers an ActionView template handler and an ActionMailer interceptor via a Railtie. No configuration needed — just add `gem "emjay"` to the Gemfile.
16
+
17
+ ### How it works
18
+
19
+ The `.mjml` template handler is an ERB passthrough — it does not compile MJML itself. An ActionMailer interceptor (`Emjay::Rails::MailInterceptor`) detects `<mjml` in the message body after Rails has assembled the full render (template + layout), then compiles MJML → HTML in one pass. This architecture enables MJML layouts.
20
+
21
+ ### Template naming
22
+
23
+ Templates use the `.html.mjml` extension:
24
+
25
+ app/views/user_mailer/welcome.html.mjml
26
+
27
+ Rails resolves this as: `.mjml` selects the emjay handler, `.html` sets the output MIME type.
28
+
29
+ ### ERB is always available
30
+
31
+ The handler chains through ERB. All ERB tags (`<%= %>`, `<% %>`, `<%# %>`) work inside `.mjml` templates. There is no separate `.mjml.erb` extension — ERB support is built in.
32
+
33
+ Use ERB exactly as you would in any other Rails template:
34
+
35
+ - Instance variables from the mailer action: `<%= @user.name %>`
36
+ - Helpers: `<%= number_to_currency(@amount) %>`
37
+ - Partials: `<%= render "shared/header" %>`
38
+ - Conditionals: `<% if @user.premium? %> ... <% end %>`
39
+
40
+ ### Layouts
41
+
42
+ MJML layouts work like regular Rails mailer layouts. The layout provides the `<mjml>` / `<mj-body>` wrapper, and `yield` receives the template's MJML content sections:
43
+
44
+ app/views/layouts/mailer.html.mjml — wraps yield with <mjml><mj-body>...</mj-body></mjml>
45
+ app/views/user_mailer/welcome.html.mjml — contains only <mj-section> content
46
+
47
+ Set the layout in your mailer: `layout "mailer"`
48
+
49
+ ### Partials
50
+
51
+ MJML partials work with the standard `render` helper. Use `.html.mjml` extension for partials that contain MJML components:
52
+
53
+ app/views/shared/_header.html.mjml
54
+
55
+ Render them as usual: `<%= render "shared/header" %>`
56
+
57
+ ### Follow host app conventions
58
+
59
+ When writing MJML templates in a Rails app, follow the conventions of the host application:
60
+
61
+ - Use the same helper methods, partial patterns, and i18n approach as other views
62
+ - Use mailer previews (`ActionMailer::Preview`) to iterate on email design
63
+ - Provide a plain text alternative (`welcome.text.erb`) alongside the MJML template for multipart emails
64
+ - Keep mailer actions simple — set instance variables and call `mail()`
65
+
66
+ ## MJML components
67
+
68
+ MJML templates are XML documents wrapped in `<mjml>` tags. The full component reference is at https://documentation.mjml.io/
69
+
70
+ ### Structure
71
+
72
+ Every MJML email follows this structure:
73
+
74
+ <mjml>
75
+ <mj-head>
76
+ <!-- head components: styles, fonts, attributes, title, preview -->
77
+ </mj-head>
78
+ <mj-body>
79
+ <mj-section>
80
+ <mj-column>
81
+ <!-- content components: text, image, button, etc. -->
82
+ </mj-column>
83
+ </mj-section>
84
+ </mj-body>
85
+ </mjml>
86
+
87
+ Content must be nested inside `mj-section > mj-column`. Sections are full-width rows, columns divide them horizontally.
88
+
89
+ ### Head components
90
+
91
+ - `mj-attributes` — set default attributes for components globally or by class
92
+ - `mj-style` — add CSS to the `<head>` (use `inline="inline"` to inline into elements)
93
+ - `mj-font` — register web fonts
94
+ - `mj-title` — set the `<title>` tag
95
+ - `mj-preview` — set preview text shown in inbox listings
96
+ - `mj-breakpoint` — set the responsive breakpoint (default 480px)
97
+ - `mj-html-attributes` — add HTML attributes to rendered elements via CSS selectors
98
+
99
+ ### Body components
100
+
101
+ - `mj-section` — full-width row container
102
+ - `mj-column` — vertical subdivision of a section
103
+ - `mj-group` — non-responsive column grouping
104
+ - `mj-wrapper` — wraps multiple sections with shared background
105
+ - `mj-text` — text content (supports inline HTML)
106
+ - `mj-image` — responsive image
107
+ - `mj-button` — call-to-action button
108
+ - `mj-divider` — horizontal rule
109
+ - `mj-spacer` — vertical spacing
110
+ - `mj-table` — HTML table passthrough
111
+ - `mj-raw` — raw HTML passthrough
112
+ - `mj-hero` — hero section with background image
113
+ - `mj-social` / `mj-social-element` — social media icon links
114
+ - `mj-navbar` / `mj-navbar-link` — horizontal navigation
115
+ - `mj-accordion` / `mj-accordion-element` — expandable content
116
+ - `mj-carousel` / `mj-carousel-image` — image carousel
117
+
118
+ ### Common attributes
119
+
120
+ Most body components accept: `padding`, `background-color`, `css-class`, `mj-class`, `width`, `font-family`, `font-size`, `color`, `align`.
121
+
122
+ Refer to the MJML documentation for the full attribute list per component: https://documentation.mjml.io/
123
+
124
+ ## Tips for generating MJML
125
+
126
+ - Always wrap content in the `mj-section > mj-column` structure
127
+ - Use `mj-attributes` to set shared styles instead of repeating attributes
128
+ - Use `mj-style` with `inline="inline"` for CSS properties not available as component attributes
129
+ - Use `mj-raw` sparingly — only for HTML that MJML components cannot express
130
+ - Test with real email clients or use mailer previews — email rendering varies wildly across clients