mjml-rb 0.5.1 → 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 (46) 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/attributes.rb +5 -5
  18. data/lib/mjml-rb/components/base.rb +5 -1
  19. data/lib/mjml-rb/components/body.rb +6 -3
  20. data/lib/mjml-rb/components/breakpoint.rb +1 -1
  21. data/lib/mjml-rb/components/carousel.rb +2 -2
  22. data/lib/mjml-rb/components/carousel_image.rb +2 -2
  23. data/lib/mjml-rb/components/column.rb +16 -11
  24. data/lib/mjml-rb/components/group.rb +13 -4
  25. data/lib/mjml-rb/components/head.rb +3 -3
  26. data/lib/mjml-rb/components/hero.rb +7 -2
  27. data/lib/mjml-rb/components/html_attributes.rb +3 -3
  28. data/lib/mjml-rb/components/image.rb +1 -3
  29. data/lib/mjml-rb/components/raw.rb +1 -1
  30. data/lib/mjml-rb/components/section.rb +23 -8
  31. data/lib/mjml-rb/components/social.rb +19 -28
  32. data/lib/mjml-rb/components/table.rb +2 -2
  33. data/lib/mjml-rb/config_file.rb +25 -15
  34. data/lib/mjml-rb/dependencies.rb +1 -0
  35. data/lib/mjml-rb/formatter.rb +113 -0
  36. data/lib/mjml-rb/node_compat.rb +19 -0
  37. data/lib/mjml-rb/options.rb +81 -0
  38. data/lib/mjml-rb/parser.rb +158 -131
  39. data/lib/mjml-rb/renderer.rb +84 -100
  40. data/lib/mjml-rb/result.rb +3 -2
  41. data/lib/mjml-rb/validator.rb +26 -34
  42. data/lib/mjml-rb/version.rb +2 -1
  43. data/lib/mjml-rb.rb +3 -3
  44. data/mjml-rb.gemspec +2 -2
  45. metadata +20 -2
  46. data/lib/mjml-rb/ast_node.rb +0 -37
