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.
- 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/attributes.rb +5 -5
- data/lib/mjml-rb/components/base.rb +5 -1
- data/lib/mjml-rb/components/body.rb +6 -3
- data/lib/mjml-rb/components/breakpoint.rb +1 -1
- 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/head.rb +3 -3
- data/lib/mjml-rb/components/hero.rb +7 -2
- data/lib/mjml-rb/components/html_attributes.rb +3 -3
- 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 +19 -28
- data/lib/mjml-rb/components/table.rb +2 -2
- 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 +19 -0
- data/lib/mjml-rb/options.rb +81 -0
- data/lib/mjml-rb/parser.rb +158 -131
- data/lib/mjml-rb/renderer.rb +84 -100
- data/lib/mjml-rb/result.rb +3 -2
- data/lib/mjml-rb/validator.rb +26 -34
- data/lib/mjml-rb/version.rb +2 -1
- data/lib/mjml-rb.rb +3 -3
- data/mjml-rb.gemspec +2 -2
- metadata +20 -2
- 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
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
require "nokogiri"
|
|
2
|
+
require "uri"
|
|
3
|
+
require_relative "options"
|
|
2
4
|
|
|
3
|
-
require_relative "
|
|
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
|
-
|
|
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,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 =
|
|
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])
|
|
67
|
+
doc.root
|
|
61
68
|
rescue Nokogiri::XML::SyntaxError => e
|
|
62
|
-
raise
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 =
|
|
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.
|
|
113
|
+
includes.each do |include_node|
|
|
94
114
|
path_attr = include_node["path"]
|
|
95
|
-
|
|
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
|
-
|
|
119
|
+
include_type = include_node["type"].to_s
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
@
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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. & { ). 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, "&")
|
|
365
|
+
outside_cdata(content) { |part| part.gsub(BARE_AMPERSAND_RE, "&") }
|
|
359
366
|
end
|
|
360
367
|
|
|
361
368
|
# Replace HTML named entities (e.g. , ©) 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
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
|
430
|
+
def prepare_node(element, keep_comments:)
|
|
406
431
|
raise ParseError, "Missing XML root element" unless element
|
|
407
432
|
|
|
408
|
-
#
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
#
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
+
prepare_node(child, keep_comments: keep_comments)
|
|
436
456
|
elsif child.text? || child.cdata?
|
|
437
457
|
text = child.content
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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:)
|