mjml-rb 0.5.2 → 0.6.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +60 -22
  4. data/THIRD_PARTY_LICENSES.md +25 -0
  5. data/docs/ARCHITECTURE.md +363 -0
  6. data/docs/MIGRATING_TO_V5.md +112 -0
  7. data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
  8. data/docs/PARITY_AUDIT.md +124 -0
  9. data/docs/RELEASING.md +35 -0
  10. data/docs/USAGE.md +1258 -0
  11. data/docs/releases/v0.6.0.md +63 -0
  12. data/lib/mjml-rb/cli.rb +43 -31
  13. data/lib/mjml-rb/column_gutter.rb +81 -0
  14. data/lib/mjml-rb/compiler.rb +32 -122
  15. data/lib/mjml-rb/component_registry.rb +11 -1
  16. data/lib/mjml-rb/components/accordion.rb +11 -9
  17. data/lib/mjml-rb/components/base.rb +1 -1
  18. data/lib/mjml-rb/components/body.rb +6 -3
  19. data/lib/mjml-rb/components/carousel.rb +2 -2
  20. data/lib/mjml-rb/components/carousel_image.rb +2 -2
  21. data/lib/mjml-rb/components/column.rb +16 -11
  22. data/lib/mjml-rb/components/group.rb +13 -4
  23. data/lib/mjml-rb/components/hero.rb +7 -2
  24. data/lib/mjml-rb/components/image.rb +1 -3
  25. data/lib/mjml-rb/components/raw.rb +1 -1
  26. data/lib/mjml-rb/components/section.rb +23 -8
  27. data/lib/mjml-rb/components/social.rb +18 -27
  28. data/lib/mjml-rb/components/table.rb +1 -1
  29. data/lib/mjml-rb/config_file.rb +25 -15
  30. data/lib/mjml-rb/dependencies.rb +1 -0
  31. data/lib/mjml-rb/formatter.rb +113 -0
  32. data/lib/mjml-rb/node_compat.rb +4 -0
  33. data/lib/mjml-rb/options.rb +81 -0
  34. data/lib/mjml-rb/parser.rb +138 -94
  35. data/lib/mjml-rb/renderer.rb +50 -76
  36. data/lib/mjml-rb/result.rb +3 -2
  37. data/lib/mjml-rb/validator.rb +14 -33
  38. data/lib/mjml-rb/version.rb +2 -1
  39. data/lib/mjml-rb.rb +2 -2
  40. data/mjml-rb.gemspec +2 -2
  41. metadata +19 -1
