mjml-rb 0.4.0 → 0.4.2

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: 0062fce070623c7a358e1af4811bb928a46d7f0cd4e1320a71e16276ccca1d74
4
+ data.tar.gz: a4e81e450419963570486009b46f44962d93c25b41cdbe4df721346db6e79fc8
5
5
  SHA512:
6
- metadata.gz: 10f9d9c51871108519184113747abbee99c6c9b58e4f9a95161756296c4c97b64bf136cf969980ad99b00eb12ba505871a6efaa6b5ec1524f1ec14375e9030a1
7
- data.tar.gz: f2af62cc6cb8f43f6fbd4e7aa6feb20ec7326b1069c52ee46d807ce7d4e134b3f1b931a03dc78c03108328621c322764ab70b14bf3d6e84c52940661ad6d2c09
6
+ metadata.gz: 880bfd1a06dfe3fb9e8fe2169fe660554cb4e9ca96ee5d6eff1d2b41f6df748af14b753895d7b58a3106e33335516f6f4caadd2fd01717c597846b6104330fbf
7
+ data.tar.gz: 94f84fa6a66210d9177db704c16efa6fce09d983eb138bc5d13fdbf9bf967ac553b867907cfce9190a2bd7e14a6e6108c4ff74eb776d16221cbf77689f59ea85
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,
@@ -104,7 +104,7 @@ module MjmlRb
104
104
  .moz-text-html input.mj-accordion-checkbox + * .mj-accordion-ico { display:none; }
105
105
  CSS
106
106
 
