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 +4 -4
- data/Gemfile +4 -1
- data/README.md +2 -0
- data/Rakefile +9 -0
- data/lib/mjml-rb/cli.rb +16 -1
- data/lib/mjml-rb/compiler.rb +62 -10
- data/lib/mjml-rb/components/base.rb +3 -0
- data/lib/mjml-rb/components/button.rb +0 -15
- data/lib/mjml-rb/components/css_helpers.rb +33 -0
- data/lib/mjml-rb/components/divider.rb +0 -9
- data/lib/mjml-rb/components/image.rb +0 -9
- data/lib/mjml-rb/components/section.rb +0 -6
- data/lib/mjml-rb/config_file.rb +10 -2
- data/lib/mjml-rb/parser.rb +10 -5
- data/lib/mjml-rb/validator.rb +19 -6
- data/lib/mjml-rb/version.rb +1 -1
- data/mjml-rb.gemspec +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b8ae00ad7838acf9020b38b585a90fbf86981d234e813da8f9d45d52421dd1a
|
|
4
|
+
data.tar.gz: b6723a5a59e85e6db01ae7bcb8e8b185c6cbd5253fccd195f93aa6158fa08361
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7ad920fbb9996b3bdb4f0321420121695ebf3124ce466e19059b38f72add99284f8208fd2d985313b0e217762f5676ed669cb438cbaec6b68685366e43186c8
|
|
7
|
+
data.tar.gz: 853ffaae0d6889dc630837b73856204bc68d42c8eb7a01dc125c6629a2b9b0258a6fc73a22855e553f1c34a2b2d4876a04e76598e8e0f3be5c5bd6e99ef99fe1
|
data/Gemfile
CHANGED
data/README.md
CHANGED
data/Rakefile
ADDED
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
|
|
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|
|
data/lib/mjml-rb/compiler.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
70
|
+
Result.new(
|
|
47
71
|
html: post_process(html, merged),
|
|
48
|
-
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
|
-
|
|
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,
|
|
@@ -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
|
|
data/lib/mjml-rb/config_file.rb
CHANGED
|
@@ -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
|
-
|
|
31
|
-
{}
|
|
39
|
+
raise ConfigError.new("Failed to parse #{path}: #{e.message}", path: path)
|
|
32
40
|
end
|
|
33
41
|
end
|
|
34
42
|
end
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
data/lib/mjml-rb/validator.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
walk(root,
|
|
25
|
-
|
|
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,
|
data/lib/mjml-rb/version.rb
CHANGED
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.
|
|
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
|