@@ -0,0 +1,113 @@
1
+ module MjmlRb
2
+ # A deliberately conservative formatter: tokenize markup before changing
3
+ # whitespace, and never reserialize the document (which can damage VML/MSO).
4
+ class Formatter
5
+ IGNORE = /<!--\s*htmlmin:ignore\s*-->/
6
+ PROTECTED = /<!--\s*htmlmin:ignore\s*-->.*?<!--\s*htmlmin:ignore\s*-->|<(pre|textarea|script|style)\b[^>]*>.*?<\/\1\s*>|<!--[\s\S]*?-->|<v:[\s\S]*?<\/v:[^>]+>/im
7
+ TOKEN = /<[^>"']*(?:(?:"[^"]*"|'[^']*')[^>"']*)*>/m
8
+
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def format(html)
14
+ output = html.dup
15
+ # Source and generated protection regions are restored byte for byte.
16
+ regions = []
17
+ prefix = "MJMLRBPRESERVE#{object_id}X"
18
+ prefix += "X" while html.include?(prefix)
19
+ output = output.gsub(/<!--\s*htmlmin:ignore\s*-->.*?<!--\s*htmlmin:ignore\s*-->/m) do |region|
20
+ regions << region.gsub(IGNORE, "")
21
+ "#{prefix}#{regions.length - 1}X"
22
+ end
23
+ raw_regions = []
24
+ output = output.gsub(/<!--mjml-rb:raw-start-->(.*?)<!--mjml-rb:raw-end-->/m) do
25
+ content = Regexp.last_match(1)
26
+ content = process_styles(content) if @options[:sanitize_styles]
27
+ raw_regions << content
28
+ "#{prefix}R#{raw_regions.length - 1}X"
29
+ end
30
+ output = process_styles(output) if @options[:sanitize_styles] || (@options[:minify] && @options[:minify_options].fetch(:minify_css, true))
31
+ if @options[:minify] || @options[:beautify]
32
+ protected = []
33
+ output = output.gsub(PROTECTED) do |part|
34
+ protected << part
35
+ "<#{prefix}P#{protected.length - 1}X/>"
36
+ end
37
+ # Whitespace inside text and quoted attributes is significant. Only
38
+ # whitespace between structural tags is eligible for formatting.
39
+ output = output.gsub(/(>)([\t\r\n ]*)(?=<)/) do
40
+ @options[:minify] ? ">" : ">\n"
41
+ end
42
+ protected.each_with_index { |part, index| output = output.gsub("<#{prefix}P#{index}X/>") { part } }
43
+ end
44
+ raw_regions.each_with_index { |part, index| output = output.gsub("#{prefix}R#{index}X") { part } }
45
+ regions.each_with_index { |part, index| output = output.gsub("#{prefix}#{index}X") { part } }
46
+ output.gsub(IGNORE, "")
47
+ end
48
+
49
+ private
50
+
51
+ def process_styles(html)
52
+ pattern = /<(script|textarea|pre)\b[^>]*>.*?<\/\1\s*>|<!--.*?-->|(<style\b[^>]*>)(.*?)(<\/style\s*>)|#{TOKEN}/mi
53
+ html.gsub(pattern) do |part|
54
+ if Regexp.last_match(1) || part.start_with?("<!--")
55
+ part
56
+ elsif Regexp.last_match(2)
57
+ opening, css, closing = Regexp.last_match.captures[1..3]
58
+ "#{opening}#{process_css(css)}#{closing}"
59
+ else
60
+ part.gsub(/(\sstyle\s*=\s*)(["'])(.*?)\2/mi) do
61
+ name, quote, css = Regexp.last_match.captures
62
+ "#{name}#{quote}#{process_css(css)}#{quote}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def process_css(css)
69
+ variables = []
70
+ kinds = []
71
+ token_prefix = "mjmlrbvariable"
72
+ token_prefix += "x" while css.include?(token_prefix)
73
+ syntaxes = @options[:template_syntax]
74
+ unless syntaxes.is_a?(Array) && syntaxes.all? { |syntax| syntax.is_a?(Hash) && (syntax[:prefix] || syntax['prefix']).is_a?(String) && (syntax[:suffix] || syntax['suffix']).is_a?(String) }
75
+ raise ArgumentError, "template_syntax must be an array of prefix/suffix objects"
76
+ end
77
+ syntaxes.each do |syntax|
78
+ prefix, suffix = syntax.values_at(:prefix, :suffix)
79
+ prefix ||= syntax['prefix']
80
+ suffix ||= syntax['suffix']
81
+ raise ArgumentError, "Template delimiters must not be empty" if prefix.empty? || suffix.empty?
82
+ pattern = /#{Regexp.escape(prefix)}.*?#{Regexp.escape(suffix)}/m
83
+ if @options[:sanitize_styles]
84
+ if css.scan(Regexp.new(Regexp.escape(prefix))).size != css.scan(Regexp.new(Regexp.escape(suffix))).size
85
+ raise ArgumentError, "Unbalanced template delimiters #{prefix.inspect}/#{suffix.inspect} in CSS"
86
+ end
87
+ rest = css.gsub(pattern, '')
88
+ if rest.include?(prefix) || rest.include?(suffix)
89
+ raise ArgumentError, "Unbalanced template delimiters #{prefix.inspect}/#{suffix.inspect} in CSS"
90
+ end
91
+ end
92
+ css = css.gsub(pattern) do |token|
93
+ match = Regexp.last_match
94
+ before, after = css[0...match.begin(0)].rstrip, css[match.end(0)..].lstrip
95
+ kinds << (after.start_with?(':') ? :property : before.end_with?(':') ? :value : :block)
96
+ variables << token
97
+ "#{token_prefix}#{variables.length - 1}token"
98
+ end
99
+ end
100
+ if @options[:sanitize_styles] && !@options[:allow_mixed_syntax] && kinds.include?(:block) && (kinds.include?(:value) || kinds.include?(:property))
101
+ raise ArgumentError, "Mixed variable syntax detected in CSS; set allow_mixed_syntax: true to allow it"
102
+ end
103
+ if @options[:minify] && @options[:minify_options].fetch(:minify_css, true)
104
+ # CSS strings and comments are tokens, not whitespace to collapse.
105
+ css = css.split(/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\*.*?\*\/)/m).map.with_index do |part, index|
106
+ index.odd? ? part : part.gsub(/\s+/, ' ').gsub(/\s*([{};,])\s*/, '\1').gsub(/:\s+/, ':')
107
+ end.join.strip
108
+ end
109
+ variables.each_with_index { |value, index| css = css.gsub("#{token_prefix}#{index}token") { value } }
110
+ css
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,19 @@
1
+ require "nokogiri"
2
+
3
+ module MjmlRb
4
+ module NodeCompat
5
+ def tag_name
6
+ name
7
+ end
8
+
9
+ def line
10
+ element? && self["data-mjml-line"] ? self["data-mjml-line"].to_i : super
11
+ end
12
+
13
+ def file
14
+ self["data-mjml-file"]
15
+ end
16
+ end
17
+ end
18
+
19
+ Nokogiri::XML::Node.prepend(MjmlRb::NodeCompat)
@@ -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,22 +1,17 @@
1
1
  require "nokogiri"
2
+ require "uri"
3
+ require_relative "options"
2
4
 
3
- require_relative "ast_node"
5
+ require_relative "node_compat"
4
6
 
5
7
  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,45 +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
- element_to_ast(doc.root, keep_comments: opts[:keep_comments])
66
+ prepare_node(doc.root, keep_comments: opts[:keep_comments])
67
+ doc.root
61
68
  rescue Nokogiri::XML::SyntaxError => e
62
- raise ParseError.new("XML parse error: #{e.message}")
69
+ raise xml_parse_error(e, opts[:actual_path])
63
70
  end
64
71
 
65
72
  private
66
73
 
67
74
  def normalize_options(options)
68
- {
69
- keep_comments: options[:keep_comments],
70
- preprocessors: Array(options[:preprocessors]),
71
- ignore_includes: !!options[:ignore_includes],
72
- file_path: options[:file_path] || ".",
73
- actual_path: options[:actual_path] || "."
74
- }
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
75
94
  end
76
95
 
77
96
  def apply_preprocessors(xml, preprocessors)
@@ -82,50 +101,55 @@ module MjmlRb
82
101
 
83
102
  def expand_includes(xml, options, included_in = [])
84
103
  xml = wrap_ending_tags_in_cdata(xml)
85
- xml = normalize_html_void_tags(xml)
104
+ xml = outside_cdata(xml) { |part| normalize_html_void_tags(part) }
86
105
  doc = parse_xml(sanitize_bare_ampersands(xml))
87
106
  includes = doc.xpath("//mj-include")
88
- 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?
89
110
 
90
- css_includes = []
91
111
  head_includes = []
92
112
 
93
- includes.reverse_each do |include_node|
113
+ includes.each do |include_node|
94
114
  path_attr = include_node["path"]
95
- raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
96
-
97
- include_type = include_node["type"].to_s
98
- parent = include_node.parent
99
-
100
- resolved_path = begin
101
- resolve_include_path(path_attr, options[:actual_path], options[:file_path])
102
- rescue Errno::ENOENT
103
- 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)
104
117
  end
105
118
 
106
- include_content = resolved_path ? File.read(resolved_path) : nil
119
+ include_type = include_node["type"].to_s
107
120
 
108
- if include_content.nil?
109
- display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
110
- @include_errors << {
111
- message: "mj-include fails to read file : #{path_attr} at #{display_path}",
112
- tag_name: "mj-include",
113
- file: display_path
114
- }
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}")
115
133
  include_node.remove
