mjml-rb 0.4.3 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a143101aa0a18f9aa1e77160d0810561f9b5e58fe6dbf8c68d9f809b91421d8d
4
- data.tar.gz: b3fb93b17518384e6ce03e96162a30ef870ab4534bfc3c0fe62775b5db21c594
3
+ metadata.gz: 5b2c747b6f5afbc288db3ddc0afec65b3f0ff5a208bb2958abf8c9869a76790c
4
+ data.tar.gz: edd5d4b07c99bfd971bafadf42b11a66c0ace207cdfe835fa2643c64fc4297f9
5
5
  SHA512:
6
- metadata.gz: a811a85d468e8332b29c533efe918038c68fccb9f7383d82bfceff42954cbe7c9faeccbe72dde57380b422881b620a358db2d6032bef62928053a6b25c001d71
7
- data.tar.gz: c04f1e1df5835cbf5d0c2f81835ba942aa6f9d3149e592662cffeec22ece7c2841d89f44d4834e5ba29ef8b2891070c402a9da277b04363a7f59541740f2c785
6
+ metadata.gz: 8989b98cecdb8b63d843f87dd980c409581fe11220cbb439d183768259027a04536799e1d9bc43f5f8760aef216c208ff4e0d74d01e13008f65c749f190fc99c
7
+ data.tar.gz: 83d310d5e9f5f92bf97da53086c56bca1baed0e9bd7495f7c8595f888b6343621e76b6dc98292db801a28c169a85720be6e8afcd674928ec9a3a4a5947d727fd
@@ -173,7 +173,7 @@ module MjmlRb
173
173
  when "mj-accordion-element"
174
174
  render_accordion_element(child, context, accordion_attrs)
175
175
  when "mj-raw"
176
- raw_inner(child)
176
+ raw_inner_for_body(child)
177
177
  else
178
178
  render_node(child, context, parent: "mj-accordion")
179
179
  end
@@ -210,7 +210,7 @@ module MjmlRb
210
210
  child_attrs = attrs.merge(resolved_attributes(child, context))
211
211
  content << render_accordion_text(child, child_attrs)
212
212
  when "mj-raw"
213
- content << raw_inner(child)
213
+ content << raw_inner_for_body(child)
214
214
  end
215
215
  end
216
216
  end
@@ -249,7 +249,7 @@ module MjmlRb
249
249
  "width" => "100%",
250
250
  "border-bottom" => title_attrs["border"] || DEFAULTS["border"]
251
251
  )
252
- title_content = node ? raw_inner(node) : ""
252
+ title_content = node ? raw_inner_for_body(node) : ""
253
253
  title_cell = %(<td style="#{td_style}">#{title_content}</td>)
254
254
  icon_cell = %(<td class="mj-accordion-ico" style="#{td2_style}"><img src="#{escape_attr(title_attrs["icon-wrapped-url"] || DEFAULTS["icon-wrapped-url"])}" alt="#{escape_attr(title_attrs["icon-wrapped-alt"] || DEFAULTS["icon-wrapped-alt"])}" class="mj-accordion-more" style="#{icon_style}" /><img src="#{escape_attr(title_attrs["icon-unwrapped-url"] || DEFAULTS["icon-unwrapped-url"])}" alt="#{escape_attr(title_attrs["icon-unwrapped-alt"] || DEFAULTS["icon-unwrapped-alt"])}" class="mj-accordion-less" style="#{icon_style}" /></td>)
255
255
  cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
@@ -277,7 +277,7 @@ module MjmlRb
277
277
  "width" => "100%",
278
278
  "border-bottom" => text_attrs["border"] || DEFAULTS["border"]
279
279
  )
280
- content = node ? raw_inner(node) : ""
280
+ content = node ? raw_inner_for_body(node) : ""
281
281
  css_class = text_attrs["css-class"] ? %( class="#{escape_attr(text_attrs["css-class"])}") : ""
282
282
 
283
283
  %(<div class="mj-accordion-content"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr><td#{css_class} style="#{td_style}">#{content}</td></tr></tbody></table></div>)
@@ -50,6 +50,10 @@ module MjmlRb
50
50
  renderer.send(:raw_inner, node)
51
51
  end
52
52
 
53
+ def raw_inner_for_body(node)
54
+ renderer.send(:annotate_raw_html, raw_inner(node))
55
+ end
56
+
53
57
  # Like raw_inner but HTML-escapes text nodes. Use for components such as
54
58
  # mj-text where the inner content is treated as HTML but bare text must
55
59
  # be properly encoded (e.g. & -> &amp;).
@@ -143,7 +143,7 @@ module MjmlRb
143
143
  link_attrs["title"] = a["title"]
144
144
  end
145
145
 
146
- content = raw_inner(node)
146
+ content = raw_inner_for_body(node)
147
147
  inner_tag = %(<#{tag}#{html_attrs(link_attrs)}>#{content}</#{tag}>)
148
148
  table = %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{table_style}"><tbody><tr><td#{html_attrs(td_attrs)}>#{inner_tag}</td></tr></tbody></table>)
149
149
 
@@ -205,7 +205,7 @@ module MjmlRb
205
205
  "style" => anchor_style
206
206
  }
207
207
 
208
- content = raw_inner(node)
208
+ content = raw_inner_for_body(node)
209
209
  link = %(<a#{html_attrs(link_attrs)}>#{content}</a>)
210
210
  return link unless parent == "mj-navbar"
211
211
 
@@ -10,7 +10,7 @@ module MjmlRb
10
10
  }.freeze
11
11
 
12
12
  def render(tag_name:, node:, context:, attrs:, parent:)
13
- raw_inner(node)
13
+ raw_inner_for_body(node)
14
14
  end
15
15
 
16
16
  def handle_head(node, context)
@@ -72,7 +72,7 @@ module MjmlRb
72
72
  "height" => height
73
73
  )
