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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bae58d9f87e82dc3c39bd0c9fca4949bb2a953cf8e22a2dd56fa2d5ff057a566
4
- data.tar.gz: b892f3ed57cf49cf3935a60cd8abef4f232544b0bb2abf2999ef5a557b49849f
3
+ metadata.gz: 2075cd423603c4070dae0acdbecf052f474931e96c079d6369eabbefb505306c
4
+ data.tar.gz: 1d5fd4664d222e546cbf61c51f1e8fbe6e76880d9e1e0eb858cce266cec58989
5
5
  SHA512:
6
- metadata.gz: 320fa67da655fcb902557e784e8f1411ab94316b0c1d9e18b2890a6e25fba015ce159fe8ae6e404fbfa60273c9db3a0fdb45320abc9d72eba19a7eff8867706a
7
- data.tar.gz: e6e3176e0c1cf87791bac2625ea520c339a4059f9ef474b74e16cc2ffd6bd8c1664caa35bd429e2c963d4f39167051fbaf7e391669d44cec93e7788664e5d519
6
+ metadata.gz: 3e82ec6fe3d8af7361b3fe5691eed18449724f3b4fc5c2318077ccfe5f406b20bed0f8aaa724a059d2298a53d8a4a04a470ee2484ab259f007d5a67fa4da8e08
7
+ data.tar.gz: a09686d9b4cffab082e5c3bb0a7b3049c2702e857eaec5234aa3185c1cb8b47949e772711470ea29b9d0884932c7b5a9bc62016bcb76f8a96fd348d2688eee18
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
1
  source "https://rubygems.org"
2
2
 
3
3
  gemspec
4
+
5
+ gem "minitest", group: :test
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 [doc/TODO.md](/doc/TODO.md).
49
+ Remaining parity work is tracked in [npm ↔ Ruby Parity Audit](/docs/PARITY_AUDIT.md).
@@ -10,6 +10,7 @@ module MjmlRb
10
10
  minify: false,
11
11
  keep_comments: true,
12
12
  ignore_includes: false,
13
+ printer_support: false,
13
14
  preprocessors: [],
14
15
  validation_level: "soft",
15
16
  file_path: ".",
@@ -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 = wrap_ending_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
113
- # Recursively expand includes in the included content
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
- fragment.root.children.each do |child|
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, deep_clone(child))
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 inject_css_includes(doc, css_includes)
142
- mjml_root = doc.root
143
- return unless mjml_root
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
- # Find or create mj-head
165
+ body = XPath.first(mjml_root, "mj-body")
146
166
  head = XPath.first(mjml_root, "mj-head")
147
- unless head
148
- head = Element.new("mj-head")
149
- # Insert mj-head before mj-body if possible
150
- body = XPath.first(mjml_root, "mj-body")
151
- if body
152
- mjml_root.insert_before(body, head)
153
- else
154
- mjml_root.add(head)
155
- end
156
- end
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
@@ -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
- "Roboto" => "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700"
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(context[:breakpoint], context[:column_widths])
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:0;overflow:hidden;opacity:0;">#{escape_html(preview)}</div>)
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("word-spacing" => "normal")
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
- parts << "<style type=\"text/css\">\n#{owa_rules.join("\n")}\n</style>"
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?
@@ -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
 
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.34".freeze
2
+ VERSION = "0.2.36".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mjml-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.34
4
+ version: 0.2.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk