mjml-rb 0.2.34 → 0.2.36
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 +4 -4
- data/Gemfile +2 -0
- data/README.md +1 -1
- data/lib/mjml-rb/compiler.rb +1 -0
- data/lib/mjml-rb/parser.rb +61 -20
- data/lib/mjml-rb/renderer.rb +42 -7
- data/lib/mjml-rb/validator.rb +16 -0
- data/lib/mjml-rb/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2075cd423603c4070dae0acdbecf052f474931e96c079d6369eabbefb505306c
|
|
4
|
+
data.tar.gz: 1d5fd4664d222e546cbf61c51f1e8fbe6e76880d9e1e0eb858cce266cec58989
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e82ec6fe3d8af7361b3fe5691eed18449724f3b4fc5c2318077ccfe5f406b20bed0f8aaa724a059d2298a53d8a4a04a470ee2484ab259f007d5a67fa4da8e08
|
|
7
|
+
data.tar.gz: a09686d9b4cffab082e5c3bb0a7b3049c2702e857eaec5234aa3185c1cb8b47949e772711470ea29b9d0884932c7b5a9bc62016bcb76f8a96fd348d2688eee18
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -46,4 +46,4 @@ bundle exec bin/mjml --migrate old.mjml -s
|
|
|
46
46
|
> MJML templates the same way you render ERB — no extra runtime, no
|
|
47
47
|
> `package.json`, no `node_modules`.
|
|
48
48
|
|
|
49
|
-
Remaining parity work is tracked in [
|
|
49
|
+
Remaining parity work is tracked in [npm ↔ Ruby Parity Audit](/docs/PARITY_AUDIT.md).
|
data/lib/mjml-rb/compiler.rb
CHANGED
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -66,6 +66,7 @@ module MjmlRb
|
|
|
66
66
|
return xml if includes.empty?
|
|
67
67
|
|
|
68
68
|
css_includes = []
|
|
69
|
+
head_includes = []
|
|
69
70
|
|
|
70
71
|
includes.reverse_each do |include_node|
|
|
71
72
|
path_attr = include_node.attributes["path"]
|
|
@@ -109,23 +110,34 @@ module MjmlRb
|
|
|
109
110
|
replacement = if include_type == "html"
|
|
110
111
|
%(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
|
|
111
112
|
else
|
|
112
|
-
prepared =
|
|
113
|
-
|
|
114
|
-
expand_includes(prepared, options.merge(
|
|
113
|
+
prepared = prepare_mjml_include_document(include_content)
|
|
114
|
+
prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(prepared))
|
|
115
|
+
expanded = expand_includes(prepared, options.merge(
|
|
115
116
|
actual_path: resolved_path,
|
|
116
117
|
file_path: File.dirname(resolved_path)
|
|
117
118
|
), included_in + [resolved_path])
|
|
119
|
+
body_children, include_head_children = extract_mjml_include_children(expanded)
|
|
120
|
+
head_includes.unshift(*include_head_children)
|
|
121
|
+
body_children
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
121
124
|
insert_before = include_node
|
|
122
|
-
|
|
125
|
+
replacement_nodes = if replacement.is_a?(Array)
|
|
126
|
+
replacement
|
|
127
|
+
else
|
|
128
|
+
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
129
|
+
fragment.root.children.map { |child| deep_clone(child) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
replacement_nodes.each do |child|
|
|
123
133
|
annotate_include_source(child, resolved_path) if child.is_a?(Element)
|
|
124
|
-
parent.insert_before(insert_before,
|
|
134
|
+
parent.insert_before(insert_before, child)
|
|
125
135
|
end
|
|
126
136
|
parent.delete(include_node)
|
|
127
137
|
end
|
|
128
138
|
|
|
139
|
+
inject_head_includes(doc, head_includes) unless head_includes.empty?
|
|
140
|
+
|
|
129
141
|
# Inject CSS includes into mj-head
|
|
130
142
|
unless css_includes.empty?
|
|
131
143
|
inject_css_includes(doc, css_includes)
|
|
@@ -138,22 +150,34 @@ module MjmlRb
|
|
|
138
150
|
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
139
151
|
end
|
|
140
152
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
return
|
|
153
|
+
def prepare_mjml_include_document(content)
|
|
154
|
+
stripped = strip_xml_declaration(content)
|
|
155
|
+
return stripped if stripped.match?(/<mjml(?=[\s>])/i)
|
|
156
|
+
|
|
157
|
+
"<mjml><mj-body>#{stripped}</mj-body></mjml>"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def extract_mjml_include_children(xml)
|
|
161
|
+
include_doc = Document.new(sanitize_bare_ampersands(xml))
|
|
162
|
+
mjml_root = include_doc.root
|
|
163
|
+
return [[], []] unless mjml_root&.name == "mjml"
|
|
144
164
|
|
|
145
|
-
|
|
165
|
+
body = XPath.first(mjml_root, "mj-body")
|
|
146
166
|
head = XPath.first(mjml_root, "mj-head")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
|
|
168
|
+
[
|
|
169
|
+
body ? body.children.map { |child| deep_clone(child) } : [],
|
|
170
|
+
head ? head.children.map { |child| deep_clone(child) } : []
|
|
171
|
+
]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def inject_head_includes(doc, head_includes)
|
|
175
|
+
head = ensure_head(doc)
|
|
176
|
+
head_includes.each { |child| head.add(child) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def inject_css_includes(doc, css_includes)
|
|
180
|
+
head = ensure_head(doc)
|
|
157
181
|
|
|
158
182
|
# Add each CSS include as an mj-style element
|
|
159
183
|
css_includes.each do |css_include|
|
|
@@ -164,6 +188,23 @@ module MjmlRb
|
|
|
164
188
|
end
|
|
165
189
|
end
|
|
166
190
|
|
|
191
|
+
def ensure_head(doc)
|
|
192
|
+
mjml_root = doc.root
|
|
193
|
+
return unless mjml_root
|
|
194
|
+
|
|
195
|
+
head = XPath.first(mjml_root, "mj-head")
|
|
196
|
+
return head if head
|
|
197
|
+
|
|
198
|
+
head = Element.new("mj-head")
|
|
199
|
+
body = XPath.first(mjml_root, "mj-body")
|
|
200
|
+
if body
|
|
201
|
+
mjml_root.insert_before(body, head)
|
|
202
|
+
else
|
|
203
|
+
mjml_root.add(head)
|
|
204
|
+
end
|
|
205
|
+
head
|
|
206
|
+
end
|
|
207
|
+
|
|
167
208
|
def strip_xml_declaration(content)
|
|
168
209
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
169
210
|
end
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -27,7 +27,11 @@ module MjmlRb
|
|
|
27
27
|
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
28
28
|
|
|
29
29
|
DEFAULT_FONTS = {
|
|
30
|
-
"
|
|
30
|
+
"Open Sans" => "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
|
|
31
|
+
"Droid Sans" => "https://fonts.googleapis.com/css?family=Droid+Sans:300,400,500,700",
|
|
32
|
+
"Lato" => "https://fonts.googleapis.com/css?family=Lato:300,400,500,700",
|
|
33
|
+
"Roboto" => "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700",
|
|
34
|
+
"Ubuntu" => "https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
|
|
31
35
|
}.freeze
|
|
32
36
|
|
|
33
37
|
DOCUMENT_RESET_CSS = <<~CSS.freeze
|
|
@@ -68,6 +72,8 @@ module MjmlRb
|
|
|
68
72
|
context[:before_doctype] = root_file_start_raw(document)
|
|
69
73
|
context[:lang] = options[:lang] || document.attributes["lang"] || "und"
|
|
70
74
|
context[:dir] = options[:dir] || document.attributes["dir"] || "auto"
|
|
75
|
+
context[:force_owa_desktop] = document.attributes["owa"] == "desktop"
|
|
76
|
+
context[:printer_support] = options[:printer_support] || options[:printerSupport]
|
|
71
77
|
context[:column_widths] = {}
|
|
72
78
|
append_component_head_styles(document, context)
|
|
73
79
|
content = render_node(body, context, parent: "mjml")
|
|
@@ -106,15 +112,21 @@ module MjmlRb
|
|
|
106
112
|
end
|
|
107
113
|
|
|
108
114
|
def build_html_document(content, context)
|
|
115
|
+
content = minify_outlook_conditionals(content)
|
|
109
116
|
title = context[:title].to_s
|
|
110
117
|
preview = context[:preview]
|
|
111
118
|
head_raw = Array(context[:head_raw]).join("\n")
|
|
112
119
|
before_doctype = context[:before_doctype].to_s
|
|
113
120
|
font_tags = build_font_tags(content, context[:inline_styles], context[:fonts])
|
|
114
|
-
media_queries_tags = build_media_queries_tags(
|
|
121
|
+
media_queries_tags = build_media_queries_tags(
|
|
122
|
+
context[:breakpoint],
|
|
123
|
+
context[:column_widths],
|
|
124
|
+
force_owa_desktop: context[:force_owa_desktop],
|
|
125
|
+
printer_support: context[:printer_support]
|
|
126
|
+
)
|
|
115
127
|
component_styles_tag = build_style_tag(unique_strings(context[:component_head_styles]))
|
|
116
128
|
user_styles_tag = build_style_tag(unique_strings(context[:user_styles]))
|
|
117
|
-
preview_block = preview.empty? ? "" : %(<div style="display:none;max-height:
|
|
129
|
+
preview_block = preview.empty? ? "" : %(<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">#{escape_html(preview)}</div>)
|
|
118
130
|
html_attributes = {
|
|
119
131
|
"lang" => context[:lang],
|
|
120
132
|
"dir" => context[:dir],
|
|
@@ -122,7 +134,10 @@ module MjmlRb
|
|
|
122
134
|
"xmlns:v" => "urn:schemas-microsoft-com:vml",
|
|
123
135
|
"xmlns:o" => "urn:schemas-microsoft-com:office:office"
|
|
124
136
|
}
|
|
125
|
-
body_style = style_join(
|
|
137
|
+
body_style = style_join(
|
|
138
|
+
"word-spacing" => "normal",
|
|
139
|
+
"background-color" => context[:background_color]
|
|
140
|
+
)
|
|
126
141
|
|
|
127
142
|
html = <<~HTML
|
|
128
143
|
<!doctype html>
|
|
@@ -133,7 +148,6 @@ module MjmlRb
|
|
|
133
148
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
134
149
|
<!--<![endif]-->
|
|
135
150
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
136
|
-
<meta charset="utf-8">
|
|
137
151
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
138
152
|
<style type="text/css">#{DOCUMENT_RESET_CSS}</style>
|
|
139
153
|
#{OUTLOOK_DOCUMENT_SETTINGS}
|
|
@@ -153,6 +167,7 @@ module MjmlRb
|
|
|
153
167
|
|
|
154
168
|
html = apply_html_attributes(html, context)
|
|
155
169
|
html = apply_inline_styles(html, context)
|
|
170
|
+
html = merge_outlook_conditionals(html)
|
|
156
171
|
before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
|
|
157
172
|
end
|
|
158
173
|
|
|
@@ -231,7 +246,7 @@ module MjmlRb
|
|
|
231
246
|
end
|
|
232
247
|
end
|
|
233
248
|
|
|
234
|
-
def build_media_queries_tags(breakpoint, column_widths)
|
|
249
|
+
def build_media_queries_tags(breakpoint, column_widths, force_owa_desktop: false, printer_support: false)
|
|
235
250
|
widths = column_widths || {}
|
|
236
251
|
return "" if widths.empty?
|
|
237
252
|
|
|
@@ -256,7 +271,13 @@ module MjmlRb
|
|
|
256
271
|
parts << "<style media=\"screen and (min-width:#{bp})\">\n#{moz_rules.join("\n")}\n</style>"
|
|
257
272
|
end
|
|
258
273
|
|
|
259
|
-
|
|
274
|
+
if printer_support
|
|
275
|
+
parts << "<style type=\"text/css\">\n@media only print {\n#{base_rules.join("\n")}\n}\n</style>"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if force_owa_desktop
|
|
279
|
+
parts << "<style type=\"text/css\">\n#{owa_rules.join("\n")}\n</style>"
|
|
280
|
+
end
|
|
260
281
|
parts.join("\n")
|
|
261
282
|
end
|
|
262
283
|
|
|
@@ -272,6 +293,20 @@ module MjmlRb
|
|
|
272
293
|
html.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
|
|
273
294
|
end
|
|
274
295
|
|
|
296
|
+
def minify_outlook_conditionals(html)
|
|
297
|
+
html.gsub(/(<!--\[if\s[^\]]+\]>)([\s\S]*?)(<!\[endif\]-->)/m) do
|
|
298
|
+
prefix = Regexp.last_match(1)
|
|
299
|
+
content = Regexp.last_match(2)
|
|
300
|
+
suffix = Regexp.last_match(3)
|
|
301
|
+
|
|
302
|
+
processed = content
|
|
303
|
+
.gsub(/(^|>)(\s+)(<|$)/m, '\1\3')
|
|
304
|
+
.gsub(/\s{2,}/m, " ")
|
|
305
|
+
|
|
306
|
+
"#{prefix}#{processed}#{suffix}"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
275
310
|
def apply_html_attributes(html, context)
|
|
276
311
|
rules = context[:html_attributes] || {}
|
|
277
312
|
return html if rules.empty?
|
data/lib/mjml-rb/validator.rb
CHANGED
|
@@ -43,14 +43,26 @@ module MjmlRb
|
|
|
43
43
|
def walk(node, errors)
|
|
44
44
|
return unless node.element?
|
|
45
45
|
|
|
46
|
+
validate_known_tag(node, errors)
|
|
46
47
|
validate_allowed_children(node, errors)
|
|
47
48
|
validate_required_attributes(node, errors)
|
|
48
49
|
validate_supported_attributes(node, errors)
|
|
49
50
|
validate_attribute_types(node, errors)
|
|
50
51
|
|
|
52
|
+
return if Dependencies::ENDING_TAGS.include?(node.tag_name)
|
|
53
|
+
|
|
51
54
|
node.element_children.each { |child| walk(child, errors) }
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
def validate_known_tag(node, errors)
|
|
58
|
+
return if known_tag?(node.tag_name)
|
|
59
|
+
|
|
60
|
+
errors << error(
|
|
61
|
+
"Element <#{node.tag_name}> doesn't exist or is not registered",
|
|
62
|
+
tag_name: node.tag_name, line: node.line, file: node.file
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
54
66
|
def validate_allowed_children(node, errors)
|
|
55
67
|
# Ending-tag components treat content as raw HTML; REXML still parses
|
|
56
68
|
# children structurally, so skip child validation for those tags.
|
|
@@ -130,6 +142,10 @@ module MjmlRb
|
|
|
130
142
|
end.find { |klass| klass.tags.include?(tag_name) }
|
|
131
143
|
end
|
|
132
144
|
|
|
145
|
+
def known_tag?(tag_name)
|
|
146
|
+
tag_name == "mjml" || !component_class_for_tag(tag_name).nil?
|
|
147
|
+
end
|
|
148
|
+
|
|
133
149
|
def valid_attribute_value?(value, expected_type)
|
|
134
150
|
return true if value.nil?
|
|
135
151
|
|
data/lib/mjml-rb/version.rb
CHANGED