116
134
  next
117
135
  end
118
136
 
119
137
  # Circular include detection
120
138
  if included_in.include?(resolved_path)
121
- 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)
122
140
  end
123
141
 
124
142
  if include_type == "css"
125
- # CSS includes get collected and added to mj-head later
126
- css_inline = include_node["css-inline"].to_s
127
- css_includes << { content: include_content, inline: css_inline == "inline" }
128
- 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
129
153
  next
130
154
  end
131
155
 
@@ -133,13 +157,13 @@ module MjmlRb
133
157
  %(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
134
158
  else
135
159
  prepared = prepare_mjml_include_document(include_content)
136
- prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(prepared))
160
+ prepared = wrap_ending_tags_in_cdata(prepared)
137
161
  expanded = expand_includes(prepared, options.merge(
138
162
  actual_path: resolved_path,
139
163
  file_path: File.dirname(resolved_path)
140
164
  ), included_in + [resolved_path])
141
165
  body_children, include_head_children = extract_mjml_include_children(expanded)
142
- head_includes.unshift(*include_head_children)
166
+ head_includes.push(*include_head_children)
143
167
  body_children
144
168
  end
145
169
 
@@ -161,12 +185,9 @@ module MjmlRb
161
185
 
162
186
  inject_head_includes(doc, head_includes) unless head_includes.empty?
