mjml-rb 0.3.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 414eb19865bc6fe6432e0ed0576001365e2c7fcc9e6133005bf4f4cefc89f35b
4
- data.tar.gz: 420df02142daf9807af8fa96d2ad0ca5ca13f68ffa737781c4c38a85c176d29b
3
+ metadata.gz: 3b8ae00ad7838acf9020b38b585a90fbf86981d234e813da8f9d45d52421dd1a
4
+ data.tar.gz: b6723a5a59e85e6db01ae7bcb8e8b185c6cbd5253fccd195f93aa6158fa08361
5
5
  SHA512:
6
- metadata.gz: 77e7e40b344e0560b75ec58d1a5e0364f2b8c148f985cba18c77cc678114381416157709db251ef6e3762886a286b2da2d8bb7e38cbfaed07006ed9161c1d0fe
7
- data.tar.gz: 2511e7880dcb3dccb36dd97228f1e49b9384b0e2f31ed0c51b91db1287d56bd9c5a40f81637dc30cfb783fbd743129523fffcb93e19cab220227f3e6cd836745
6
+ metadata.gz: d7ad920fbb9996b3bdb4f0321420121695ebf3124ce466e19059b38f72add99284f8208fd2d985313b0e217762f5676ed669cb438cbaec6b68685366e43186c8
7
+ data.tar.gz: 853ffaae0d6889dc630837b73856204bc68d42c8eb7a01dc125c6629a2b9b0258a6fc73a22855e553f1c34a2b2d4876a04e76598e8e0f3be5c5bd6e99ef99fe1
data/Gemfile CHANGED
@@ -2,4 +2,7 @@ source "https://rubygems.org"
2
2
 
3
3
  gemspec
4
4
 
5
- gem "minitest", group: :test
5
+ group :test, :development do
6
+ gem "minitest"
7
+ gem "rake"
8
+ end
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Gem Version](https://badge.fury.io/rb/mjml-rb.svg)](https://badge.fury.io/rb/mjml-rb)
2
+
1
3
  # MJML Ruby Implementation
2
4
 
3
5
  > **Note:** This is an unofficial Ruby port of the MJML email framework,
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "rake/testtask"
2
+
3
+ Rake::TestTask.new(:test) do |t|
4
+ t.libs << "lib"
5
+ t.test_files = FileList["test/test_*.rb"]
6
+ t.warning = false
7
+ end
8
+
9
+ task default: :test
@@ -25,11 +25,13 @@ module MjmlRb
25
25
 
26
26
  def text_content
27
27
  return @content.to_s if text?
28
- @children.map(&:text_content).join
28
+ result = +""
29
+ @children.each { |child| result << child.text_content }
30
+ result
29
31
  end
30
32
 
31
33
  def element_children
32
- @children.select(&:element?)
34
+ @element_children ||= @children.select(&:element?)
33
35
  end
34
36
  end
35
37
  end
data/lib/mjml-rb/cli.rb CHANGED
@@ -52,6 +52,10 @@ module MjmlRb
52
52
  @stderr.puts("\nCommand line error:")
53
53
  @stderr.puts(e.message)
54
54
  1
55
+ rescue ConfigFile::ConfigError => e
56
+ @stderr.puts("\nConfiguration error:")
57
+ @stderr.puts(e.message)
58
+ 1
55
59
  rescue OptionParser::ParseError => e
56
60
  @stderr.puts(e.message)
57
61
  1
@@ -194,7 +198,10 @@ module MjmlRb
194
198
  end
195
199
 
196
200
  def emit_results(processed, output_mode, options)
197
- processed.each { |item| emit_errors(item[:compiled], item[:file]) }
201
+ processed.each do |item|
202
+ emit_warnings(item[:compiled], item[:file])
203
+ emit_errors(item[:compiled], item[:file])
204
+ end
198
205
 
199
206
  invalid = processed.any? { |item| !item[:compiled].errors.empty? }
200
207
  if options[:validate].any? && invalid
@@ -213,6 +220,14 @@ module MjmlRb
213
220
  invalid ? 2 : 0
214
221
  end
215
222
 
223
+ def emit_warnings(result, file)
224
+ return if result.warnings.empty?
225
+ result.warnings.each do |warning|
226
+ prefix = file ? "File: #{file}\n" : ""
227
+ @stderr.puts("WARNING: #{prefix}#{warning[:formatted_message] || warning[:message]}")
228
+ end
229
+ end
230
+
216
231
  def emit_errors(result, file)
217
232
  return if result.errors.empty?
218
233
  result.errors.each do |error|
@@ -17,8 +17,17 @@ module MjmlRb
17
17
  actual_path: "."
18
18
  }.freeze