74
74
 
75
- content = raw_inner(node)
75
+ content = raw_inner_for_body(node)
76
76
  inner_div = %(<div style="#{div_style}">#{content}</div>)
77
77
 
78
78
  body = if height
@@ -4,7 +4,7 @@ module MjmlRb
4
4
  module Dependencies
5
5
  # Components whose content is treated as raw HTML in NPM (endingTag = true).
6
6
  # The parser preserves their inner markup as-is; the validator skips child
7
- # element checks because REXML structurally parses what NPM treats as text.
7
+ # element checks because the XML parser structurally parses what NPM treats as text.
8
8
  ENDING_TAGS = Set.new(%w[
9
9
  mj-accordion-text
10
10
  mj-accordion-title
@@ -1,11 +1,9 @@
1
- require "rexml/document"
2
- require "rexml/xpath"
1
+ require "nokogiri"
3
2
 
4
3
  require_relative "ast_node"
5
4
 
6
5
  module MjmlRb
7
6
  class Parser
8
- include REXML
9
7
  HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
10
8
 
11
9
  # Ending-tag components whose inner HTML is preserved as raw content via CDATA
@@ -18,13 +16,21 @@ module MjmlRb
18
16
  ].freeze
19
17
 
20
18
  # Pre-compiled regex patterns to avoid rebuilding on every call
21
- ENDING_TAGS_CDATA_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi.freeze
19
+ ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
22
20
 
23
21
  VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
24
22
  VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
25
23
  VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
26
- LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
27
24
  BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
25
+ ROOT_LEVEL_HEAD_TAGS = %w[
26
+ mj-attributes
27
+ mj-breakpoint
28
+ mj-html-attributes
29
+ mj-font
30
+ mj-preview
31
+ mj-style
32
+ mj-title
33
+ ].freeze
28
34
 
29
35
  class ParseError < StandardError
30
36
  attr_reader :line
@@ -47,10 +53,12 @@ module MjmlRb
47
53
  xml = normalize_html_void_tags(xml)
48
54
  xml = expand_includes(xml, opts) unless opts[:ignore_includes]
49
55
 
50
- xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
51
- doc = Document.new(xml)
56
+ xml = sanitize_bare_ampersands(xml)
57
+ xml = replace_html_entities(xml)
58
+ doc = Nokogiri::XML(xml) { |config| config.strict }
59
+ normalize_root_head_elements(doc)
52
60
  element_to_ast(doc.root, keep_comments: opts[:keep_comments])
53
- rescue ParseException => e
61
+ rescue Nokogiri::XML::SyntaxError => e
54
62
  raise ParseError.new("XML parse error: #{e.message}")
55
63
  end
56
64
 
@@ -75,18 +83,18 @@ module MjmlRb
75
83
  def expand_includes(xml, options, included_in = [])
76
84
  xml = wrap_ending_tags_in_cdata(xml)
77
85
  xml = normalize_html_void_tags(xml)
78
- doc = Document.new(sanitize_bare_ampersands(xml))
79
- includes = XPath.match(doc, "//mj-include")
86
+ doc = parse_xml(sanitize_bare_ampersands(xml))
87
+ includes = doc.xpath("//mj-include")
80
88
  return xml if includes.empty?
81
89
 
82
90
  css_includes = []
83
91
  head_includes = []
84
92
 
85
93
  includes.reverse_each do |include_node|
86
- path_attr = include_node.attributes["path"]
94
+ path_attr = include_node["path"]
87
95
  raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
88
96
 
89
- include_type = include_node.attributes["type"].to_s
97
+ include_type = include_node["type"].to_s
90
98
  parent = include_node.parent
91
99
 
92
100
  resolved_path = begin
@@ -104,7 +112,7 @@ module MjmlRb
104
112
  tag_name: "mj-include",
105
113
  file: display_path
106
114
  }
107
- parent.delete(include_node)
115
+ include_node.remove
108
116
  next
109
117
  end
110
118
 
@@ -115,9 +123,9 @@ module MjmlRb
115
123
 
116
124
  if include_type == "css"
117
125
  # CSS includes get collected and added to mj-head later
118
- css_inline = include_node.attributes["css-inline"].to_s
126
+ css_inline = include_node["css-inline"].to_s
119
127
  css_includes << { content: include_content, inline: css_inline == "inline" }
120
- parent.delete(include_node)
128
+ include_node.remove
121
129
  next
122
130
  end
123
131
 
@@ -135,19 +143,20 @@ module MjmlRb
135
143
  body_children
136
144
  end
137
145
 
138
- insert_before = include_node
139
- replacement_nodes = if replacement.is_a?(Array)
140
- replacement
141
- else
142
- fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
143
- fragment.root.children.map { |child| deep_clone(child) }
144
- end
145
-
146
- replacement_nodes.each do |child|
147
- annotate_include_source(child, resolved_path) if child.is_a?(Element)
148
- parent.insert_before(insert_before, child)
146
+ if replacement.is_a?(Array)
147
+ replacement.each do |child|
148
+ annotate_include_source(child, resolved_path) if child.element?
149
+ include_node.add_previous_sibling(child)
150
+ end
151
+ else
152
+ fragment = parse_xml("<include-root>#{sanitize_bare_ampersands(replacement)}</include-root>").root
153
+ fragment.children.each do |child|
154
+ cloned = child.dup(1)
155
+ annotate_include_source(cloned, resolved_path) if cloned.element?
156
+ include_node.add_previous_sibling(cloned)
157
+ end
149
158
  end
150
- parent.delete(include_node)
159
+ include_node.remove
151
160
  end
152
161
 
153
162
  inject_head_includes(doc, head_includes) unless head_includes.empty?
@@ -157,11 +166,7 @@ module MjmlRb
157
166
  inject_css_includes(doc, css_includes)
158
167
  end
159
168
 
160
- output = +""
161
- doc.write(output)
162
- output
163
- rescue ParseException => e
164
- raise ParseError, "Failed to parse included content: #{e.message}"
169
+ doc.root.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
165
170
  end
166
171
 
167
172
  def prepare_mjml_include_document(content)
@@ -172,22 +177,23 @@ module MjmlRb
172
177
  end
173
178
 
174
179
  def extract_mjml_include_children(xml)
175
- include_doc = Document.new(sanitize_bare_ampersands(xml))
180
+ include_doc = parse_xml(sanitize_bare_ampersands(xml))
181
+ normalize_root_head_elements(include_doc)
176
182
  mjml_root = include_doc.root
177
183
  return [[], []] unless mjml_root&.name == "mjml"
178
184
 
179
- body = XPath.first(mjml_root, "mj-body")
180
- head = XPath.first(mjml_root, "mj-head")
185
+ body = mjml_root.at_xpath("mj-body")
186
+ head = mjml_root.at_xpath("mj-head")
181
187
 
182
188
  [
183
- body ? body.children.map { |child| deep_clone(child) } : [],
184
- head ? head.children.map { |child| deep_clone(child) } : []
189
+ body ? body.children.map { |child| child.dup(1) } : [],
190
+ head ? head.children.map { |child| child.dup(1) } : []
185
191
  ]
186
192
  end
187
193
 
188
194
  def inject_head_includes(doc, head_includes)
189
195
  head = ensure_head(doc)
190
- head_includes.each { |child| head.add(child) }
196
+ head_includes.each { |child| head.add_child(child) }
191
197
  end
192
198
 
193
199
  def inject_css_includes(doc, css_includes)
@@ -195,10 +201,10 @@ module MjmlRb
195
201
 
196
202
  # Add each CSS include as an mj-style element
197
203
  css_includes.each do |css_include|
198
- style_node = Element.new("mj-style")
199
- style_node.add_attribute("inline", "inline") if css_include[:inline]
200
- style_node.add(CData.new(css_include[:content]))
201
- head.add(style_node)
204
+ style_node = Nokogiri::XML::Node.new("mj-style", doc)
205
+ style_node["inline"] = "inline" if css_include[:inline]
206
+ style_node.add_child(Nokogiri::XML::CDATA.new(doc, css_include[:content]))
207
+ head.add_child(style_node)
202
208
  end
203
209
  end
204
210
 
@@ -206,19 +212,51 @@ module MjmlRb
206
212
  mjml_root = doc.root
207
213
  return unless mjml_root
208
214
 
209
- head = XPath.first(mjml_root, "mj-head")
215
+ head = mjml_root.at_xpath("mj-head")
210
216
  return head if head
211
217
 
212
- head = Element.new("mj-head")
213
- body = XPath.first(mjml_root, "mj-body")
218
+ head = Nokogiri::XML::Node.new("mj-head", doc)
219
+ body = mjml_root.at_xpath("mj-body")
214
220
  if body
215
- mjml_root.insert_before(body, head)
221
+ body.add_previous_sibling(head)
216
222
  else
217
- mjml_root.add(head)
223
+ mjml_root.add_child(head)
218
224
  end
219
225
  head
220
226
  end
221
227
 
228
+ def normalize_root_head_elements(doc)
229
+ mjml_root = doc.root
230
+ return unless mjml_root&.name == "mjml"
231
+
232
+ head_nodes = []
233
+ normalized_head_children = []
234
+ root_head_elements = []
235
+
236
+ mjml_root.children.each do |child|
237
+ next unless child.element?
238
+
239
+ if child.name == "mj-head"
240
+ head_nodes << child
241
+ child.children.each { |head_child| normalized_head_children << head_child.dup(1) }
242
+ elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
243
+ root_head_elements << child
244
+ normalized_head_children << child.dup(1)
245
+ end
246
+ end
247
+
248
+ return if root_head_elements.empty? && head_nodes.length <= 1
249
+
250
+ head = head_nodes.first || ensure_head(doc)
251
+ return unless head
252
+
253
+ head.children.each(&:remove)
254
+ normalized_head_children.each { |child| head.add_child(child) }
255
+
256
+ root_head_elements.each(&:remove)
257
+ head_nodes.drop(1).each(&:remove)
258
+ end
259
+
222
260
  def strip_xml_declaration(content)
223
261
  content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
224
262
  end
@@ -239,20 +277,74 @@ module MjmlRb
239
277
  end
240
278
 
241
279
  def wrap_ending_tags_in_cdata(content)
242
- # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
243
- content.gsub(ENDING_TAGS_CDATA_RE) do
244
- tag = Regexp.last_match(1)
245
- attrs = Regexp.last_match(2).to_s
246
- inner = Regexp.last_match(3).to_s
280
+ wrapped = +""
281
+ cursor = 0
282
+
283
+ while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
284
+ tag = match[1]
285
+ attrs = match[2].to_s
286
+ wrapped << content[cursor...match.begin(0)]
287
+
288
+ closing_range = find_matching_ending_tag(content, tag, match.end(0))
289
+ unless closing_range
290
+ wrapped << match[0]
291
+ cursor = match.end(0)
292
+ next
293
+ end
294
+
295
+ inner = content[match.end(0)...closing_range.begin(0)]
247
296
  if inner.include?("<![CDATA[")
248
- "<#{tag}#{attrs}>#{inner}</#{tag}>"
297
+ wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
249
298
  else
250
299
  # Pre-process content: normalize void tags and sanitize bare ampersands
251
300
  # before wrapping in CDATA, so the raw HTML is well-formed for output.
252
301
  prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
253
- "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
302
+ wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
254
303
  end
304
+
305
+ cursor = closing_range.end(0)
255
306
  end
307
+
308
+ wrapped << content[cursor..] if cursor < content.length
309
+ wrapped
310
+ end
311
+
312
+ def find_matching_ending_tag(content, tag_name, cursor)
313
+ open_tag_re = /<#{Regexp.escape(tag_name)}(\s[^<>]*?)?(?<!\/)>/mi
314
+ close_tag_re = %r{</#{Regexp.escape(tag_name)}\s*>}i
315
+ depth = 1
316
+
317
+ while cursor < content.length
318
+ cdata_index = content.index("<![CDATA[", cursor)
319
+ open_match = open_tag_re.match(content, cursor)
320
+ close_match = close_tag_re.match(content, cursor)
321
+
322
+ candidates = []
323
+ candidates << [:cdata, cdata_index, nil] if cdata_index
324
+ candidates << [:open, open_match.begin(0), open_match] if open_match
325
+ candidates << [:close, close_match.begin(0), close_match] if close_match
326
+ return nil if candidates.empty?
327
+
328
+ kind, _, match = candidates.min_by { |candidate| candidate[1] }
329
+
330
+ case kind
331
+ when :cdata
332
+ cdata_end = content.index("]]>", cdata_index + 9)
333
+ return nil unless cdata_end
334
+
335
+ cursor = cdata_end + 3
336
+ when :open
337
+ depth += 1
338
+ cursor = match.end(0)
339
+ when :close
340
+ depth -= 1
341
+ return match if depth.zero?
342
+
343
+ cursor = match.end(0)
344
+ end
345
+ end
346
+
347
+ nil
256
348
  end
257
349
 
258
350
  def escape_cdata(content)
@@ -260,40 +352,39 @@ module MjmlRb
260
352
  end
261
353
 
262
354
  # Escape bare "&" that are not part of a valid XML entity reference
263
- # (e.g. &amp; &#123; &#x1F;). This lets REXML parse HTML-ish content
264
- # such as "Terms & Conditions" which is common in email templates.
355
+ # (e.g. &amp; &#123; &#x1F;). This lets the XML parser handle HTML-ish
356
+ # content such as "Terms & Conditions" which is common in email templates.
265
357
  def sanitize_bare_ampersands(content)
266
358
  content.gsub(BARE_AMPERSAND_RE, "&amp;")
267
359
  end
268
360
 
269
- # Adds data-mjml-line attributes to MJML tags so line numbers survive
270
- # REXML parsing (which doesn't expose source positions).
271
- # Skips content inside CDATA sections to avoid modifying raw HTML.
272
- def annotate_line_numbers(xml)
273
- line = 1
274
- xml.gsub(LINE_ANNOTATION_RE) do
275
- if Regexp.last_match(1) # newline
276
- line += 1
277
- "\n"
278
- elsif Regexp.last_match(2) # CDATA section — count newlines, pass through
279
- line += Regexp.last_match(2).count("\n")
280
- Regexp.last_match(2)
281
- else # opening MJML tag
282
- "#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
283
- end
361
+ # Replace HTML named entities (e.g. &nbsp;, &copy;) with their numeric
362
+ # XML equivalents (e.g. &#160;, &#169;). XML only defines five named
363
+ # entities (amp, lt, gt, quot, apos); all other named references from
364
+ # HTML must be converted to numeric form for the XML parser to accept them.
365
+ # Uses Nokogiri's built-in HTML entity lookup table.
366
+ def replace_html_entities(content)
367
+ content.gsub(/&([a-zA-Z][a-zA-Z0-9]*);/) do |match|
368
+ name = ::Regexp.last_match(1)
369
+ next match if XML_PREDEFINED_ENTITIES.include?(name)
370
+
371
+ codepoint = Nokogiri::HTML::NamedCharacters[name]
372
+ codepoint ? "&##{codepoint};" : match
284
373
  end
285
374
  end
286
375
 
287
- # Recursively marks REXML elements from included files with data-mjml-file.
376
+ XML_PREDEFINED_ENTITIES = %w[amp lt gt quot apos].freeze
377
+
378
+ # Recursively marks Nokogiri elements from included files with data-mjml-file.
288
379
  # Only sets the attribute on elements that don't already have it (preserving
289
380
  # deeper include annotations from recursive expansion).
290
381
  def annotate_include_source(element, file_path)
291
- return unless element.is_a?(Element)
382
+ return unless element.element?
292
383
 
293
- if (element.name.start_with?("mj-") || element.name == "mjml") && !element.attributes["data-mjml-file"]
294
- element.add_attribute("data-mjml-file", file_path)
384
+ if (element.name.start_with?("mj-") || element.name == "mjml") && !element["data-mjml-file"]
385
+ element["data-mjml-file"] = file_path
295
386
  end
296
- element.each_element { |child| annotate_include_source(child, file_path) }
387
+ element.element_children.each { |child| annotate_include_source(child, file_path) }
297
388
  end
298
389
 
299
390
  def resolve_include_path(include_path, actual_path, file_path)
@@ -311,37 +402,24 @@ module MjmlRb
311
402
  raise Errno::ENOENT, include_path
312
403
  end
313
404
 
314
- def deep_clone(node)
315
- case node
316
- when Element
317
- clone = Element.new(node.name)
318
- node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
319
- node.children.each { |child| clone.add(deep_clone(child)) }
320
- clone
321
- when Text
322
- Text.new(node.value)
323
- when Comment
324
- Comment.new(node.string)
325
- else
326
- node
327
- end
328
- end
329
-
330
405
  def element_to_ast(element, keep_comments:)
331
406
  raise ParseError, "Missing XML root element" unless element
332
407
 
333
- # Extract metadata annotations (added by annotate_line_numbers / annotate_include_source)
408
+ # Extract metadata annotations (added by annotate_include_source)
334
409
  # and strip them from the public attributes hash.
335
- meta_line = element.attributes["data-mjml-line"]&.to_i
336
- meta_file = element.attributes["data-mjml-file"]
337
- attrs = element.attributes.each_with_object({}) do |(name, val), h|
338
- h[name] = val unless name.start_with?("data-mjml-")
410
+ # Line numbers come from Nokogiri's native node.line.
411
+ meta_line = element.line
412
+ meta_file = element["data-mjml-file"]
413
+ attrs = {}
414
+ element.attributes.each do |name, attr|
415
+ attrs[name] = attr.value unless name.start_with?("data-mjml-")
339
416
  end
417
+ attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
340
418
 
341
419
  # For ending-tag elements whose content was wrapped in CDATA, store
342
420
  # the raw HTML directly as content instead of parsing structurally.
343
421
  if ENDING_TAGS_FOR_CDATA.include?(element.name)
344
- raw_content = element.children.select { |c| c.is_a?(Text) }.map(&:value).join
422
+ raw_content = element.children.select { |c| c.cdata? || c.text? }.map(&:content).join
345
423
  return AstNode.new(
346
424
  tag_name: element.name,
347
425
  attributes: attrs,
@@ -353,14 +431,16 @@ module MjmlRb
353
431
  end
354
432
 
355
433
  children = element.children.each_with_object([]) do |child, memo|
356
- case child
357
- when Element
434
+ if child.element?
358
435
  memo << element_to_ast(child, keep_comments: keep_comments)
359
- when Text
360
- text = child.value
361
- memo << AstNode.new(tag_name: "#text", content: text) unless text.strip.empty?
362
- when Comment
363
- memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
436
+ elsif child.text? || child.cdata?
437
+ text = child.content
438
+ next if text.empty?
439
+ next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
440
+
441
+ memo << AstNode.new(tag_name: "#text", content: text)
442
+ elsif child.comment?
443
+ memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
364
444
  end
365
445
  end
366
446
 
@@ -372,5 +452,18 @@ module MjmlRb
372
452
  file: meta_file
373
453
  )
374
454
  end
455
+
456
+ # Lenient XML parse used during include expansion and intermediate steps.
457
+ # Errors are collected but do not raise; the final strict parse in #parse
458
+ # will surface any real issues.
459
+ def parse_xml(xml)
460
+ Nokogiri::XML(replace_html_entities(xml))
461
+ end
462
+
463
+ def ignorable_whitespace_text?(text, parent_element_name:)
464
+ return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
465
+
466
+ text.match?(/[\r\n]/)
467
+ end
375
468
  end
376
469
  end
@@ -1,4 +1,5 @@
1
1
  require "cgi"
2
+ require "css_parser"
2
3
  require "nokogiri"
3
4
  require "set"
4
5
  require_relative "components/accordion"
@@ -168,6 +169,8 @@ module MjmlRb
168
169
  HTML
169
170
 
170
171
  html = apply_inline_styles(html, context)
172
+ html = preserve_raw_tag_spacing(html)
173
+ html = strip_internal_raw_markers(html)
171
174
  html = merge_outlook_conditionals(html)
172
175
  before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
173
176
  end
@@ -342,20 +345,27 @@ module MjmlRb
342
345
  return html if css_blocks.empty?
343
346
 
344
347
  document = parse_html_document(html)
345
- rules, at_rules_css = parse_inline_css_rules(css_blocks.join("\n"))
348
+ rules, = parse_inline_css_rules(css_blocks.join("\n"))
349
+ merged_declarations_by_node = {}
350
+ touched_properties_by_node = Hash.new { |hash, node| hash[node] = Set.new }
346
351
 
347
352
  rules.each do |selector, declarations|
348
353
  next if selector.empty? || declarations.empty?
349
354
 
350
355
  select_nodes(document, selector).each do |node|
351
- merge_inline_style!(node, declarations)
356
+ existing = merged_declarations_by_node[node] ||= begin
357
+ source = node["data-mjml-raw"] == "true" ? :inline : :css
358
+ parsed = parse_css_declarations(node["style"].to_s, source: source)
359
+ touched_properties_by_node[node].merge(parsed.keys & HTML_ATTRIBUTE_SYNC_PROPERTIES.to_a) if source == :inline
360
+ parsed
361
+ end
362
+ merge_inline_declarations!(existing, declarations, touched_properties_by_node[node])
352
363
  end
353
364
  end
354
365
 
355
- # Inject preserved @-rules (@media, @font-face, etc.) as a <style> block.
356
- # These rules cannot be inlined into style attributes but should be kept
357
- # in the document for runtime application by email clients.
358
- inject_preserved_at_rules(document, at_rules_css)
366
+ merged_declarations_by_node.each do |node, declarations|
367
+ finalize_inline_style!(node, declarations, touched_properties_by_node[node])
368
+ end
359
369
 
360
370
  document.to_html
361
371
  end
@@ -368,18 +378,6 @@ module MjmlRb
368
378
  end
369
379
  end
370
380
 
371
- def inject_preserved_at_rules(document, at_rules_css)
372
- return if at_rules_css.nil? || at_rules_css.strip.empty?
373
-
374
- head = document.at_css("head")
375
- return unless head
376
-
377
- style = Nokogiri::XML::Node.new("style", document)
378
- style["type"] = "text/css"
379
- style.content = at_rules_css.strip
380
- head.add_child(style)
381
- end
382
-
383
381
  def select_nodes(document, selector)
384
382
  document.css(selector)
385
383
  rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
@@ -420,146 +418,69 @@ module MjmlRb
420
418
  end
421
419
 
422
420
  def parse_inline_css_rules(css)
423
- stripped_css = strip_css_comments(css.to_s)
424
- plain_css, at_rules_css = extract_css_at_rules(stripped_css)
425
-
426
- rules = plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
427
- selectors = selector_group.split(",").map(&:strip).reject(&:empty?)
428
- declaration_map = parse_css_declarations(declarations)
429
- selectors.map { |selector| [selector, declaration_map] }
421
+ parser = CssParser::Parser.new
422
+ parser.add_block!(css.to_s)
423
+
424
+ rules = []
425
+ parser.each_rule_set do |rule_set, media_types|
426
+ # Only inline rules not inside @media blocks; css_parser stores
427
+ # top-level rules under the :all media type.
428
+ next unless media_types == [:all]
429
+
430
+ rule_set.each_selector do |selector, declarations_str, specificity|
431
+ selector = selector.strip
432
+ # Skip @-rules like @font-face that css_parser yields as selectors
433
+ next if selector.start_with?("@")
434
+
435
+ declaration_map = parse_css_declarations(declarations_str)
436
+ rules << [selector, declaration_map, specificity]
437
+ end
430
438
  end
431
439
 
432
440
  # Sort rules by specificity (ascending). With the "last wins" merge
433
441
  # strategy, higher-specificity rules applied later correctly override
434
442
  # lower-specificity ones — matching CSS cascade behavior.
435
- sorted = rules.each_with_index
436
- .sort_by { |(selector, _), idx| [css_specificity(selector), idx] }
437
- .map(&:first)
443
+ sorted = rules.sort_by.with_index { |(_, _, spec), idx| [spec, idx] }
444
+ .map { |sel, decl, _| [sel, decl] }
438
445
 
439
- [sorted, at_rules_css]
446
+ [sorted, ""]
440
447
  end
441
448
 
442
- def strip_css_comments(css)
443
- css.gsub(%r{/\*.*?\*/}m, "")
449
+ def css_specificity(selector)
450
+ CssParser.calculate_specificity(selector.to_s)
444
451
  end
445
452
 
446
- # Separates @-rules (@media, @font-face, etc.) from plain CSS selectors.
447
- # Returns [plain_css, at_rules_css]. The at_rules_css can be injected as a
448
- # <style> block since @-rules cannot be inlined into style attributes.
449
- def extract_css_at_rules(css)
450
- plain = +""
451
- at_rules = +""
452
- index = 0
453
-
454
- while index < css.length
455
- if css[index] == "@"
456
- brace_index = css.index("{", index)
457
- semicolon_index = css.index(";", index)
458
-
459
- # Simple @-rules like @import or @charset end with semicolon
460
- if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
461
- at_rules << css[index..semicolon_index] << "\n"
462
- index = semicolon_index + 1
463
- next
464
- end
465
-
466
- # Block @-rules like @media, @font-face have nested braces
467
- if brace_index
468
- depth = 1
469
- cursor = brace_index + 1
470
- while cursor < css.length && depth.positive?
471
- depth += 1 if css[cursor] == "{"
472
- depth -= 1 if css[cursor] == "}"
473
- cursor += 1
474
- end
475
- at_rules << css[index...cursor] << "\n"
476
- index = cursor
477
- next
478
- end
479
- end
480
-
481
- plain << css[index]
482
- index += 1
483
- end
484
-
485
- [plain, at_rules]
486
- end
453
+ def parse_css_declarations(declarations, source: :css)
454
+ return {} if declarations.nil? || declarations.to_s.strip.empty?
487
455
 
488
- # Calculates CSS specificity as a comparable [a, b, c] tuple:
489
- # a = number of ID selectors (#id)
490
- # b = number of class selectors (.class), attribute selectors ([attr]),
491
- # and pseudo-classes (:hover, :lang())
492
- # c = number of type selectors (div, p) and pseudo-elements (::before)
493
- def css_specificity(selector)
494
- s = selector.to_s
495
-
496
- # a: ID selectors
497
- a = s.scan(/#[\w-]+/).length
498
-
499
- # b: class selectors + attribute selectors + pseudo-classes
500
- b = s.scan(/\.[\w-]+/).length +
501
- s.scan(/\[[^\]]*\]/).length +
502
- s.scan(/:(?!:)[\w-]+/).length
503
-
504
- # c: type selectors + pseudo-elements
505
- # Strip everything except element names and combinators
506
- cleaned = s
507
- .gsub(/#[\w-]+/, "") # remove IDs
508
- .gsub(/\.[\w-]+/, "") # remove classes
509
- .gsub(/\[[^\]]*\]/, "") # remove attribute selectors
510
- .gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
511
- .gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
512
- .gsub(/[>+~]/, " ") # combinators → spaces
513
- .gsub(/\*/, "") # universal selector has no specificity
514
- c = cleaned.split.reject(&:empty?).length +
515
- s.scan(/::[\w-]+/).length
516
-
517
- [a, b, c]
518
- end
519
-
520
- def parse_css_declarations(declarations)
521
- declarations.split(";").each_with_object({}) do |entry, memo|
522
- property, value = entry.split(":", 2).map { |part| part&.strip }
456
+ rule_set = CssParser::RuleSet.new(block: declarations.to_s)
457
+ result = {}
458
+ rule_set.each_declaration do |property, value, important|
523
459
  next if property.nil? || property.empty? || value.nil? || value.empty?
524
460
 
525
- important = value.match?(/\s*!important\s*\z/)
526
- memo[property] = {
527
- value: value.sub(/\s*!important\s*\z/, "").strip,
528
- important: important
461
+ result[property] = {
462
+ value: value,
463
+ important: important,
464
+ source: source
529
465
  }
530
466
  end
467
+ result
531
468
  end
532
469
 
533
- def merge_inline_style!(node, declarations)
534
- existing = parse_css_declarations(node["style"].to_s)
470
+ def merge_inline_declarations!(existing, declarations, touched_properties)
535
471
  declarations.each do |property, value|
536
472
  merged = merge_css_declaration(existing[property], value)
537
473
  next if merged.equal?(existing[property])
538
474
 
539
475
  existing.delete(property)
540
476
  existing[property] = merged
477
+ touched_properties << property
541
478
  end
542
- normalize_background_fallbacks!(node, existing)
543
- sync_html_attributes!(node, existing)
544
- node["style"] = serialize_css_declarations(existing)
545
479
  end
546
480
 
547
- def normalize_background_fallbacks!(node, declarations)
548
- background_image = declaration_value(declarations["background-image"])
549
- if background_image && !background_image.empty?
550
- declarations.delete("background") if syncable_background?(declaration_value(declarations["background"]))
551
- return
552
- end
553
-
554
- background_color = declaration_value(declarations["background-color"])
555
- return if background_color.nil? || background_color.empty?
556
-
557
- if syncable_background?(declaration_value(declarations["background"]))
558
- declarations["background"] = {
559
- value: background_color,
560
- important: declarations.fetch("background-color", {}).fetch(:important, false)
561
- }
562
- end
481
+ def finalize_inline_style!(node, declarations, touched_properties)
482
+ sync_html_attributes!(node, declarations, touched_properties)
483
+ node["style"] = serialize_css_declarations(declarations)
563
484
  end
564
485
 
565
486
  # Sync HTML attributes from inlined CSS declarations.
@@ -575,15 +496,19 @@ module MjmlRb
575
496
  "text-align" => "align",
576
497
  "vertical-align" => "valign"
577
498
  }.freeze
499
+ HTML_ATTRIBUTE_SYNC_PROPERTIES = Set.new((%w[width height] + STYLE_TO_ATTRIBUTE.keys)).freeze
578
500
 
579
- def sync_html_attributes!(node, declarations)
501
+ def sync_html_attributes!(node, declarations, touched_properties = nil)
580
502
  tag = node.name.downcase
581
503
 
582
504
  # Sync width/height on TABLE, TD, TH, IMG
583
505
  if WIDTH_HEIGHT_ELEMENTS.include?(tag)
584
506
  %w[width height].each do |prop|
507
+ next if touched_properties && !touched_properties.include?(prop)
508
+
585
509
  css_value = declaration_value(declarations[prop])
586
510
  next if css_value.nil? || css_value.empty?
511
+ next if tag == "img" && prop == "width" && css_value.include?("%")
587
512
 
588
513
  # Convert CSS px values to plain numbers for HTML attributes;
589
514
  # keep other values (auto, %) as-is.
@@ -595,6 +520,8 @@ module MjmlRb
595
520
  # Sync style-to-attribute mappings on table elements
596
521
  if TABLE_ELEMENTS.include?(tag)
597
522
  STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
523
+ next if touched_properties && !touched_properties.include?(css_prop)
524
+
598
525
  css_value = declaration_value(declarations[css_prop])
599
526
  next if css_value.nil? || css_value.empty?
600
527
 
@@ -615,26 +542,10 @@ module MjmlRb
615
542
  end
616
543
  end
617
544
 
618
- def syncable_background?(value)
619
- return true if value.nil? || value.empty?
620
-
621
- normalized = value.downcase
622
- !normalized.include?("url(") &&
623
- !normalized.include?("gradient(") &&
624
- !normalized.include?("/") &&
625
- !normalized.include?(" no-repeat") &&
626
- !normalized.include?(" repeat") &&
627
- !normalized.include?(" fixed") &&
628
- !normalized.include?(" scroll") &&
629
- !normalized.include?(" center") &&
630
- !normalized.include?(" top") &&
631
- !normalized.include?(" bottom") &&
632
- !normalized.include?(" left") &&
633
- !normalized.include?(" right")
634
- end
635
-
636
545
  def merge_css_declaration(existing, incoming)
637
546
  return incoming if existing.nil?
547
+ return incoming if incoming[:important] && !existing[:important]
548
+ return existing if existing[:source] == :inline
638
549
  return existing if existing[:important] && !incoming[:important]
639
550
 
640
551
  incoming
@@ -645,13 +556,49 @@ module MjmlRb
645
556
  end
646
557
 
647
558
  def serialize_css_declarations(declarations)
648
- declarations.map do |property, declaration|
649
- value = declaration[:value]
650
- value = "#{value} !important" if declaration[:important]
651
- "#{property}: #{value}"
559
+ ordered_css_declarations(declarations).map do |property, declaration|
560
+ "#{property}: #{declaration[:value]}"
652
561
  end.join("; ")
653
562
  end
654
563
 
564
+ SHORTHAND_LONGHAND_FAMILIES = {
565
+ "background" => /\Abackground-/,
566
+ "border" => /\Aborder(?:-(?!collapse|spacing)[a-z-]+)?\z/,
567
+ "border-radius" => /\Aborder-(?:top|bottom)-(?:left|right)-radius\z/,
568
+ "font" => /\Afont-/,
569
+ "list-style" => /\Alist-style-/,
570
+ "margin" => /\Amargin-(?:top|right|bottom|left)\z/,
571
+ "padding" => /\Apadding-(?:top|right|bottom|left)\z/
572
+ }.freeze
573
+
574
+ def ordered_css_declarations(declarations)
575
+ ordered = declarations.to_a
576
+
577
+ SHORTHAND_LONGHAND_FAMILIES.each do |shorthand, longhand_pattern|
578
+ family_indexes = ordered.each_index.select do |index|
579
+ property = ordered[index][0]
580
+ property == shorthand || property.match?(longhand_pattern)
581
+ end
582
+ next if family_indexes.length < 2
583
+
584
+ family_entries = family_indexes.map.with_index do |declaration_index, original_family_index|
585
+ [ordered[declaration_index], original_family_index]
586
+ end
587
+ next unless family_entries.any? { |((_, declaration), _)| declaration[:important] }
588
+ next unless family_entries.any? { |((_, declaration), _)| !declaration[:important] }
589
+
590
+ reordered_entries = family_entries.sort_by do |((_, declaration), original_family_index)|
591
+ [declaration[:important] ? 1 : 0, original_family_index]
592
+ end.map(&:first)
593
+
594
+ family_indexes.each_with_index do |declaration_index, reordered_index|
595
+ ordered[declaration_index] = reordered_entries[reordered_index]
596
+ end
597
+ end
598
+
599
+ ordered
600
+ end
601
+
655
602
  def append_component_head_styles(document, context)
656
603
  all_tags = collect_tag_names(document)
657
604
 
@@ -746,7 +693,7 @@ module MjmlRb
746
693
  if node.respond_to?(:children)
747
694
  node.children.map do |child|
748
695
  if child.text?
749
- escape_html(child.content.to_s)
696
+ serialize_text_content(escape_html(child.content.to_s))
750
697
  elsif child.comment?
751
698
  "<!--#{child.content}-->"
752
699
  else
@@ -765,7 +712,7 @@ module MjmlRb
765
712
  if node.respond_to?(:children)
766
713
  node.children.map do |child|
767
714
  if child.text?
768
- child.content.to_s
715
+ serialize_text_content(child.content.to_s)
769
716
  elsif child.comment?
770
717
  "<!--#{child.content}-->"
771
718
  else
@@ -777,15 +724,52 @@ module MjmlRb
777
724
  end
778
725
  end
779
726
 
727
+ def annotate_raw_html(content)
728
+ return content if content.nil? || content.empty? || !content.include?("<")
729
+ content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
730
+ tag_name = Regexp.last_match(1)
731
+ attrs = Regexp.last_match(2).to_s
732
+ closing = Regexp.last_match(3).to_s
733
+ "<#{tag_name}#{attrs} data-mjml-raw=\"true\"#{closing}>"
734
+ end
735
+ end
736
+
737
+ def strip_internal_raw_markers(html)
738
+ html.gsub(/\sdata-mjml-raw=(['"])true\1/, "")
739
+ end
740
+
741
+ def preserve_raw_tag_spacing(html)
742
+ html.gsub(
743
+ /(<[^>]+data-mjml-raw=(['"])true\2[^>]*>)([ \t]+)(<[^\/!][^>]+data-mjml-raw=(['"])true\5[^>]*>)/
744
+ ) do
745
+ "#{Regexp.last_match(1)}#{encode_whitespace_entities(Regexp.last_match(3))}#{Regexp.last_match(4)}"
746
+ end
747
+ end
748
+
749
+ def encode_whitespace_entities(text)
750
+ text.to_s.gsub(" ", "&#32;").gsub("\t", "&#9;")
751
+ end
752
+
780
753
  def serialize_node(node)
781
754
  attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
782
755
  return "<#{node.tag_name}#{attrs} />" if node.children.empty? && html_void_tag?(node.tag_name)
783
756
  return "<#{node.tag_name}#{attrs}></#{node.tag_name}>" if node.children.empty?
784
757
 
785
- inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
758
+ inner = node.children.map { |child| child.text? ? serialize_text_content(child.content.to_s) : serialize_node(child) }.join
786
759
  "<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
787
760
  end
788
761
 
762
+ def serialize_text_content(text)
763
+ value = text.to_s
764
+ return value unless significant_whitespace_text?(value)
765
+
766
+ value.gsub(" ", "&#32;").gsub("\t", "&#9;")
767
+ end
768
+
769
+ def significant_whitespace_text?(text)
770
+ !text.empty? && text.strip.empty? && !text.match?(/[\r\n]/)
771
+ end
772
+
789
773
  def html_void_tag?(tag_name)
790
774
  HTML_VOID_TAGS.include?(tag_name.to_s.downcase)
791
775
  end
@@ -77,8 +77,8 @@ module MjmlRb
77
77
  end
78
78
 
79
79
  def validate_allowed_children(node, errors)
80
- # Ending-tag components treat content as raw HTML; REXML still parses
81
- # children structurally, so skip child validation for those tags.
80
+ # Ending-tag components treat content as raw HTML; the XML parser still
81
+ # parses children structurally, so skip child validation for those tags.
82
82
  return if MjmlRb.component_registry.ending_tags.include?(node.tag_name)
83
83
 
84
84
  allowed = MjmlRb.component_registry.dependency_rules[node.tag_name]
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.4.3".freeze
2
+ VERSION = "0.5.0".freeze
3
3
  end
data/mjml-rb.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.summary = "Ruby implementation of the MJML toolchain"
10
10
  spec.description = "Ruby-first MJML compiler API and CLI with compatibility-focused behavior."
11
11
  spec.license = "MIT"
12
- spec.required_ruby_version = ">= 3.0"
12
+ spec.required_ruby_version = ">= 3.3"
13
13
 
14
14
  spec.homepage = "https://github.com/faraquet/mjml-rb"
15
15
  spec.files = Dir.chdir(__dir__) do
@@ -18,6 +18,6 @@ Gem::Specification.new do |spec|
18
18
  spec.bindir = "bin"
19
19
  spec.executables = ["mjml"]
20
20
  spec.require_paths = ["lib"]
21
+ spec.add_dependency "css_parser", ">= 1.17"
21
22
  spec.add_dependency "nokogiri", ">= 1.13"
22
- spec.add_dependency "rexml", ">= 3.2.5"
23
23
  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.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -10,33 +10,33 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: nokogiri
13
+ name: css_parser
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.13'
18
+ version: '1.17'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '1.13'
25
+ version: '1.17'
26
26
  - !ruby/object:Gem::Dependency
27
- name: rexml
27
+ name: nokogiri
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 3.2.5
32
+ version: '1.13'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 3.2.5
39
+ version: '1.13'
40
40
  description: Ruby-first MJML compiler API and CLI with compatibility-focused behavior.
41
41
  email:
42
42
  - andreiandriichuk@gmail.com
@@ -99,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
99
  requirements:
100
100
  - - ">="
101
101
  - !ruby/object:Gem::Version
102
- version: '3.0'
102
+ version: '3.3'
103
103
  required_rubygems_version: !ruby/object:Gem::Requirement
104
104
  requirements:
105
105
  - - ">="