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 +4 -4
- data/Gemfile +4 -1
- data/README.md +2 -0
- data/Rakefile +9 -0
- data/lib/mjml-rb/ast_node.rb +4 -2
- data/lib/mjml-rb/cli.rb +16 -1
- data/lib/mjml-rb/compiler.rb +62 -10
- data/lib/mjml-rb/component_registry.rb +23 -7
- data/lib/mjml-rb/components/base.rb +3 -0
- data/lib/mjml-rb/components/button.rb +0 -15
- data/lib/mjml-rb/components/carousel.rb +37 -33
- 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 +25 -13
- data/lib/mjml-rb/renderer.rb +11 -7
- 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/ast_node.rb
CHANGED
|
@@ -25,11 +25,13 @@ module MjmlRb
|
|
|
25
25
|
|
|
26
26
|
def text_content
|
|
27
27
|
return @content.to_s if text?
|
|
28
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
24
|
+
tag_class_cache[tag_name]
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def dependency_rules
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
|
@@ -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 =
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
".mj-carousel-#{carousel_id}-radio
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
".mj-carousel-#{carousel_id}-radio-#{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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. & { ). 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(
|
|
266
|
+
content.gsub(BARE_AMPERSAND_RE, "&")
|
|
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(
|
|
274
|
+
xml.gsub(LINE_ANNOTATION_RE) do
|
|
263
275
|
if Regexp.last_match(1) # newline
|
|
264
276
|
line += 1
|
|
265
277
|
"\n"
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -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|
|
|
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
|
|
819
|
-
return
|
|
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
|
-
|
|
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
|
|
854
|
+
next unless seen.add?(value)
|
|
851
855
|
|
|
852
856
|
memo << value
|
|
853
857
|
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
|
+
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
|