107
- DEFAULTS = {
107
+ DEFAULT_ATTRIBUTES = {
108
108
  "border" => "2px solid black",
109
109
  "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
110
110
  "icon-align" => "middle",
@@ -118,21 +118,17 @@ module MjmlRb
118
118
  "padding" => "10px 25px"
119
119
  }.freeze
120
120
 
121
- TITLE_DEFAULTS = {
121
+ TITLE_DEFAULT_ATTRIBUTES = {
122
122
  "font-size" => "13px",
123
123
  "padding" => "16px"
124
124
  }.freeze
125
125
 
126
- TEXT_DEFAULTS = {
126
+ TEXT_DEFAULT_ATTRIBUTES = {
127
127
  "font-size" => "13px",
128
128
  "line-height" => "1",
129
129
  "padding" => "16px"
130
130
  }.freeze
131
131
 
132
- def tags
133
- TAGS
134
- end
135
-
136
132
  def head_style
137
133
  HEAD_STYLE
138
134
  end
@@ -146,11 +142,11 @@ module MjmlRb
146
142
  when "mj-accordion"
147
143
  render_accordion(node, context, attrs)
148
144
  when "mj-accordion-element"
149
- render_accordion_element(node, context, DEFAULTS.merge(attrs))
145
+ render_accordion_element(node, context, DEFAULT_ATTRIBUTES.merge(attrs))
150
146
  when "mj-accordion-title"
151
- render_accordion_title(node, DEFAULTS.merge(attrs))
147
+ render_accordion_title(node, DEFAULT_ATTRIBUTES.merge(attrs))
152
148
  when "mj-accordion-text"
153
- render_accordion_text(node, DEFAULTS.merge(attrs))
149
+ render_accordion_text(node, DEFAULT_ATTRIBUTES.merge(attrs))
154
150
  else
155
151
  render_children(node, context, parent: parent)
156
152
  end
@@ -159,7 +155,7 @@ module MjmlRb
159
155
  private
160
156
 
161
157
  def render_accordion(node, context, attrs)
162
- accordion_attrs = DEFAULTS.merge(attrs)
158
+ accordion_attrs = DEFAULT_ATTRIBUTES.merge(attrs)
163
159
  outer_style = style_join(
164
160
  "padding" => accordion_attrs["padding"],
165
161
  "background-color" => accordion_attrs["container-background-color"]
@@ -225,7 +221,7 @@ module MjmlRb
225
221
  end
226
222
 
227
223
  def render_accordion_title(node, attrs)
228
- title_attrs = TITLE_DEFAULTS.merge(attrs)
224
+ title_attrs = TITLE_DEFAULT_ATTRIBUTES.merge(attrs)
229
225
  td_style = style_join(
230
226
  "width" => "100%",
231
227
  "background-color" => title_attrs["background-color"],
@@ -262,7 +258,7 @@ module MjmlRb
262
258
  end
263
259
 
264
260
  def render_accordion_text(node, attrs)
265
- text_attrs = TEXT_DEFAULTS.merge(attrs)
261
+ text_attrs = TEXT_DEFAULT_ATTRIBUTES.merge(attrs)
266
262
  td_style = style_join(
267
263
  "background" => text_attrs["background-color"],
268
264
  "font-size" => text_attrs["font-size"],
@@ -15,10 +15,6 @@ module MjmlRb
15
15
  end
16
16
  end
17
17
 
18
- def tags
19
- TAGS
20
- end
21
-
22
18
  def render(tag_name:, node:, context:, attrs:, parent:)
23
19
  ""
24
20
  end
@@ -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) : []
@@ -11,13 +14,7 @@ module MjmlRb
11
14
  end
12
15
 
13
16
  def default_attributes
14
- if const_defined?(:DEFAULT_ATTRIBUTES)
15
- const_get(:DEFAULT_ATTRIBUTES)
16
- elsif const_defined?(:DEFAULTS)
17
- const_get(:DEFAULTS)
18
- else
19
- {}
20
- end
17
+ const_defined?(:DEFAULT_ATTRIBUTES) ? const_get(:DEFAULT_ATTRIBUTES) : {}
21
18
  end
22
19
  end
23
20
 
@@ -14,10 +14,6 @@ module MjmlRb
14
14
  "width" => "600px"
15
15
  }.freeze
16
16
 
17
- def tags
18
- TAGS
19
- end
20
-
21
17
  def render(tag_name:, node:, context:, attrs:, parent:)
22
18
  return render_children(node, context, parent: parent) unless tag_name == "mj-body"
23
19
 
@@ -13,10 +13,6 @@ module MjmlRb
13
13
  "width" => "480px"
14
14
  }.freeze
15
15
 
16
- def tags
17
- TAGS
18
- end
19
-
20
16
  def render(tag_name:, node:, context:, attrs:, parent:)
21
17
  ""
22
18
  end
@@ -41,7 +41,7 @@ module MjmlRb
41
41
  "width" => "unit(px,%)"
42
42
  }.freeze
43
43
 
44
- DEFAULTS = {
44
+ DEFAULT_ATTRIBUTES = {
45
45
  "align" => "center",
46
46
  "background-color" => "#414141",
47
47
  "border" => "none",
@@ -59,12 +59,8 @@ module MjmlRb
59
59
  "vertical-align" => "middle"
60
60
  }.freeze
61
61
 
62
- def tags
63
- TAGS
64
- end
65
-
66
62
  def render(tag_name:, node:, context:, attrs:, parent:)
67
- a = DEFAULTS.merge(attrs)
63
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
68
64
 
69
65
  bg_color = a["background-color"]
70
66
  inner_padding = a["inner-padding"]
@@ -173,21 +169,6 @@ module MjmlRb
173
169
  "#{result.to_i}px"
174
170
  end
175
171
 
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
172
  end
192
173
  end
193
174
  end
@@ -40,10 +40,6 @@ module MjmlRb
40
40
  "tb-selected-border-color" => "#ccc"
41
41
  }.freeze
42
42
 
43
- def tags
44
- TAGS
45
- end
46
-
47
43
  def render(tag_name:, node:, context:, attrs:, parent:)
48
44
  a = DEFAULT_ATTRIBUTES.merge(attrs)
49
45
  children = carousel_images(node)
@@ -23,10 +23,6 @@ module MjmlRb
23
23
  "target" => "_blank"
24
24
  }.freeze
25
25
 
26
- def tags
27
- TAGS
28
- end
29
-
30
26
  def render(tag_name:, node:, context:, attrs:, parent:)
31
27
  render_item(
32
28
  node,
@@ -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
@@ -19,7 +19,7 @@ module MjmlRb
19
19
  "align" => "enum(left,center,right)"
20
20
  }.freeze
21
21
 
22
- DEFAULTS = {
22
+ DEFAULT_ATTRIBUTES = {
23
23
  "align" => "center",
24
24
  "border-color" => "#000000",
25
25
  "border-style" => "solid",
@@ -28,12 +28,8 @@ module MjmlRb
28
28
  "width" => "100%"
29
29
  }.freeze
30
30
 
31
- def tags
32
- TAGS
33
- end
34
-
35
31
  def render(tag_name:, node:, context:, attrs:, parent:)
36
- a = DEFAULTS.merge(attrs)
32
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
37
33
 
38
34
  outer_td_style = style_join(
39
35
  "background" => a["container-background-color"],
@@ -112,15 +108,6 @@ module MjmlRb
112
108
  end
113
109
  end
114
110
 
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
111
  def outlook_block(align, style, width)
125
112
  # Strip trailing px for the HTML width attribute
126
113
  width_int = width.to_i.to_s
@@ -16,10 +16,6 @@ module MjmlRb
16
16
  "direction" => "ltr"
17
17
  }.freeze
18
18
 
19
- def tags
20
- TAGS
21
- end
22
-
23
19
  def render(tag_name:, node:, context:, attrs:, parent:)
24
20
  width_pct = context.delete(:_column_width_pct) || 100.0
25
21
  a = DEFAULT_ATTRIBUTES.merge(attrs)
@@ -25,10 +25,6 @@ module MjmlRb
25
25
  end
26
26
  end
27
27
 
28
- def tags
29
- TAGS
30
- end
31
-
32
28
  def render(tag_name:, node:, context:, attrs:, parent:)
33
29
  ""
34
30
  end
@@ -43,10 +43,6 @@ module MjmlRb
43
43
  "vertical-align" => "top"
44
44
  }.freeze
45
45
 
46
- def tags
47
- TAGS
48
- end
49
-
50
46
  def render(tag_name:, node:, context:, attrs:, parent:)
51
47
  a = DEFAULT_ATTRIBUTES.merge(attrs)
52
48
  container_width = normalize_container_width(context[:container_width] || "600px")
@@ -37,7 +37,7 @@ module MjmlRb
37
37
  "usemap" => "string"
38
38
  }.freeze
