mjml-rb 0.4.4 → 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: 750fc042cf9ff6273cebd5907a096e6fc304d28a49b0872cf6eec93f9818e148
4
- data.tar.gz: 138a44a38b430267f646cd771b4a9fa01e3c0e9609083291de5270280db36650
3
+ metadata.gz: 5b2c747b6f5afbc288db3ddc0afec65b3f0ff5a208bb2958abf8c9869a76790c
4
+ data.tar.gz: edd5d4b07c99bfd971bafadf42b11a66c0ace207cdfe835fa2643c64fc4297f9
5
5
  SHA512:
6
- metadata.gz: 5d83da073643a75183303c6a14a6b0cbd2e06d864875c80a4bbfc65ef5c857b5a4ffb2971a0465cc58e46b400efe8a1d760994f643a420da0d549837799f8bf5
7
- data.tar.gz: 1bd7d0c899cb6de244ab534d0b598b12f437c53ef556cb7fd5c56880ec4d6fc164dedb493ad49e45e60b52e5bd9180dd725b7f91790eb2952440df5d5219bb3a
6
+ metadata.gz: 8989b98cecdb8b63d843f87dd980c409581fe11220cbb439d183768259027a04536799e1d9bc43f5f8760aef216c208ff4e0d74d01e13008f65c749f190fc99c
7
+ data.tar.gz: 83d310d5e9f5f92bf97da53086c56bca1baed0e9bd7495f7c8595f888b6343621e76b6dc98292db801a28c169a85720be6e8afcd674928ec9a3a4a5947d727fd
@@ -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
@@ -23,7 +21,6 @@ module MjmlRb
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
28
25
  ROOT_LEVEL_HEAD_TAGS = %w[
29
26
  mj-attributes
@@ -56,11 +53,12 @@ module MjmlRb
56
53
  xml = normalize_html_void_tags(xml)
57
54
  xml = expand_includes(xml, opts) unless opts[:ignore_includes]
58
55
 
59
- xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
60
- doc = Document.new(xml)
56
+ xml = sanitize_bare_ampersands(xml)
57
+ xml = replace_html_entities(xml)
58
+ doc = Nokogiri::XML(xml) { |config| config.strict }
61
59
  normalize_root_head_elements(doc)
62
60
  element_to_ast(doc.root, keep_comments: opts[:keep_comments])
63
- rescue ParseException => e
61
+ rescue Nokogiri::XML::SyntaxError => e
64
62
  raise ParseError.new("XML parse error: #{e.message}")
65
63
  end
66
64
 
@@ -85,18 +83,18 @@ module MjmlRb
85
83
  def expand_includes(xml, options, included_in = [])
86
84
  xml = wrap_ending_tags_in_cdata(xml)
87
85
  xml = normalize_html_void_tags(xml)
88
- doc = Document.new(sanitize_bare_ampersands(xml))
89
- includes = XPath.match(doc, "//mj-include")
86
+ doc = parse_xml(sanitize_bare_ampersands(xml))
87
+ includes = doc.xpath("//mj-include")
90
88
  return xml if includes.empty?
91
89
 
92
90
  css_includes = []
93
91
  head_includes = []
94
92
 
95
93
  includes.reverse_each do |include_node|
96
- path_attr = include_node.attributes["path"]
94
+ path_attr = include_node["path"]
97
95
  raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
98
96
 
99
- include_type = include_node.attributes["type"].to_s
97
+ include_type = include_node["type"].to_s
100
98
  parent = include_node.parent
101
99
 
102
100
  resolved_path = begin
@@ -114,7 +112,7 @@ module MjmlRb
114
112
  tag_name: "mj-include",
115
113
  file: display_path
116
114
  }
117
- parent.delete(include_node)
115
+ include_node.remove
118
116
  next
119
117
  end
120
118
 
@@ -125,9 +123,9 @@ module MjmlRb
125
123
 
126
124
  if include_type == "css"
127
125
  # CSS includes get collected and added to mj-head later
128
- css_inline = include_node.attributes["css-inline"].to_s
126
+ css_inline = include_node["css-inline"].to_s
129
127
  css_includes << { content: include_content, inline: css_inline == "inline" }
130
- parent.delete(include_node)
128
+ include_node.remove
131
129
  next
132
130
  end
133
131
 
@@ -145,19 +143,20 @@ module MjmlRb
145
143
  body_children
146
144
  end
147
145
 
148
- insert_before = include_node
149
- replacement_nodes = if replacement.is_a?(Array)
150
- replacement
151
- else
152
- fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
153
- fragment.root.children.map { |child| deep_clone(child) }
154
- end
155
-
156
- replacement_nodes.each do |child|
157
- annotate_include_source(child, resolved_path) if child.is_a?(Element)
158
- 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
159
158
  end