19
19
 
20
+ # Additional option keys accepted by the renderer but not in DEFAULT_OPTIONS.
21
+ EXTRA_VALID_KEYS = %i[lang dir fonts printerSupport].freeze
22
+
23
+ VALID_OPTION_KEYS = (DEFAULT_OPTIONS.keys + EXTRA_VALID_KEYS).freeze
24
+
25
+ VALID_VALIDATION_LEVELS = %w[soft strict skip].freeze
26
+
20
27
  def initialize(options = {})
21
- @options = DEFAULT_OPTIONS.merge(symbolize_keys(options))
28
+ normalized = symbolize_keys(options)
29
+ validate_options!(normalized)
30
+ @options = DEFAULT_OPTIONS.merge(normalized)
22
31
  @parser = Parser.new
23
32
  @validator = Validator.new(parser: @parser)
24
33
  @renderer = Renderer.new
@@ -29,7 +38,16 @@ module MjmlRb
29
38
  end
30
39
 
31
40
  def compile(mjml, options = {})
32
- merged = @options.merge(symbolize_keys(options))
41
+ normalized = symbolize_keys(options)
42
+ validate_options!(normalized)
43
+ merged = @options.merge(normalized)
44
+
45
+ do_compile(mjml, merged)
46
+ end
47
+
48
+ private
49
+
50
+ def do_compile(mjml, merged)
33
51
  ast = @parser.parse(
34
52
  mjml,
35
53
  keep_comments: merged[:keep_comments],
@@ -39,26 +57,43 @@ module MjmlRb
39
57
  actual_path: merged[:actual_path]
40
58
  )
41
59
 
42
- validation_errors = validate_if_needed(ast, merged)
43
- return Result.new(errors: validation_errors) if strict_validation_failed?(merged, validation_errors)
60
+ include_issues = format_include_errors(@parser.include_errors)
61
+ validation = validate_if_needed(ast, merged)
62
+ include_validation = classify_include_issues(include_issues, merged)
63
+
64
+ all_errors = validation[:errors] + include_validation[:errors]
65
+ all_warnings = validation[:warnings] + include_validation[:warnings]
66
+
67
+ return Result.new(errors: all_errors, warnings: all_warnings) if strict_validation_failed?(merged, all_errors)
44
68
 
45
69
  html = @renderer.render(ast, merged)