39
39
 
40
- DEFAULTS = {
40
+ DEFAULT_ATTRIBUTES = {
41
41
  "alt" => "",
42
42
  "align" => "center",
43
43
  "border" => "0",
@@ -47,10 +47,6 @@ module MjmlRb
47
47
  "font-size" => "13px"
48
48
  }.freeze
49
49
 
50
- def tags
51
- TAGS
52
- end
53
-
54
50
  def head_style(breakpoint)
55
51
  lower_breakpoint = make_lower_breakpoint(breakpoint)
56
52
 
@@ -67,7 +63,7 @@ module MjmlRb
67
63
  end
68
64
 
69
65
  def render(tag_name:, node:, context:, attrs:, parent:)
70
- a = DEFAULTS.merge(attrs)
66
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
71
67
 
72
68
  fluid = a["fluid-on-mobile"] == "true"
73
69
  full_width = a["full-width"] == "full-width"
@@ -202,15 +198,6 @@ module MjmlRb
202
198
  end
203
199
  end
204
200
 
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
201
  def make_lower_breakpoint(breakpoint)
215
202
  matched = breakpoint.to_s.match(/[0-9]+/)
216
203
  return breakpoint if matched.nil?
@@ -91,10 +91,6 @@ module MjmlRb
91
91
  end
92
92
  end
93
93
 
94
- def tags
95
- TAGS
96
- end
97
-
98
94
  def head_style(breakpoint)
99
95
  lower_breakpoint = make_lower_breakpoint(breakpoint)
100
96
 
@@ -9,10 +9,6 @@ module MjmlRb
9
9
  "position" => "enum(file-start)"
10
10
  }.freeze
11
11
 
12
- def tags
13
- TAGS
14
- end
15
-
16
12
  def render(tag_name:, node:, context:, attrs:, parent:)
17
13
  raw_inner(node)
18
14
  end
@@ -54,10 +54,6 @@ module MjmlRb
54
54
  end
55
55
  end
56
56
 
57
- def tags
58
- TAGS
59
- end
60
-
61
57
  def render(tag_name:, node:, context:, attrs:, parent:)
62
58
  case tag_name
63
59
  when "mj-wrapper"
@@ -80,12 +76,6 @@ module MjmlRb
80
76
  value.to_s.to_i
81
77
  end
82
78
 
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
79
  def parse_padding_value(str)
90
80
  return 0 if str.nil? || str.to_s.strip.empty?
91
81
 
@@ -107,7 +107,7 @@ module MjmlRb
107
107
  icon-size icon-height icon-padding text-padding line-height text-decoration
108
108
  ].freeze
109
109
 