160
- parent.delete(include_node)
159
+ include_node.remove
161
160
  end
162
161
 
163
162
  inject_head_includes(doc, head_includes) unless head_includes.empty?
@@ -167,11 +166,7 @@ module MjmlRb
167
166
  inject_css_includes(doc, css_includes)
168
167
  end
169
168
 
170
- output = +""
171
- doc.write(output)
172
- output
173
- rescue ParseException => e
174
- 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)
175
170
  end
176
171
 
177
172
  def prepare_mjml_include_document(content)
@@ -182,23 +177,23 @@ module MjmlRb
182
177
  end
183
178
 
184
179
  def extract_mjml_include_children(xml)
185
- include_doc = Document.new(sanitize_bare_ampersands(xml))
180
+ include_doc = parse_xml(sanitize_bare_ampersands(xml))
186
181
  normalize_root_head_elements(include_doc)
187
182
  mjml_root = include_doc.root
188
183
  return [[], []] unless mjml_root&.name == "mjml"
189
184
 
190
- body = XPath.first(mjml_root, "mj-body")
191
- head = XPath.first(mjml_root, "mj-head")
185
+ body = mjml_root.at_xpath("mj-body")
186
+ head = mjml_root.at_xpath("mj-head")
192
187
 
193
188
  [
194
- body ? body.children.map { |child| deep_clone(child) } : [],
195
- 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) } : []
196
191
  ]
197
192
  end
198
193
 
199
194
  def inject_head_includes(doc, head_includes)
200
195
  head = ensure_head(doc)
201
- head_includes.each { |child| head.add(child) }
196
+ head_includes.each { |child| head.add_child(child) }
202
197
  end
203
198
 
204
199
  def inject_css_includes(doc, css_includes)
@@ -206,10 +201,10 @@ module MjmlRb
206
201
 
207
202
  # Add each CSS include as an mj-style element
208
203
  css_includes.each do |css_include|
209
- style_node = Element.new("mj-style")
210
- style_node.add_attribute("inline", "inline") if css_include[:inline]
211
- style_node.add(CData.new(css_include[:content]))
212
- 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)
213
208
  end
214
209
  end
215
210
 
@@ -217,15 +212,15 @@ module MjmlRb
217
212
  mjml_root = doc.root
218
213
  return unless mjml_root
219
214
 
220
- head = XPath.first(mjml_root, "mj-head")
215
+ head = mjml_root.at_xpath("mj-head")
221
216
  return head if head
222
217
 
223
- head = Element.new("mj-head")
224
- body = XPath.first(mjml_root, "mj-body")
218
+ head = Nokogiri::XML::Node.new("mj-head", doc)
219
+ body = mjml_root.at_xpath("mj-body")
225
220
  if body
226
- mjml_root.insert_before(body, head)
221
+ body.add_previous_sibling(head)
227
222
  else
228
- mjml_root.add(head)
223
+ mjml_root.add_child(head)
229
224
  end
230
225
  head
231
226
  end
@@ -239,14 +234,14 @@ module MjmlRb
239
234
  root_head_elements = []
240
235
 
241
236
  mjml_root.children.each do |child|
242
- next unless child.is_a?(Element)
237
+ next unless child.element?
243
238
 
244
239
  if child.name == "mj-head"
245
240
  head_nodes << child
246
- child.children.each { |head_child| normalized_head_children << deep_clone(head_child) }
241
+ child.children.each { |head_child| normalized_head_children << head_child.dup(1) }
247
242
  elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
248
243
  root_head_elements << child
249
- normalized_head_children << deep_clone(child)
244
+ normalized_head_children << child.dup(1)
250
245
  end
251
246
  end
252
247
 
@@ -255,11 +250,11 @@ module MjmlRb
255
250
  head = head_nodes.first || ensure_head(doc)
256
251
  return unless head
257
252
 
258
- head.children.to_a.each { |child| head.delete(child) }
259
- normalized_head_children.each { |child| head.add(child) }
253
+ head.children.each(&:remove)
254
+ normalized_head_children.each { |child| head.add_child(child) }
260
255
 
261
- root_head_elements.each { |child| mjml_root.delete(child) }
262
- head_nodes.drop(1).each { |extra_head| mjml_root.delete(extra_head) }
256
+ root_head_elements.each(&:remove)
257
+ head_nodes.drop(1).each(&:remove)
263
258
  end
264
259
 
265
260
  def strip_xml_declaration(content)
@@ -357,40 +352,39 @@ module MjmlRb
357
352
  end
358
353
 