@@ -0,0 +1,81 @@
1
+ module MjmlRb
2
+ # Shared by the library, JSON configuration and CLI. Canonicalization happens
3
+ # before merging layers, so aliases never accidentally change precedence.
4
+ module Options
5
+ DEFAULTS = {
6
+ beautify: false, minify: false, keep_comments: true,
7
+ ignore_includes: true, printer_support: false, preprocessors: [],
8
+ validation_level: "soft", file_path: ".", actual_path: ".",
9
+ minify_options: {}, sanitize_styles: false,
10
+ template_syntax: [{prefix: "{{", suffix: "}}"}, {prefix: "[[", suffix: "]]"}],
11
+ allow_mixed_syntax: false, use_mjml_config_options: false
12
+ }.freeze
13
+ KEYS = (DEFAULTS.keys + %i[lang dir fonts include_path mjml_config_path juice_options juice_preserve_tags]).freeze
14
+
15
+ def self.key(key)
16
+ key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase.to_sym
17
+ end
18
+
19
+ def self.normalize(options)
20
+ raise ArgumentError, "Options must be an object" unless options.is_a?(Hash)
21
+
22
+ result = options.each_with_object({}) do |(name, value), normalized|
23
+ canonical = key(name)
24
+ if canonical == :allow_includes
25
+ canonical = :ignore_includes
26
+ raise ArgumentError, "allowIncludes must be true or false" unless [true, false].include?(value)
27
+ value = !value
28
+ end
29
+ if normalized.key?(canonical) && normalized[canonical] != value
30
+ raise ArgumentError, "Conflicting values for #{canonical}"
31
+ end
32
+ normalized[canonical] = value
33
+ end
34
+ unknown = result.keys - KEYS
35
+ raise ArgumentError, "Unknown option(s): #{unknown.join(', ')}" unless unknown.empty?
36
+ if result.key?(:validation_level) && !%w[soft strict skip].include?(result[:validation_level].to_s)
37
+ raise ArgumentError, "Invalid validation_level: #{result[:validation_level].inspect}"
38
+ end
39
+ %i[beautify minify keep_comments ignore_includes printer_support sanitize_styles allow_mixed_syntax use_mjml_config_options].each do |name|
40
+ next unless result.key?(name)
41
+ raise ArgumentError, "#{name} must be true or false" unless [true, false].include?(result[name])
42
+ end
43
+ %i[juice_options juice_preserve_tags].each do |name|
44
+ next unless result.key?(name)
45
+ raise ArgumentError, "Unsupported option: #{name}; the Ruby CSS inliner does not implement Juice options" unless result[name].nil? || result[name] == {}
46
+ end
47
+ if result.key?(:include_path) && !(result[:include_path].is_a?(String) || (result[:include_path].is_a?(Array) && result[:include_path].all? { |p| p.is_a?(String) }))
48
+ raise ArgumentError, "include_path must be a string or an array of strings"
49
+ end
50
+ if result.key?(:preprocessors) && !(result[:preprocessors].is_a?(Array) && result[:preprocessors].all? { |processor| processor.respond_to?(:call) })
51
+ raise ArgumentError, "preprocessors must be an array of callables"
52
+ end
53
+ if result.key?(:template_syntax)
54
+ syntaxes = result[:template_syntax]
55
+ valid = syntaxes.is_a?(Array) && syntaxes.all? do |syntax|
56
+ syntax.is_a?(Hash) && %i[prefix suffix].all? do |part|
57
+ value = syntax[part] || syntax[part.to_s]
58
+ value.is_a?(String) && !value.empty?
59
+ end
60
+ end
61
+ raise ArgumentError, "template_syntax must be an array of non-empty prefix/suffix objects" unless valid
62
+ end
63
+ if result.key?(:minify_options)
64
+ value = result[:minify_options]
65
+ raise ArgumentError, "minify_options must be an object" unless value.is_a?(Hash)
66
+ result[:minify_options] = value.each_with_object({}) do |(name, setting), memo|
67
+ canonical = name.to_s.gsub(/[^a-z]/i, "").downcase
68
+ raise ArgumentError, "Unsupported minify_options key: #{name}" unless canonical == "minifycss"
69
+ raise ArgumentError, "minifyCss must be true or false" unless [true, false].include?(setting)
70
+ raise ArgumentError, "Conflicting minifyCss values" if memo.key?(:minify_css) && memo[:minify_css] != setting
71
+ memo[:minify_css] = setting
72
+ end
73
+ end
74
+ result
75
+ end
76
+
77
+ def self.merge(base, overrides)
78
+ base.merge(overrides).merge(minify_options: (base[:minify_options] || {}).merge(overrides[:minify_options] || {}))
79
+ end
80
+ end
81
+ end
@@ -1,4 +1,6 @@
1
1
  require "nokogiri"
2
+ require "uri"
3
+ require_relative "options"
2
4
 
3
5
  require_relative "node_compat"
4
6
 
@@ -6,17 +8,10 @@ module MjmlRb
6
8
  class Parser
7
9
  HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
8
10
 
9
- # Ending-tag components whose inner HTML is preserved as raw content via CDATA
10
- # wrapping, matching upstream npm's endingTag behavior. mj-table is excluded
11
- # because its component needs structural AST access for attribute normalization.
12
- # mj-carousel-image is excluded because it has no meaningful inner content.
13
- ENDING_TAGS_FOR_CDATA = %w[
14
- mj-accordion-text mj-accordion-title mj-button
15
- mj-navbar-link mj-raw mj-text
16
- ].freeze
11
+ ParseResult = Struct.new(:ast, :diagnostics, :dependencies, keyword_init: true)
17
12
 
18
- # Pre-compiled regex patterns to avoid rebuilding on every call
19
- ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
13
+ # mj-table retains structural access for its attribute normalization.
14
+ STRUCTURAL_ENDING_TAGS = %w[mj-table mj-carousel-image].freeze
20
15
 