110
- SOCIAL_DEFAULTS = {
110
+ SOCIAL_DEFAULT_ATTRIBUTES = {
111
111
  "align" => "center",
112
112
  "border-radius" => "3px",
113
113
  "color" => "#333333",
@@ -120,7 +120,7 @@ module MjmlRb
120
120
  "text-decoration" => "none"
121
121
  }.freeze
122
122
 
123
- ELEMENT_DEFAULTS = {
123
+ ELEMENT_DEFAULT_ATTRIBUTES = {
124
124
  "alt" => "",
125
125
  "align" => "left",
126
126
  "icon-position" => "left",
@@ -136,17 +136,13 @@ module MjmlRb
136
136
  "vertical-align" => "middle"
137
137
  }.freeze
138
138
 
139
- def tags
140
- TAGS
141
- end
142
-
143
139
  def render(tag_name:, node:, context:, attrs:, parent:)
144
140
  case tag_name
145
141
  when "mj-social"
146
142
  render_social(node, context, attrs)
147
143
  when "mj-social-element"
148
144
  # Direct dispatch (no parent attrs merging) — fallback for standalone use
149
- render_social_element(node, ELEMENT_DEFAULTS.merge(attrs))
145
+ render_social_element(node, ELEMENT_DEFAULT_ATTRIBUTES.merge(attrs))
150
146
  end
151
147
  end
152
148
 
@@ -155,7 +151,7 @@ module MjmlRb
155
151
  # ── mj-social ──────────────────────────────────────────────────────────
156
152
 
157
153
  def render_social(node, context, attrs)
158
- a = SOCIAL_DEFAULTS.merge(attrs)
154
+ a = SOCIAL_DEFAULT_ATTRIBUTES.merge(attrs)
159
155
 
160
156
  outer_td_style = style_join(
161
157
  "background" => a["container-background-color"],
@@ -211,7 +207,7 @@ module MjmlRb
211
207
  children_html = with_inherited_mj_class(context, node) do
212
208
  elements.map.with_index do |child, idx|
213
209
  child_attrs = resolved_attributes(child, context)
214
- merged_attrs = ELEMENT_DEFAULTS.merge(inherited).merge(child_attrs)
210
+ merged_attrs = ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(child_attrs)
215
211
  el_html = render_social_element(child, merged_attrs)
216
212
 
217
213
  outlook_td_open = idx == 0 ? "<td>" : "</td><td>"
@@ -229,7 +225,7 @@ module MjmlRb
229
225
  children_html = with_inherited_mj_class(context, node) do
230
226
  elements.map do |child|
231
227
  child_attrs = resolved_attributes(child, context)
232
- merged_attrs = ELEMENT_DEFAULTS.merge(inherited).merge(child_attrs)
228
+ merged_attrs = ELEMENT_DEFAULT_ATTRIBUTES.merge(inherited).merge(child_attrs)
233
229
  render_social_element(child, merged_attrs)
234
230
  end.join
235
231
  end
@@ -24,10 +24,6 @@ module MjmlRb
24
24
  "height" => "20px"
25
25
  }.freeze
26
26
 
27
- def tags
28
- TAGS
29
- end
30
-
31
27
  def render(tag_name:, node:, context:, attrs:, parent:)
32
28
  a = self.class.default_attributes.merge(attrs)
33
29
  height = a["height"]
@@ -27,7 +27,7 @@ module MjmlRb
27
27
  "width" => "unit(px,%,auto)"
28
28
  }.freeze
29
29
 
30
- DEFAULTS = {
30
+ DEFAULT_ATTRIBUTES = {
31
31
  "align" => "left",
32
32
  "border" => "none",
33
33
  "cellpadding" => "0",
@@ -41,12 +41,8 @@ module MjmlRb
41
41
  "width" => "100%"
42
42
  }.freeze
43
43
 
44
- def tags
45
- TAGS
46
- end
47
-
48
44
  def render(tag_name:, node:, context:, attrs:, parent:)
49
- a = DEFAULTS.merge(attrs)
45
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
50
46
 
51
47
  outer_td_style = style_join(
52
48
  "background" => a["container-background-color"],
@@ -27,7 +27,7 @@ module MjmlRb
27
27
  "vertical-align" => "enum(top,bottom,middle)"
28
28
  }.freeze
29
29
 
30
- DEFAULTS = {
30
+ DEFAULT_ATTRIBUTES = {
31
31
  "align" => "left",
32
32
  "color" => "#000000",
33
33
  "font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
@@ -36,12 +36,8 @@ module MjmlRb
36
36
  "padding" => "10px 25px"
37
37
  }.freeze
38
38
 
39
- def tags
40
- TAGS
41
- end
42
-
43
39
  def render(tag_name:, node:, context:, attrs:, parent:)
44
- a = DEFAULTS.merge(attrs)
40
+ a = DEFAULT_ATTRIBUTES.merge(attrs)
45
41
  height = a["height"]
46
42
 
47
43
  outer_td_style = style_join(
@@ -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.2".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.2
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