359
354
  # Escape bare "&" that are not part of a valid XML entity reference
360
- # (e.g. &amp; &#123; &#x1F;). This lets REXML parse HTML-ish content
361
- # 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.
362
357
  def sanitize_bare_ampersands(content)
363
358
  content.gsub(BARE_AMPERSAND_RE, "&amp;")
364
359
  end
365
360
 
366
- # Adds data-mjml-line attributes to MJML tags so line numbers survive
367
- # REXML parsing (which doesn't expose source positions).
368
- # Skips content inside CDATA sections to avoid modifying raw HTML.
369
- def annotate_line_numbers(xml)
370
- line = 1
371
- xml.gsub(LINE_ANNOTATION_RE) do
372
- if Regexp.last_match(1) # newline
373
- line += 1
374
- "\n"
375
- elsif Regexp.last_match(2) # CDATA section — count newlines, pass through
376
- line += Regexp.last_match(2).count("\n")
377
- Regexp.last_match(2)
378
- else # opening MJML tag
379
- "#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
380
- 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
381
373
  end
382
374
  end
383
375
 
384
- # 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.
385
379
  # Only sets the attribute on elements that don't already have it (preserving
386
380
  # deeper include annotations from recursive expansion).
387
381
  def annotate_include_source(element, file_path)
388
- return unless element.is_a?(Element)
382
+ return unless element.element?
389
383
 
390
- if (element.name.start_with?("mj-") || element.name == "mjml") && !element.attributes["data-mjml-file"]
391
- 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
392
386
  end
393
- element.each_element { |child| annotate_include_source(child, file_path) }
387
+ element.element_children.each { |child| annotate_include_source(child, file_path) }
394
388
  end
395
389
 
396
390
  def resolve_include_path(include_path, actual_path, file_path)
@@ -408,38 +402,24 @@ module MjmlRb
408
402
  raise Errno::ENOENT, include_path
409
403
  end
410
404
 
411
- def deep_clone(node)
412
- case node
413
- when Element
414
- clone = Element.new(node.name)
415
- node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
416
- node.children.each { |child| clone.add(deep_clone(child)) }
417
- clone
418
- when Text
419
- Text.new(node.value)
420
- when Comment
421
- Comment.new(node.string)
422
- else
423
- node
424
- end
425
- end
426
-
427
405
  def element_to_ast(element, keep_comments:)
428
406
  raise ParseError, "Missing XML root element" unless element
429
407
 
430
- # Extract metadata annotations (added by annotate_line_numbers / annotate_include_source)
408
+ # Extract metadata annotations (added by annotate_include_source)
431
409
  # and strip them from the public attributes hash.
432
- meta_line = element.attributes["data-mjml-line"]&.to_i
433
- meta_file = element.attributes["data-mjml-file"]
434
- attrs = element.attributes.each_with_object({}) do |(name, val), h|
435
- 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-")
436
416
  end
437
417
  attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
438
418
 
439
419
  # For ending-tag elements whose content was wrapped in CDATA, store
440
420
  # the raw HTML directly as content instead of parsing structurally.
441
421
  if ENDING_TAGS_FOR_CDATA.include?(element.name)
442
- 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
443
423
  return AstNode.new(
444
424
  tag_name: element.name,
445
425
  attributes: attrs,
@@ -451,17 +431,16 @@ module MjmlRb
451
431
  end
452
432
 
453
433
  children = element.children.each_with_object([]) do |child, memo|
454
- case child
455
- when Element
434
+ if child.element?
456
435
  memo << element_to_ast(child, keep_comments: keep_comments)
457
- when Text
458
- text = child.value
436
+ elsif child.text? || child.cdata?
437
+ text = child.content
459
438
  next if text.empty?
460
439
  next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
461
440
 
462
441
  memo << AstNode.new(tag_name: "#text", content: text)
463
- when Comment
464
- memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
442
+ elsif child.comment?
443
+ memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
465
444
  end
466
445
  end
467
446
 
@@ -474,6 +453,13 @@ module MjmlRb
474
453
  )
475
454
  end
476
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
+
477
463
  def ignorable_whitespace_text?(text, parent_element_name:)
478
464
  return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
479
465
 
@@ -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"
@@ -417,115 +418,53 @@ module MjmlRb
417
418
  end
418
419
 
419
420
  def parse_inline_css_rules(css)
420
- stripped_css = strip_css_comments(css.to_s)
421
- plain_css, at_rules_css = extract_css_at_rules(stripped_css)
422
-
423
- rules = plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
424
- selectors = selector_group.split(",").map(&:strip).reject(&:empty?)
425
- declaration_map = parse_css_declarations(declarations)
426
- 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
427
438
  end
