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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +158 -0
- data/lib/emjay/body_component.rb +142 -0
- data/lib/emjay/component.rb +61 -0
- data/lib/emjay/components/body/mj_accordion.rb +99 -0
- data/lib/emjay/components/body/mj_accordion_element.rb +127 -0
- data/lib/emjay/components/body/mj_accordion_text.rb +123 -0
- data/lib/emjay/components/body/mj_accordion_title.rb +171 -0
- data/lib/emjay/components/body/mj_body.rb +70 -0
- data/lib/emjay/components/body/mj_button.rb +198 -0
- data/lib/emjay/components/body/mj_carousel.rb +410 -0
- data/lib/emjay/components/body/mj_carousel_image.rb +188 -0
- data/lib/emjay/components/body/mj_column.rb +287 -0
- data/lib/emjay/components/body/mj_divider.rb +120 -0
- data/lib/emjay/components/body/mj_group.rb +196 -0
- data/lib/emjay/components/body/mj_hero.rb +382 -0
- data/lib/emjay/components/body/mj_image.rb +188 -0
- data/lib/emjay/components/body/mj_navbar.rb +187 -0
- data/lib/emjay/components/body/mj_navbar_link.rb +129 -0
- data/lib/emjay/components/body/mj_raw.rb +34 -0
- data/lib/emjay/components/body/mj_section.rb +442 -0
- data/lib/emjay/components/body/mj_social.rb +174 -0
- data/lib/emjay/components/body/mj_social_element.rb +272 -0
- data/lib/emjay/components/body/mj_spacer.rb +57 -0
- data/lib/emjay/components/body/mj_table.rb +113 -0
- data/lib/emjay/components/body/mj_text.rb +100 -0
- data/lib/emjay/components/body/mj_wrapper.rb +56 -0
- data/lib/emjay/components/head/mj_attributes.rb +38 -0
- data/lib/emjay/components/head/mj_breakpoint.rb +28 -0
- data/lib/emjay/components/head/mj_font.rb +24 -0
- data/lib/emjay/components/head/mj_head.rb +20 -0
- data/lib/emjay/components/head/mj_html_attributes.rb +33 -0
- data/lib/emjay/components/head/mj_preview.rb +24 -0
- data/lib/emjay/components/head/mj_style.rb +34 -0
- data/lib/emjay/components/head/mj_title.rb +24 -0
- data/lib/emjay/global_data.rb +64 -0
- data/lib/emjay/head_component.rb +37 -0
- data/lib/emjay/helpers/conditional_tag.rb +24 -0
- data/lib/emjay/helpers/fonts.rb +34 -0
- data/lib/emjay/helpers/gen_random_hex_string.rb +9 -0
- data/lib/emjay/helpers/make_lower_breakpoint.rb +17 -0
- data/lib/emjay/helpers/media_queries.rb +47 -0
- data/lib/emjay/helpers/merge_outlook_conditionals.rb +11 -0
- data/lib/emjay/helpers/minify_outlook_conditionals.rb +18 -0
- data/lib/emjay/helpers/shorthand_parser.rb +33 -0
- data/lib/emjay/helpers/styles.rb +34 -0
- data/lib/emjay/helpers/suffix_css_classes.rb +12 -0
- data/lib/emjay/helpers/width_parser.rb +26 -0
- data/lib/emjay/rails/mail_interceptor.rb +37 -0
- data/lib/emjay/rails/template_handler.rb +16 -0
- data/lib/emjay/railtie.rb +21 -0
- data/lib/emjay/registry.rb +19 -0
- data/lib/emjay/renderer.rb +302 -0
- data/lib/emjay/skeleton.rb +80 -0
- data/lib/emjay/version.rb +5 -0
- data/lib/emjay.rb +66 -0
- data/llms.txt +130 -0
- 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("&", "&").gsub('"', """)
|
|
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
|
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
|