21
16
  VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
22
17
  VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
@@ -33,46 +28,69 @@ module MjmlRb
33
28
  ].freeze
34
29
 
35
30
  class ParseError < StandardError
36
- attr_reader :line
31
+ attr_reader :line, :file, :dependencies
37
32
 
38
- def initialize(message, line: nil)
33
+ def initialize(message, line: nil, file: nil, dependencies: [])
39
34
  super(message)
40
35
  @line = line
36
+ @file = file
37
+ @dependencies = dependencies
41
38
  end
42
39
  end
43
40
 
44
41
  # Errors collected during include expansion (missing files, etc.)
45
42
  # Read after calling #parse to retrieve non-fatal include issues.
46
- attr_reader :include_errors
43
+ attr_reader :include_errors, :dependencies
44
+
45
+ def parse_result(mjml, options = {})
46
+ worker = self.class.new
47
+ ast = worker.parse(mjml, options)
48
+ ParseResult.new(ast: ast, diagnostics: worker.include_errors.freeze, dependencies: worker.dependencies.uniq.freeze)
49
+ end
47
50
 
48
51
  def parse(mjml, options = {})
49
52
  @include_errors = []
53
+ @dependencies = []
54
+ @ending_tags = @ending_tag_open_re = nil
50
55
  opts = normalize_options(options)
51
56
  xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
52
57
  xml = wrap_ending_tags_in_cdata(xml)
53
- xml = normalize_html_void_tags(xml)
58
+ xml = outside_cdata(xml) { |part| normalize_html_void_tags(part) }
54
59
  xml = expand_includes(xml, opts) unless opts[:ignore_includes]
55
60
 
56
61
  xml = sanitize_bare_ampersands(xml)
57
62
  xml = replace_html_entities(xml)
58
63
  doc = Nokogiri::XML(xml) { |config| config.strict }
64
+ doc.xpath("//mj-include").each(&:remove) if opts[:ignore_includes]
59
65
  normalize_root_head_elements(doc)
60
66
  prepare_node(doc.root, keep_comments: opts[:keep_comments])
61
67
  doc.root
62
68
  rescue Nokogiri::XML::SyntaxError => e
63
- raise ParseError.new("XML parse error: #{e.message}")
69
+ raise xml_parse_error(e, opts[:actual_path])
64
70
  end
65
71
 
66
72
  private
67
73
 
68
74
  def normalize_options(options)
