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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +60 -22
- data/THIRD_PARTY_LICENSES.md +25 -0
- data/docs/ARCHITECTURE.md +363 -0
- data/docs/MIGRATING_TO_V5.md +112 -0
- data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
- data/docs/PARITY_AUDIT.md +124 -0
- data/docs/RELEASING.md +35 -0
- data/docs/USAGE.md +1258 -0
- data/docs/releases/v0.6.0.md +63 -0
- data/lib/mjml-rb/cli.rb +43 -31
- data/lib/mjml-rb/column_gutter.rb +81 -0
- data/lib/mjml-rb/compiler.rb +32 -122
- data/lib/mjml-rb/component_registry.rb +11 -1
- data/lib/mjml-rb/components/accordion.rb +11 -9
- data/lib/mjml-rb/components/base.rb +1 -1
- data/lib/mjml-rb/components/body.rb +6 -3
- data/lib/mjml-rb/components/carousel.rb +2 -2
- data/lib/mjml-rb/components/carousel_image.rb +2 -2
- data/lib/mjml-rb/components/column.rb +16 -11
- data/lib/mjml-rb/components/group.rb +13 -4
- data/lib/mjml-rb/components/hero.rb +7 -2
- data/lib/mjml-rb/components/image.rb +1 -3
- data/lib/mjml-rb/components/raw.rb +1 -1
- data/lib/mjml-rb/components/section.rb +23 -8
- data/lib/mjml-rb/components/social.rb +18 -27
- data/lib/mjml-rb/components/table.rb +1 -1
- data/lib/mjml-rb/config_file.rb +25 -15
- data/lib/mjml-rb/dependencies.rb +1 -0
- data/lib/mjml-rb/formatter.rb +113 -0
- data/lib/mjml-rb/node_compat.rb +4 -0
- data/lib/mjml-rb/options.rb +81 -0
- data/lib/mjml-rb/parser.rb +138 -94
- data/lib/mjml-rb/renderer.rb +50 -76
- data/lib/mjml-rb/result.rb +3 -2
- data/lib/mjml-rb/validator.rb +14 -33
- data/lib/mjml-rb/version.rb +2 -1
- data/lib/mjml-rb.rb +2 -2
- data/mjml-rb.gemspec +2 -2
- 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
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
19
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
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.
|
|
113
|
+
includes.each do |include_node|
|
|
95
114
|
path_attr = include_node["path"]
|
|
96
|
-
|
|
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
|
-
|
|
119
|
+
include_type = include_node["type"].to_s
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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. & { ). 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, "&")
|
|
365
|
+
outside_cdata(content) { |part| part.gsub(BARE_AMPERSAND_RE, "&") }
|
|
360
366
|
end
|
|
361
367
|
|
|
362
368
|
# Replace HTML named entities (e.g. , ©) 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
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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:)
|