mjml-rb 0.4.0 → 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: c9cdaa07e2f84c880f1c3a4ae1594a32ec0d49436d6bf2fe83ee7d1738628fdf
4
- data.tar.gz: 4b8576742cd05906ad3bf2bf8ef184dcf0eb6e4a7a7a19ca701e7a84c5ad3939
3
+ metadata.gz: 3b8ae00ad7838acf9020b38b585a90fbf86981d234e813da8f9d45d52421dd1a
4
+ data.tar.gz: b6723a5a59e85e6db01ae7bcb8e8b185c6cbd5253fccd195f93aa6158fa08361
5
5
  SHA512:
6
- metadata.gz: 10f9d9c51871108519184113747abbee99c6c9b58e4f9a95161756296c4c97b64bf136cf969980ad99b00eb12ba505871a6efaa6b5ec1524f1ec14375e9030a1
7
- data.tar.gz: f2af62cc6cb8f43f6fbd4e7aa6feb20ec7326b1069c52ee46d807ce7d4e134b3f1b931a03dc78c03108328621c322764ab70b14bf3d6e84c52940661ad6d2c09
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
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,
@@ -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
@@ -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
@@ -35,7 +35,12 @@ module MjmlRb
35
35
  end
36
36
  end
37
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
+
38
42
  def parse(mjml, options = {})
43
+ @include_errors = []
39
44
  opts = normalize_options(options)
40
45
  xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
41
46
  xml = wrap_ending_tags_in_cdata(xml)
@@ -93,12 +98,12 @@ module MjmlRb
93
98
  include_content = resolved_path ? File.read(resolved_path) : nil
94
99
 
95
100
  if include_content.nil?
96
- # Collect error as an mj-raw comment node instead of raising
97
101
  display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
98
- error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
99
- error_node = Element.new("mj-raw")
100
- error_node.add(CData.new(error_comment))
101
- 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
+ }
102
107
  parent.delete(include_node)
103
108
  next
104
109
  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.4.0".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.4.0
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