69
- {
70
- keep_comments: options[:keep_comments],
71
- preprocessors: Array(options[:preprocessors]),
72
- ignore_includes: !!options[:ignore_includes],
73
- file_path: options[:file_path] || ".",
74
- actual_path: options[:actual_path] || "."
75
- }
75
+ opts = Options::DEFAULTS.merge(Options.normalize(options))
76
+ base = File.expand_path(opts[:file_path])
77
+ base = File.dirname(base) if File.file?(base)
78
+ roots = [base] + Array(opts[:include_path]).map { |root| File.expand_path(root, base) }
79
+ opts.merge(file_path: base, allowed_roots: roots.filter_map { |root| File.realpath(root) rescue nil })
80
+ end
81
+
82
+ def ending_tags
83
+ @ending_tags ||= MjmlRb.component_registry.ending_tags - STRUCTURAL_ENDING_TAGS
84
+ end
85
+
86
+ def ending_tag_open_re
87
+ @ending_tag_open_re ||= /<(#{ending_tags.map { |tag| Regexp.escape(tag) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi
88
+ end
89
+
90
+ def outside_cdata(content)
91
+ content.split(/(<!\[CDATA\[.*?\]\]>)/m).map do |part|
92
+ part.start_with?("<![CDATA[") ? part : yield(part)
93
+ end.join
76
94
  end
77
95
 
78
96
  def apply_preprocessors(xml, preprocessors)
@@ -83,50 +101,55 @@ module MjmlRb
83
101
 
84
102
  def expand_includes(xml, options, included_in = [])
85
103
  xml = wrap_ending_tags_in_cdata(xml)
86
- xml = normalize_html_void_tags(xml)
104
+ xml = outside_cdata(xml) { |part| normalize_html_void_tags(part) }
87
105
  doc = parse_xml(sanitize_bare_ampersands(xml))
88
106
  includes = doc.xpath("//mj-include")
89
- return xml if includes.empty?
107
+ return xml if includes.empty? && included_in.empty?
108
+
109
+ annotate_include_source(doc.root, options[:actual_path]) unless included_in.empty?
90
110
 
91
- css_includes = []
92
111
  head_includes = []
93
112
 
94
- includes.reverse_each do |include_node|
113
+ includes.each do |include_node|
95
114
  path_attr = include_node["path"]
96
- raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
97
-
98
- include_type = include_node["type"].to_s
99
- parent = include_node.parent
100
-
101
- resolved_path = begin
102
- resolve_include_path(path_attr, options[:actual_path], options[:file_path])
103
- rescue Errno::ENOENT
104
- nil
115
+ if path_attr.to_s.empty?
116
+ raise ParseError.new("mj-include path is required", line: include_node.line, file: options[:actual_path], dependencies: @dependencies)
105
117
  end
106
118
 
107
- include_content = resolved_path ? File.read(resolved_path) : nil
119
+ include_type = include_node["type"].to_s
108
120
 
109
- if include_content.nil?
110
- display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
111
- @include_errors << {
112
- message: "mj-include fails to read file : #{path_attr} at #{display_path}",
113
- tag_name: "mj-include",
114
- file: display_path
115
- }
121
+ begin
122
+ resolved_path = resolve_include_path(path_attr, options)
123
+ @dependencies << resolved_path
124
+ include_content = File.read(resolved_path)
125
+ rescue IncludeDenied
126
+ @include_errors << include_diagnostic(include_node, options, "include-denied", "mj-include denied: #{path_attr}")
127
+ replacement = Nokogiri::XML::Node.new("mj-raw", doc)
128
+ replacement.add_child(Nokogiri::XML::CDATA.new(doc, "<!-- mj-include denied -->"))
129
+ include_node.replace(replacement)
130
+ next
131
+ rescue SystemCallError
132
+ @include_errors << include_diagnostic(include_node, options, "include", "mj-include fails to read file : #{path_attr} at #{resolved_path}")
116
133
  include_node.remove
117
134
  next
118
135
  end
119
136
 
120
137
  # Circular include detection
121
138
  if included_in.include?(resolved_path)
122
- raise ParseError, "Circular inclusion detected on file : #{resolved_path}"
139
+ raise ParseError.new("Circular inclusion detected on file : #{resolved_path}", line: include_node.line, file: options[:actual_path], dependencies: @dependencies)
123
140
  end
124
141
 
125
142
  if include_type == "css"
126
- # CSS includes get collected and added to mj-head later
127
- css_inline = include_node["css-inline"].to_s
128
- css_includes << { content: include_content, inline: css_inline == "inline" }
129
- include_node.remove
143
+ style = Nokogiri::XML::Node.new("mj-style", doc)
144
+ style["inline"] = "inline" if include_node["css-inline"] == "inline"
145
+ style.add_child(Nokogiri::XML::CDATA.new(doc, include_content))
146
+ annotate_include_source(style, resolved_path)
147
+ if include_node.parent.name == "mj-head"
148
+ include_node.replace(style)
149
+ else
150
+ ensure_head(doc).add_child(style)
151
+ include_node.remove
152
+ end
130
153
  next
131
154
  end
132
155
 
@@ -134,13 +157,13 @@ module MjmlRb
134
157
  %(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
135
158
  else
136
159
  prepared = prepare_mjml_include_document(include_content)
137
- prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(prepared))
160
+ prepared = wrap_ending_tags_in_cdata(prepared)
138
161
  expanded = expand_includes(prepared, options.merge(
139
162
  actual_path: resolved_path,
140
163
  file_path: File.dirname(resolved_path)
141
164
  ), included_in + [resolved_path])
142
165
  body_children, include_head_children = extract_mjml_include_children(expanded)
143
- head_includes.unshift(*include_head_children)
166
+ head_includes.push(*include_head_children)
144
167
  body_children
145
168
  end
146
169
 
@@ -162,12 +185,9 @@ module MjmlRb
162
185
 
163
186
  inject_head_includes(doc, head_includes) unless head_includes.empty?
164
187
 
165
- # Inject CSS includes into mj-head
166
- unless css_includes.empty?
167
- inject_css_includes(doc, css_includes)
168
- end
169
-
170
188
  doc.root.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
189
+ rescue Nokogiri::XML::SyntaxError => e
190
+ raise xml_parse_error(e, options[:actual_path])
171
191
  end
172
192
 
173
193
  def prepare_mjml_include_document(content)
@@ -197,18 +217,6 @@ module MjmlRb
197
217
  head_includes.each { |child| head.add_child(child) }
198
218
  end
199
219
 
200
- def inject_css_includes(doc, css_includes)
201
- head = ensure_head(doc)
202
-
203
- # Add each CSS include as an mj-style element
204
- css_includes.each do |css_include|
205
- style_node = Nokogiri::XML::Node.new("mj-style", doc)
206
- style_node["inline"] = "inline" if css_include[:inline]
207
- style_node.add_child(Nokogiri::XML::CDATA.new(doc, css_include[:content]))
208
- head.add_child(style_node)
209
- end
210
- end
211
-
212
220
  def ensure_head(doc)
213
221
  mjml_root = doc.root
214
222
  return unless mjml_root
@@ -281,7 +289,7 @@ module MjmlRb
281
289
  wrapped = +""
282
290
  cursor = 0
283
291
 
284
- while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
292
+ while (match = ending_tag_open_re.match(content, cursor))
285
293
  tag = match[1]
286
294
  attrs = match[2].to_s
287
295
  wrapped << content[cursor...match.begin(0)]
@@ -297,9 +305,7 @@ module MjmlRb
297
305
  if inner.include?("<![CDATA[")
298
306
  wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
299
307
  else
300
- # Pre-process content: normalize void tags and sanitize bare ampersands
301
- # before wrapping in CDATA, so the raw HTML is well-formed for output.
302
- prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
308
+ prepared = inner
303
309
  wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
304
310
  end
305
311
 
@@ -356,7 +362,7 @@ module MjmlRb
356
362
  # (e.g. &amp; &#123; &#x1F;). This lets the XML parser handle HTML-ish
357
363
  # content such as "Terms & Conditions" which is common in email templates.
358
364
  def sanitize_bare_ampersands(content)
359
- content.gsub(BARE_AMPERSAND_RE, "&amp;")
365
+ outside_cdata(content) { |part| part.gsub(BARE_AMPERSAND_RE, "&amp;") }
360
366
  end
361
367
 
362
368
  # Replace HTML named entities (e.g. &nbsp;, &copy;) with their numeric
@@ -365,12 +371,14 @@ module MjmlRb
365
371
  # HTML must be converted to numeric form for the XML parser to accept them.
366
372
  # Uses Nokogiri's built-in HTML entity lookup table.
367
373
  def replace_html_entities(content)
368
- content.gsub(/&([a-zA-Z][a-zA-Z0-9]*);/) do |match|
369
- name = ::Regexp.last_match(1)
370
- next match if XML_PREDEFINED_ENTITIES.include?(name)
374
+ outside_cdata(content) do |part|
375
+ part.gsub(/&([a-zA-Z][a-zA-Z0-9]*);/) do |match|
376
+ name = ::Regexp.last_match(1)
377
+ next match if XML_PREDEFINED_ENTITIES.include?(name)
371
378
 
372
- codepoint = Nokogiri::HTML::NamedCharacters[name]
373
- codepoint ? "&##{codepoint};" : match
379
+ codepoint = Nokogiri::HTML::NamedCharacters[name]
380
+ codepoint ? "&##{codepoint};" : match
381
+ end
374
382
  end
375
383
  end
376
384
 
@@ -384,23 +392,39 @@ module MjmlRb
384
392
 
385
393
  if (element.name.start_with?("mj-") || element.name == "mjml") && !element["data-mjml-file"]
386
394
  element["data-mjml-file"] = file_path
395
+ element["data-mjml-line"] = element.line.to_s
387
396
  end
388
397
  element.element_children.each { |child| annotate_include_source(child, file_path) }
389
398
  end
390
399
 
391
- def resolve_include_path(include_path, actual_path, file_path)
392
- include_path = include_path.to_s
393
- return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
394
-
395
- candidates = []
396
- candidates << File.expand_path(include_path, File.dirname(actual_path.to_s))
397
- candidates << File.expand_path(include_path, file_path.to_s)
398
- candidates << File.expand_path(include_path, Dir.pwd)
400
+ class IncludeDenied < StandardError; end
399
401
 
400
- existing = candidates.find { |candidate| File.file?(candidate) }
401
- return existing if existing
402
+ def include_diagnostic(node, options, type, message)
403
+ {type: type, message: message, formatted_message: message,
404
+ tag_name: "mj-include", line: node.line, file: options[:actual_path]}
405
+ end
402
406
 
403
- raise Errno::ENOENT, include_path
407
+ def resolve_include_path(path, options)
408
+ decoded = path.to_s
409
+ 10.times do
410
+ next_value = URI::DEFAULT_PARSER.unescape(decoded)
411
+ break if next_value == decoded
412
+ decoded = next_value
413
+ end
414
+ if decoded.include?("\0") || decoded.start_with?("/", "\\") || decoded.match?(/\A[A-Za-z]:/)
415
+ raise IncludeDenied
416
+ end
417
+ base = options[:file_path]
418
+ candidate = File.expand_path(decoded, base)
419
+ # Resolve every existing ancestor, including symlinks of missing targets.
420
+ ancestor = candidate
421
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor) || ancestor == File.dirname(ancestor)
422
+ canonical = File.realpath(ancestor)
423
+ allowed = options[:allowed_roots].any? { |root| canonical == root || canonical.start_with?(File.join(root, "")) }
424
+ raise IncludeDenied unless allowed
425
+ File.exist?(candidate) ? File.realpath(candidate) : candidate
426
+ rescue Errno::ELOOP
427
+ raise IncludeDenied
404
428
  end