428
439
 
429
440
  # Sort rules by specificity (ascending). With the "last wins" merge
430
441
  # strategy, higher-specificity rules applied later correctly override
431
442
  # lower-specificity ones — matching CSS cascade behavior.
432
- sorted = rules.each_with_index
433
- .sort_by { |(selector, _), idx| [css_specificity(selector), idx] }
434
- .map(&:first)
435
-
436
- [sorted, at_rules_css]
437
- end
443
+ sorted = rules.sort_by.with_index { |(_, _, spec), idx| [spec, idx] }
444
+ .map { |sel, decl, _| [sel, decl] }
438
445
 
439
- def strip_css_comments(css)
440
- css.gsub(%r{/\*.*?\*/}m, "")
446
+ [sorted, ""]
441
447
  end
442
448
 
443
- # Separates @-rules (@media, @font-face, etc.) from plain CSS selectors.
444
- # Returns [plain_css, at_rules_css]. The at_rules_css can be injected as a
445
- # <style> block since @-rules cannot be inlined into style attributes.
446
- def extract_css_at_rules(css)
447
- plain = +""
448
- at_rules = +""
449
- index = 0
450
-
451
- while index < css.length
452
- if css[index] == "@"
453
- brace_index = css.index("{", index)
454
- semicolon_index = css.index(";", index)
455
-
456
- # Simple @-rules like @import or @charset end with semicolon
457
- if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
458
- at_rules << css[index..semicolon_index] << "\n"
459
- index = semicolon_index + 1
460
- next
461
- end
462
-
463
- # Block @-rules like @media, @font-face have nested braces
464
- if brace_index
465
- depth = 1
466
- cursor = brace_index + 1
467
- while cursor < css.length && depth.positive?
468
- depth += 1 if css[cursor] == "{"
469
- depth -= 1 if css[cursor] == "}"
470
- cursor += 1
471
- end
472
- at_rules << css[index...cursor] << "\n"
473
- index = cursor
474
- next
475
- end
476
- end
477
-
478
- plain << css[index]
479
- index += 1
480
- end
481
-
482
- [plain, at_rules]
483
- end
484
-
485
- # Calculates CSS specificity as a comparable [a, b, c] tuple:
486
- # a = number of ID selectors (#id)
487
- # b = number of class selectors (.class), attribute selectors ([attr]),
488
- # and pseudo-classes (:hover, :lang())
489
- # c = number of type selectors (div, p) and pseudo-elements (::before)
490
449
  def css_specificity(selector)
491
- s = selector.to_s
492
-
493
- # a: ID selectors
494
- a = s.scan(/#[\w-]+/).length
495
-
496
- # b: class selectors + attribute selectors + pseudo-classes
497
- b = s.scan(/\.[\w-]+/).length +
498
- s.scan(/\[[^\]]*\]/).length +
499
- s.scan(/:(?!:)[\w-]+/).length
500
-
501
- # c: type selectors + pseudo-elements
502
- # Strip everything except element names and combinators
503
- cleaned = s
504
- .gsub(/#[\w-]+/, "") # remove IDs
505
- .gsub(/\.[\w-]+/, "") # remove classes
506
- .gsub(/\[[^\]]*\]/, "") # remove attribute selectors
507
- .gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
508
- .gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
509
- .gsub(/[>+~]/, " ") # combinators → spaces
510
- .gsub(/\*/, "") # universal selector has no specificity
511
- c = cleaned.split.reject(&:empty?).length +
512
- s.scan(/::[\w-]+/).length
513
-
514
- [a, b, c]
450
+ CssParser.calculate_specificity(selector.to_s)
515
451
  end
516
452
 
517
453
  def parse_css_declarations(declarations, source: :css)
518
- declarations.split(";").each_with_object({}) do |entry, memo|
519
- property, value = entry.split(":", 2).map { |part| part&.strip }
454
+ return {} if declarations.nil? || declarations.to_s.strip.empty?
455
+
456
+ rule_set = CssParser::RuleSet.new(block: declarations.to_s)
457
+ result = {}
458
+ rule_set.each_declaration do |property, value, important|
520
459
  next if property.nil? || property.empty? || value.nil? || value.empty?
521
460
 
522
- important = value.match?(/\s*!important\s*\z/)
523
- memo[property] = {
524
- value: value.sub(/\s*!important\s*\z/, "").strip,
461
+ result[property] = {
462
+ value: value,
525
463
  important: important,
526
464
  source: source
527
465
  }
528
466
  end
467
+ result
529
468
  end
530
469
 
531
470
  def merge_inline_declarations!(existing, declarations, touched_properties)
@@ -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.4".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.4
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
  - - ">="