163
187
 
164
- # Inject CSS includes into mj-head
165
- unless css_includes.empty?
166
- inject_css_includes(doc, css_includes)
167
- end
168
-
169
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])
170
191
  end
171
192
 
172
193
  def prepare_mjml_include_document(content)
@@ -196,18 +217,6 @@ module MjmlRb
196
217
  head_includes.each { |child| head.add_child(child) }
197
218
  end
198
219
 
199
- def inject_css_includes(doc, css_includes)
200
- head = ensure_head(doc)
201
-
202
- # Add each CSS include as an mj-style element
203
- css_includes.each do |css_include|
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)
208
- end
209
- end
210
-
211
220
  def ensure_head(doc)
212
221
  mjml_root = doc.root
213
222
  return unless mjml_root
@@ -280,7 +289,7 @@ module MjmlRb
280
289
  wrapped = +""
281
290
  cursor = 0
282
291
 
283
- while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
292
+ while (match = ending_tag_open_re.match(content, cursor))
284
293
  tag = match[1]
285
294
  attrs = match[2].to_s
286
295
  wrapped << content[cursor...match.begin(0)]
@@ -296,9 +305,7 @@ module MjmlRb
296
305
  if inner.include?("<![CDATA[")
297
306
  wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
298
307
  else
299
- # Pre-process content: normalize void tags and sanitize bare ampersands
300
- # before wrapping in CDATA, so the raw HTML is well-formed for output.
301
- prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
308
+ prepared = inner
302
309
  wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
303
310
  end
304
311
 
@@ -355,7 +362,7 @@ module MjmlRb
355
362
  # (e.g. &amp; &#123; &#x1F;). This lets the XML parser handle HTML-ish
356
363
  # content such as "Terms & Conditions" which is common in email templates.
357
364
  def sanitize_bare_ampersands(content)
358
- content.gsub(BARE_AMPERSAND_RE, "&amp;")
365
+ outside_cdata(content) { |part| part.gsub(BARE_AMPERSAND_RE, "&amp;") }
359
366
  end
360
367
 
361
368
  # Replace HTML named entities (e.g. &nbsp;, &copy;) with their numeric
@@ -364,12 +371,14 @@ module MjmlRb
364
371
  # HTML must be converted to numeric form for the XML parser to accept them.
365
372
  # Uses Nokogiri's built-in HTML entity lookup table.
366
373
  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)
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)
370
378
 