405
429
 
406
430
  def prepare_node(element, keep_comments:)
@@ -413,8 +437,14 @@ module MjmlRb
413
437
 
414
438
  # For ending-tag elements whose content was wrapped in CDATA,
415
439
  # mark them so raw_inner knows to extract content directly.
416
- if ENDING_TAGS_FOR_CDATA.include?(element.name)
440
+ if ending_tags.include?(element.name)
417
441
  element["data-mjml-ending-tag"] = "true"
442
+ unless keep_comments
443
+ element.children.each do |child|
444
+ next unless child.cdata? || child.text?
445
+ child.content = strip_source_comments(child.content)
446
+ end
447
+ end
418
448
  return
419
449
  end
420
450
 
@@ -429,18 +459,32 @@ module MjmlRb
429
459
  child.remove
430
460
  end
431
461
  elsif child.comment?
432
- child.remove unless keep_comments
462
+ child.remove unless keep_comments || child.content.match?(/\A(?:\[if|<!\[endif|\s*htmlmin:ignore)/i)
433
463
  else
434
464
  child.remove
435
465
  end
436
466
  end
437
467
  end
438
468
 
439
- # Lenient XML parse used during include expansion and intermediate steps.
440
- # Errors are collected but do not raise; the final strict parse in #parse
441
- # will surface any real issues.
469
+ def strip_source_comments(content)
470
+ content.gsub(/<(script|style|textarea)\b[^>]*>.*?<\/\1\s*>|<!--.*?-->/mi) do |part|
471
+ if !part.start_with?("<!--") || part.match?(/\A<!--(?:\[if|<!\[endif|\s*htmlmin:ignore)/i)
472
+ part
473
+ else
474
+ ""
475
+ end
476
+ end
477
+ end
478
+
442
479
  def parse_xml(xml)
443
- Nokogiri::XML(replace_html_entities(xml))
480
+ Nokogiri::XML(replace_html_entities(xml)) { |config| config.strict }
481
+ end
482
+
483
+ def xml_parse_error(error, file)
484
+ # Nokogiri's aggregate exception has no #line; retain the first reported
485
+ # location while leaving its complete diagnostic message intact.
486
+ line = error.line || error.message[/^(\d+):\d+:/, 1]&.to_i
487
+ ParseError.new("XML parse error: #{error.message}", line: line, file: file, dependencies: @dependencies)
444
488
  end
445
489
 
446
490
  def ignorable_whitespace_text?(text, parent_element_name:)