46
- result = Result.new(
70
+ Result.new(
47
71
  html: post_process(html, merged),
48
- errors: validation_errors,
49
- warnings: []
72
+ errors: all_errors,
73
+ warnings: all_warnings
50
74
  )
51
- result
52
75
  rescue Parser::ParseError => e
53
76
  Result.new(errors: [format_error(e.message, line: e.line)])
54
77
  rescue StandardError => e
55
78
  Result.new(errors: [format_error(e.message)])
56
79
  end
57
80
 
58
- private
81
+ def validate_options!(options)
82
+ unknown = options.keys - VALID_OPTION_KEYS
83
+ unless unknown.empty?
84
+ raise ArgumentError, "Unknown option(s): #{unknown.join(', ')}. Valid options are: #{VALID_OPTION_KEYS.join(', ')}"
85
+ end
86
+
87
+ if options.key?(:validation_level)
88
+ level = options[:validation_level].to_s
89
+ unless VALID_VALIDATION_LEVELS.include?(level)
90
+ raise ArgumentError, "Invalid validation_level: #{level.inspect}. Must be one of: #{VALID_VALIDATION_LEVELS.join(', ')}"
91
+ end
92
+ end
93
+ end
59
94
 
60
95
  def validate_if_needed(ast, options)
61
- return [] if options[:validation_level].to_s == "skip"
96
+ return { errors: [], warnings: [] } if options[:validation_level].to_s == "skip"
62
97
  @validator.validate(ast, options)
63
98
  end
64
99
 
@@ -98,6 +133,23 @@ module MjmlRb
98
133
  end
99
134
  end
100
135
 
136
+ def format_include_errors(include_errors)
137
+ Array(include_errors).map do |ie|
138
+ format_error(ie[:message], line: ie[:line])
139
+ end
140
+ end
141
+
142
+ def classify_include_issues(issues, options)
143
+ return { errors: [], warnings: [] } if issues.empty?
144
+
145
+ level = options[:validation_level].to_s
146
+ if level == "strict"
147
+ { errors: issues, warnings: [] }
148
+ else
149
+ { errors: [], warnings: issues }
150
+ end
151
+ end
152
+
101
153
  def format_error(message, line: nil)
102
154
  {
103
155
  line: line,
@@ -17,33 +17,49 @@ module MjmlRb
17
17
  @custom_dependencies[parent] = ((@custom_dependencies[parent] || []) + Array(children)).uniq
18
18
  end
19
19
  @custom_ending_tags.merge(Array(ending_tags))
20
+ invalidate_caches!
20
21
  end
21
22
 
22
23
  def component_class_for_tag(tag_name)
23
- all_component_classes.find { |klass| klass.tags.include?(tag_name) }
24
+ tag_class_cache[tag_name]
24
25
  end
25
26
 
26
27
  def dependency_rules
27
- merged = {}
28
- Dependencies::RULES.each { |k, v| merged[k] = v.dup }
29
- @custom_dependencies.each do |parent, children|
30
- merged[parent] = ((merged[parent] || []) + Array(children)).uniq
28
+ @dependency_rules_cache ||= begin
29
+ merged = {}
30
+ Dependencies::RULES.each { |k, v| merged[k] = v.dup }
31
+ @custom_dependencies.each do |parent, children|
32
+ merged[parent] = ((merged[parent] || []) + Array(children)).uniq
33
+ end
34
+ merged
31
35
  end
32
- merged
33
36
  end
34
37
 
35
38
  def ending_tags
36
- Dependencies::ENDING_TAGS | @custom_ending_tags
39
+ @ending_tags_cache ||= (Dependencies::ENDING_TAGS | @custom_ending_tags)
37
40
  end
38
41
 
39
42
  def reset!
40
43
  @custom_components.clear
41
44
  @custom_dependencies.clear
42
45
  @custom_ending_tags.clear
46
+ invalidate_caches!
43
47
  end
44
48
 
45
49
  private
46
50
 
51
+ def invalidate_caches!
52
+ @tag_class_cache = nil
53
+ @dependency_rules_cache = nil
54
+ @ending_tags_cache = nil
55
+ end
56
+
57
+ def tag_class_cache
58
+ @tag_class_cache ||= all_component_classes.each_with_object({}) do |klass, h|
59
+ klass.tags.each { |tag| h[tag] ||= klass }
60
+ end
61
+ end
62
+
47
63
  def all_component_classes
48
64
  builtin = MjmlRb::Components.constants.filter_map do |name|
49
65
  value = MjmlRb::Components.const_get(name)
@@ -1,6 +1,9 @@
1
+ require_relative "css_helpers"
2
+
1
3
  module MjmlRb
2
4
  module Components
3
5
  class Base
6
+ include CssHelpers
4
7
  class << self
5
8
  def tags
6
9
  const_defined?(:TAGS) ? const_get(:TAGS) : []
@@ -173,21 +173,6 @@ module MjmlRb
173
173
  "#{result.to_i}px"
174
174
  end
175
175
 
176
- def shorthand_value(parts, side)
177
- case parts.length
178
- when 1 then parts[0]
179
- when 2, 3 then parts[1]
180
- when 4 then side == :left ? parts[3] : parts[1]
181
- else "0"
182
- end
183
- end
184
-
185
- def parse_border_width(border_str)
186
- return 0 if border_str.nil? || border_str.strip == "none"
187
-
188
- m = border_str.match(/(\d+(?:\.\d+)?)\s*px/)
189
- m ? m[1].to_f : 0
190
- end
191
176
  end
192
177
  end
193
178
  end
@@ -305,39 +305,43 @@ module MjmlRb
305
305
  def component_head_style(carousel_id, length, attrs)
306
306
  return "" if length.zero?
307
307
 
308
- hide_non_selected = (0...length).map do |index|
309
- ".mj-carousel-#{carousel_id}-radio:checked #{adjacent_siblings(index)}+ .mj-carousel-content .mj-carousel-image"
310
- end.join(",\n")
311
-
312
- show_selected = (0...length).map do |index|
313
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-image-#{index + 1}"
314
- end.join(",\n")
315
-
316
- next_icons = (0...length).map do |index|
317
- target = ((index + 1) % length) + 1
318
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-next-#{target}"
319
- end.join(",\n")
320
-
321
- previous_icons = (0...length).map do |index|
322
- target = ((index - 1) % length) + 1
323
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-previous-#{target}"
324
- end.join(",\n")
325
-
326
- selected_thumbnail = (0...length).map do |index|
327
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{index + 1}"
328
- end.join(",\n")
329
-
330
- show_thumbnails = (0...length).map do |index|
331
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
332
- end.join(",\n")
333
-
334
- hide_on_hover = (0...length).map do |index|
335
- ".mj-carousel-#{carousel_id}-thumbnail:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image"
336
- end.join(",\n")
337
-
338
- show_on_hover = (0...length).map do |index|
339
- ".mj-carousel-#{carousel_id}-thumbnail-#{index + 1}:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image-#{index + 1}"
340
- end.join(",\n")
308
+ hide_non_selected = []
309
+ show_selected = []
310
+ next_icons = []
311
+ previous_icons = []
312
+ selected_thumbnail = []
313
+ show_thumbnails = []
314
+ hide_on_hover = []
315
+ show_on_hover = []
316
+
317
+ # Pre-compute adjacent sibling strings to avoid repeated "+" * count" allocations
318
+ sibling_cache = Array.new(length) { |i| adjacent_siblings(i) }
319
+
320
+ length.times do |index|
321
+ siblings_index = sibling_cache[index]
322
+ siblings_reverse = sibling_cache[length - index - 1]
323
+ idx1 = index + 1
324
+ next_target = ((index + 1) % length) + 1
325
+ prev_target = ((index - 1) % length) + 1
326
+
327
+ hide_non_selected << ".mj-carousel-#{carousel_id}-radio:checked #{siblings_index}+ .mj-carousel-content .mj-carousel-image"
328
+ show_selected << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-image-#{idx1}"
329
+ next_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-next-#{next_target}"
330
+ previous_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-previous-#{prev_target}"
331
+ selected_thumbnail << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{idx1}"
332
+ show_thumbnails << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
333
+ hide_on_hover << ".mj-carousel-#{carousel_id}-thumbnail:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image"
334
+ show_on_hover << ".mj-carousel-#{carousel_id}-thumbnail-#{idx1}:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image-#{idx1}"
335
+ end
336
+
337
+ hide_non_selected = hide_non_selected.join(",\n")
338
+ show_selected = show_selected.join(",\n")
339
+ next_icons = next_icons.join(",\n")
340
+ previous_icons = previous_icons.join(",\n")
341
+ selected_thumbnail = selected_thumbnail.join(",\n")
342
+ show_thumbnails = show_thumbnails.join(",\n")
343
+ hide_on_hover = hide_on_hover.join(",\n")
344
+ show_on_hover = show_on_hover.join(",\n")
341
345
 
342
346
  <<~CSS
343
347
  .mj-carousel {
@@ -0,0 +1,33 @@
1
+ module MjmlRb
2
+ module Components
3
+ # Shared CSS utility methods used by multiple components.
4
+ # Extracted from Button, Image, Divider, and Section to eliminate
5
+ # duplicated logic.
6
+ module CssHelpers
7
+ # Extracts a value from a CSS shorthand property (padding, margin).
8
+ # Follows CSS shorthand rules:
9
+ # 1 value → all sides
10
+ # 2 values → vertical | horizontal
11
+ # 3 values → top | horizontal | bottom
12
+ # 4 values → top | right | bottom | left
13
+ def shorthand_value(parts, side)
14
+ case parts.length
15
+ when 1 then parts[0]
16
+ when 2, 3 then parts[1]
17
+ when 4 then side == :left ? parts[3] : parts[1]
18
+ else "0"
19
+ end
20
+ end
21
+
22
+ # Extracts the numeric border width (in px) from a CSS border shorthand
23
+ # string like "2px solid #000". Returns a Float for sub-pixel values,
24
+ # or 0 when the border is nil, empty, "none", or has no px unit.
25
+ def parse_border_width(border_str)
26
+ return 0 if border_str.nil? || border_str.to_s.strip.empty? || border_str.to_s.strip == "none"
27
+
28
+ match = border_str.to_s.match(/(\d+(?:\.\d+)?)\s*px/)
29
+ match ? match[1].to_f : 0
30
+ end
31
+ end
32
+ end
33
+ end
@@ -112,15 +112,6 @@ module MjmlRb
112
112
  end
113
113
  end
114
114
 
115
- def shorthand_value(parts, side)
116
- case parts.length
117
- when 1 then parts[0]
118
- when 2, 3 then parts[1]
119
- when 4 then side == :left ? parts[3] : parts[1]
120
- else "0"
121
- end
122
- end
123
-
124
115
  def outlook_block(align, style, width)
125
116
  # Strip trailing px for the HTML width attribute
126
117
  width_int = width.to_i.to_s
@@ -202,15 +202,6 @@ module MjmlRb
202
202
  end
203
203
  end
204
204
 
205
- def shorthand_value(parts, side)
206
- case parts.length
207
- when 1 then parts[0]
208
- when 2, 3 then parts[1]
209
- when 4 then side == :left ? parts[3] : parts[1]
210
- else "0"
211
- end
212
- end
213
-
214
205
  def make_lower_breakpoint(breakpoint)
215
206
  matched = breakpoint.to_s.match(/[0-9]+/)
216
207
  return breakpoint if matched.nil?
@@ -80,12 +80,6 @@ module MjmlRb
80
80
  value.to_s.to_i
81
81
  end
82
82
 
83
- def parse_border_width(border_str)
84
- return 0 if border_str.nil? || border_str.to_s.strip.empty? || border_str.to_s.strip == "none"
85
-
86
- border_str =~ /(\d+(?:\.\d+)?)\s*px/ ? $1.to_i : 0
87
- end
88
-
89
83
  def parse_padding_value(str)
90
84
  return 0 if str.nil? || str.to_s.strip.empty?
91
85
 
@@ -4,6 +4,15 @@ module MjmlRb
4
4
  class ConfigFile
5
5
  DEFAULT_NAME = ".mjmlrc"
6
6
 
7
+ class ConfigError < StandardError
8
+ attr_reader :path
9
+
10
+ def initialize(message, path:)
11
+ @path = path
12
+ super(message)
13
+ end
14
+ end
15
+
7
16
  def self.load(dir = Dir.pwd)
8
17
  path = File.join(dir, DEFAULT_NAME)
9
18
  return {} unless File.exist?(path)
@@ -27,8 +36,7 @@ module MjmlRb
27
36
 
28
37
  config
29
38
  rescue JSON::ParserError => e
30
- warn "WARNING: Failed to parse #{path}: #{e.message}"
31
- {}
39
+ raise ConfigError.new("Failed to parse #{path}: #{e.message}", path: path)
32
40
  end
33
41
  end
34
42
  end
@@ -17,6 +17,15 @@ module MjmlRb
17
17
  mj-navbar-link mj-raw mj-text
18
18
  ].freeze
19
19
 
20
+ # Pre-compiled regex patterns to avoid rebuilding on every call
21
+ ENDING_TAGS_CDATA_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi.freeze
22
+
23
+ VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
24
+ VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
25
+ VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
26
+ LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
27
+ BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
28
+
20
29
  class ParseError < StandardError
21
30
  attr_reader :line
22
31
 
@@ -26,7 +35,12 @@ module MjmlRb
26
35
  end
27
36
  end
28
37
 
38
+ # Errors collected during include expansion (missing files, etc.)
39
+ # Read after calling #parse to retrieve non-fatal include issues.
40
+ attr_reader :include_errors
41
+
29
42
  def parse(mjml, options = {})
43
+ @include_errors = []
30
44
  opts = normalize_options(options)
31
45
  xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
32
46
  xml = wrap_ending_tags_in_cdata(xml)
@@ -84,12 +98,12 @@ module MjmlRb
84
98
  include_content = resolved_path ? File.read(resolved_path) : nil
85
99
 
86
100
  if include_content.nil?
87
- # Collect error as an mj-raw comment node instead of raising
88
101
  display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
89
- error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
90
- error_node = Element.new("mj-raw")
91
- error_node.add(CData.new(error_comment))
92
- parent.insert_before(include_node, error_node)
102
+ @include_errors << {
103
+ message: "mj-include fails to read file : #{path_attr} at #{display_path}",
104
+ tag_name: "mj-include",
105
+ file: display_path
106
+ }
93
107
  parent.delete(include_node)
94
108
  next
95
109
  end
@@ -212,23 +226,21 @@ module MjmlRb
212
226
  def normalize_html_void_tags(content)
213
227
  # Legacy mail templates sometimes emit invalid closing </br> tags.
214
228
  # Browser-style recovery treats them as actual line breaks, so preserve that.
215
- content = content.gsub(%r{</br\s*>}i, "<br />")
229
+ content = content.gsub(VOID_TAG_CLOSING_BR_RE, "<br />")
216
230
 
217
231
  # Remove other closing tags for void elements (e.g. </hr>, </img>).
218
232
  # These are invalid in both HTML and XML and HTML5 recovery drops them.
219
- content = content.gsub(/<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i, "")
233
+ content = content.gsub(VOID_TAG_CLOSING_OTHER_RE, "")
220
234
 
221
235
  # Self-close opening void tags that aren't already self-closed.
222
- pattern = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i
223
- content.gsub(pattern) do |tag|
236
+ content.gsub(VOID_TAG_OPEN_RE) do |tag|
224
237
  tag.end_with?("/>") ? tag : tag.sub(/>$/, " />")
225
238
  end
226
239
  end
227
240
 
228
241
  def wrap_ending_tags_in_cdata(content)
229
- tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
230
242
  # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
231
- content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
243
+ content.gsub(ENDING_TAGS_CDATA_RE) do
232
244
  tag = Regexp.last_match(1)
233
245
  attrs = Regexp.last_match(2).to_s
234
246
  inner = Regexp.last_match(3).to_s
@@ -251,7 +263,7 @@ module MjmlRb
251
263
  # (e.g. &amp; &#123; &#x1F;). This lets REXML parse HTML-ish content
252
264
  # such as "Terms & Conditions" which is common in email templates.
253
265
  def sanitize_bare_ampersands(content)
254
- content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&amp;")
266
+ content.gsub(BARE_AMPERSAND_RE, "&amp;")
255
267
  end
256
268
 
257
269
  # Adds data-mjml-line attributes to MJML tags so line numbers survive
@@ -259,7 +271,7 @@ module MjmlRb
259
271
  # Skips content inside CDATA sections to avoid modifying raw HTML.
260
272
  def annotate_line_numbers(xml)
261
273
  line = 1
262
- xml.gsub(/(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m) do
274
+ xml.gsub(LINE_ANNOTATION_RE) do
263
275
  if Regexp.last_match(1) # newline
264
276
  line += 1
265
277
  "\n"
@@ -25,7 +25,7 @@ require_relative "components/spacer"
25
25
 
26
26
  module MjmlRb
27
27
  class Renderer
28
- HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
28
+ HTML_VOID_TAGS = Set.new(%w[area base br col embed hr img input link meta param source track wbr]).freeze
29
29
 
30
30
  DEFAULT_FONTS = {
31
31
  "Open Sans" => "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
@@ -647,6 +647,8 @@ module MjmlRb
647
647
  end
648
648
 
649
649
  def append_component_head_styles(document, context)
650
+ all_tags = collect_tag_names(document)
651
+
650
652
  component_registry.each_value.uniq.each do |component|
651
653
  next unless component.respond_to?(:head_style)
652
654
 
@@ -662,7 +664,7 @@ module MjmlRb
662
664
  else
663
665
  component.tags
664
666
  end
665
- next unless Array(tags).any? { |tag| contains_tag?(document, tag) }
667
+ next unless Array(tags).any? { |tag| all_tags.include?(tag) }
666
668
 
667
669
  context[:component_head_styles] << style
668
670
  end
@@ -815,11 +817,12 @@ module MjmlRb
815
817
  end.join("\n")
816
818
  end
817
819
 
818
- def contains_tag?(node, tag_name)
819
- return false unless node.respond_to?(:tag_name)
820
- return true if node.tag_name == tag_name
820
+ def collect_tag_names(node, result = Set.new)
821
+ return result unless node.respond_to?(:tag_name)
821
822
 
822
- node.children.any? { |child| child.respond_to?(:children) && contains_tag?(child, tag_name) }
823
+ result << node.tag_name
824
+ node.children.each { |child| collect_tag_names(child, result) if child.respond_to?(:children) }
825
+ result
823
826
  end
824
827
 
825
828
  def escape_html(value)
@@ -845,9 +848,10 @@ module MjmlRb
845
848
  end
846
849
 
847
850
  def unique_strings(values)
851
+ seen = Set.new
848
852
  Array(values).each_with_object([]) do |value, memo|
849
853
  next if value.nil? || value.empty?
850
- next if memo.include?(value)
854
+ next unless seen.add?(value)
851
855
 
852
856
  memo << value
853
857
  end
@@ -17,18 +17,31 @@ module MjmlRb
17
17
 
18
18
  def validate(mjml_or_ast, options = {})
19
19
  root = mjml_or_ast.is_a?(AstNode) ? mjml_or_ast : parse_ast(mjml_or_ast, options)
20
- return [error("Root element must be <mjml>", tag_name: root&.tag_name)] unless root&.tag_name == "mjml"
20
+ unless root&.tag_name == "mjml"
21
+ return { errors: [error("Root element must be <mjml>", tag_name: root&.tag_name)], warnings: [] }
22
+ end
21
23
 
22
- errors = []
23
- errors << error("Missing <mj-body>", tag_name: "mjml") unless root.element_children.any? { |c| c.tag_name == "mj-body" }
24
- walk(root, errors)
25
- errors
24
+ issues = []
25
+ issues << error("Missing <mj-body>", tag_name: "mjml") unless root.element_children.any? { |c| c.tag_name == "mj-body" }
26
+ walk(root, issues)
27
+ classify_issues(issues, options)
26
28
  rescue Parser::ParseError => e
27
- [error(e.message, line: e.line)]
29
+ { errors: [error(e.message, line: e.line)], warnings: [] }
28
30
  end
29
31
 
30
32
  private
31
33
 
34
+ # In "soft" mode validation issues are non-blocking warnings;
35
+ # in "strict" mode they are blocking errors.
36
+ def classify_issues(issues, options)
37
+ level = options.fetch(:validation_level, "soft").to_s
38
+ if level == "strict"
39
+ { errors: issues, warnings: [] }
40
+ else
41
+ { errors: [], warnings: issues }
42
+ end
43
+ end
44
+
32
45
  def parse_ast(mjml, options)
33
46
  @parser.parse(
34
47
  mjml,
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.3.7".freeze
2
+ VERSION = "0.4.1".freeze
3
3
  end
data/mjml-rb.gemspec CHANGED
@@ -13,7 +13,7 @@ Gem::Specification.new do |spec|
13
13
 
14
14
  spec.homepage = "https://github.com/faraquet/mjml-rb"
15
15
  spec.files = Dir.chdir(__dir__) do
16
- Dir.glob("{bin,lib}/**/*") + ["Gemfile", "LICENSE", "mjml-rb.gemspec", "README.md"]
16
+ Dir.glob("{bin,lib}/**/*") + ["Gemfile", "LICENSE", "Rakefile", "mjml-rb.gemspec", "README.md"]
17
17
  end
18
18
  spec.bindir = "bin"
19
19
  spec.executables = ["mjml"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mjml-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -48,6 +48,7 @@ files:
48
48
  - Gemfile
49
49
  - LICENSE
50
50
  - README.md
51
+ - Rakefile
51
52
  - bin/mjml
52
53
  - lib/mjml-rb.rb
53
54
  - lib/mjml-rb/ast_node.rb
@@ -63,6 +64,7 @@ files:
63
64
  - lib/mjml-rb/components/carousel.rb
64
65
  - lib/mjml-rb/components/carousel_image.rb
65
66
  - lib/mjml-rb/components/column.rb
67
+ - lib/mjml-rb/components/css_helpers.rb
66
68
  - lib/mjml-rb/components/divider.rb
67
69
  - lib/mjml-rb/components/group.rb
68
70
  - lib/mjml-rb/components/head.rb