371
- codepoint = Nokogiri::HTML::NamedCharacters[name]
372
- codepoint ? "&##{codepoint};" : match
379
+ codepoint = Nokogiri::HTML::NamedCharacters[name]
380
+ codepoint ? "&##{codepoint};" : match
381
+ end
373
382
  end
374
383
  end
375
384
 
@@ -383,81 +392,99 @@ module MjmlRb
383
392
 
384
393
  if (element.name.start_with?("mj-") || element.name == "mjml") && !element["data-mjml-file"]
385
394
  element["data-mjml-file"] = file_path
395
+ element["data-mjml-line"] = element.line.to_s
386
396
  end
387
397
  element.element_children.each { |child| annotate_include_source(child, file_path) }
388
398
  end
389
399
 
390
- def resolve_include_path(include_path, actual_path, file_path)
391
- include_path = include_path.to_s
392
- return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
393
-
394
- candidates = []
395
- candidates << File.expand_path(include_path, File.dirname(actual_path.to_s))
396
- candidates << File.expand_path(include_path, file_path.to_s)
397
- candidates << File.expand_path(include_path, Dir.pwd)
400
+ class IncludeDenied < StandardError; end
398
401
 
399
- existing = candidates.find { |candidate| File.file?(candidate) }
400
- 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
401
406
 
402
- 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
403
428
  end
404
429
 
405
- def element_to_ast(element, keep_comments:)
430
+ def prepare_node(element, keep_comments:)
406
431
  raise ParseError, "Missing XML root element" unless element
407
432
 
408
- # Extract metadata annotations (added by annotate_include_source)
409
- # and strip them from the public attributes hash.
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-")
433
+ # Mark non-mj elements for raw HTML handling
434
+ unless element.name.start_with?("mj-") || element.name == "mjml"
435
+ element["data-mjml-raw"] = "true"
416
436
  end
417
- attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
418
-
419
- # For ending-tag elements whose content was wrapped in CDATA, store
420
- # the raw HTML directly as content instead of parsing structurally.
421
- if ENDING_TAGS_FOR_CDATA.include?(element.name)
422
- raw_content = element.children.select { |c| c.cdata? || c.text? }.map(&:content).join
423
- return AstNode.new(
424
- tag_name: element.name,
425
- attributes: attrs,
426
- children: [],
427
- content: raw_content.empty? ? nil : raw_content,
428
- line: meta_line,
429
- file: meta_file
430
- )
437
+
438
+ # For ending-tag elements whose content was wrapped in CDATA,
439
+ # mark them so raw_inner knows to extract content directly.
440
+ if ending_tags.include?(element.name)
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
448
+ return
431
449
  end
432
450
 
433
- children = element.children.each_with_object([]) do |child, memo|
451
+ # Strip ignorable whitespace text nodes, unwanted comments,
452
+ # and recurse into element children.
453
+ element.children.to_a.each do |child|
434
454
  if child.element?
435
- memo << element_to_ast(child, keep_comments: keep_comments)
455
+ prepare_node(child, keep_comments: keep_comments)
436
456
  elsif child.text? || child.cdata?
437
457
  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)
458
+ if text.empty? || (text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name))
459
+ child.remove
460
+ end
442
461
  elsif child.comment?
443
- memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
462
+ child.remove unless keep_comments || child.content.match?(/\A(?:\[if|<!\[endif|\s*htmlmin:ignore)/i)
463
+ else
464
+ child.remove
444
465
  end
445
466
  end
467
+ end
446
468
 
447
- AstNode.new(
448
- tag_name: element.name,
449
- attributes: attrs,
450
- children: children,
451
- line: meta_line,
452
- file: meta_file
453
- )
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
454
477
  end
455
478
 
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
479
  def parse_xml(xml)
460
- 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)
461
488
  end
462
489
 
463
490
  def ignorable_whitespace_text?(text, parent_element_name:)