mjml-rb 0.5.2 → 0.6.0
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/CHANGELOG.md +29 -0
- data/README.md +60 -22
- data/THIRD_PARTY_LICENSES.md +25 -0
- data/docs/ARCHITECTURE.md +363 -0
- data/docs/MIGRATING_TO_V5.md +112 -0
- data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
- data/docs/PARITY_AUDIT.md +124 -0
- data/docs/RELEASING.md +35 -0
- data/docs/USAGE.md +1258 -0
- data/docs/releases/v0.6.0.md +63 -0
- data/lib/mjml-rb/cli.rb +43 -31
- data/lib/mjml-rb/column_gutter.rb +81 -0
- data/lib/mjml-rb/compiler.rb +32 -122
- data/lib/mjml-rb/component_registry.rb +11 -1
- data/lib/mjml-rb/components/accordion.rb +11 -9
- data/lib/mjml-rb/components/base.rb +1 -1
- data/lib/mjml-rb/components/body.rb +6 -3
- data/lib/mjml-rb/components/carousel.rb +2 -2
- data/lib/mjml-rb/components/carousel_image.rb +2 -2
- data/lib/mjml-rb/components/column.rb +16 -11
- data/lib/mjml-rb/components/group.rb +13 -4
- data/lib/mjml-rb/components/hero.rb +7 -2
- data/lib/mjml-rb/components/image.rb +1 -3
- data/lib/mjml-rb/components/raw.rb +1 -1
- data/lib/mjml-rb/components/section.rb +23 -8
- data/lib/mjml-rb/components/social.rb +18 -27
- data/lib/mjml-rb/components/table.rb +1 -1
- data/lib/mjml-rb/config_file.rb +25 -15
- data/lib/mjml-rb/dependencies.rb +1 -0
- data/lib/mjml-rb/formatter.rb +113 -0
- data/lib/mjml-rb/node_compat.rb +4 -0
- data/lib/mjml-rb/options.rb +81 -0
- data/lib/mjml-rb/parser.rb +138 -94
- data/lib/mjml-rb/renderer.rb +50 -76
- data/lib/mjml-rb/result.rb +3 -2
- data/lib/mjml-rb/validator.rb +14 -33
- data/lib/mjml-rb/version.rb +2 -1
- data/lib/mjml-rb.rb +2 -2
- data/mjml-rb.gemspec +2 -2
- metadata +19 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# mjml-rb 0.6.0 — 2026-10-16
|
|
2
|
+
|
|
3
|
+
`mjml-rb` 0.6.0 targets MJML 5.4.1. The gem version is independent of the
|
|
4
|
+
upstream MJML version. Compilation remains synchronous and pure Ruby;
|
|
5
|
+
Node.js/npm is used only for development reference checks.
|
|
6
|
+
|
|
7
|
+
Requires Ruby >= 3.3, `css_parser` >= 1.17 and < 4, and Nokogiri >= 1.13.
|
|
8
|
+
The CI matrix covers `css_parser` 1.21, 2.x and 3.x across Ruby 3.3, 3.4 and 4.0,
|
|
9
|
+
plus a pinned `mjml@5.4.1` reference job.
|
|
10
|
+
|
|
11
|
+
## Upgrade changes
|
|
12
|
+
|
|
13
|
+
- `mj-include` is disabled by default, including in strict validation. Enable
|
|
14
|
+
it with `ignore_includes: false` and a `file_path` for the template's directory,
|
|
15
|
+
or use CLI `--config.allowIncludes`. Only the canonical template root and
|
|
16
|
+
explicit `include_path` roots are readable; escapes and absolute paths are denied.
|
|
17
|
+
- `mj-body` owns HTML `<body>`, including `id`, `css-class`, background styling
|
|
18
|
+
and the preview. Update CSS selectors that expected the class on the inner
|
|
19
|
+
article. Background painting below short emails can change in browser previews.
|
|
20
|
+
- `minify` wins over `beautify`. Source comments can be removed while preserving
|
|
21
|
+
Outlook conditionals, and raw content and significant whitespace are protected.
|
|
22
|
+
Formatting and entity spelling can differ from previous releases.
|
|
23
|
+
- Options accept snake_case and upstream camelCase aliases; conflicting aliases
|
|
24
|
+
raise errors. JSON config options require `use_mjml_config_options: true`.
|
|
25
|
+
|
|
26
|
+
## Improvements
|
|
27
|
+
|
|
28
|
+
- Responsive section gutters, social icon borders, string border radii, and
|
|
29
|
+
v5 hero and accordion behavior.
|
|
30
|
+
- Correct mixed column widths, raw siblings, social HTML, numeric image width,
|
|
31
|
+
CSS include ordering, and shared component registration metadata.
|
|
32
|
+
- CLI validation without rendering or output files; watch mode follows enabled
|
|
33
|
+
recursive includes, including deletion, recreation and graph changes.
|
|
34
|
+
- Support for `css_parser` 3.x, with usage, migration and compatibility documents
|
|
35
|
+
included in the packaged gem.
|
|
36
|
+
|
|
37
|
+
## Validation and scope
|
|
38
|
+
|
|
39
|
+
Maintainer-reported downstream validation covered eight booking-mailer actions,
|
|
40
|
+
eleven representative scenarios, nine locales and desktop/mobile widths. All
|
|
41
|
+
198 exact Chrome screenshot comparisons at commit `90f646e` with `css_parser`
|
|
42
|
+
3.3.0 matched the prior 0.6.0 / `css_parser` 2.2.0 run at `313ad6a`. All 198 master
|
|
43
|
+
control captures also matched their previous run.
|
|
44
|
+
|
|
45
|
+
Against the downstream application's 0.5.2 baseline, 185 comparisons were
|
|
46
|
+
pixel-identical, eleven differed only in the background below short desktop
|
|
47
|
+
emails, and two restored a missing space in the Turkish KYC email. A white
|
|
48
|
+
HTML-root background applied only in the browser harness eliminated all eleven
|
|
49
|
+
background differences exactly; no footer markup change was needed. There were
|
|
50
|
+
no rendering errors, missing images or unstable captures. All 198 generated HTML
|
|
51
|
+
documents had balanced tags, and all 32 focused downstream specs passed.
|
|
52
|
+
|
|
53
|
+
These are representative Chrome renders, not email-client certification.
|
|
54
|
+
Customer screenshots remain private and public examples use synthetic content.
|
|
55
|
+
The strict XML parser, conservative formatter and custom CSS inliner retain
|
|
56
|
+
documented differences from upstream. Unsupported minifier and Juice options
|
|
57
|
+
fail explicitly; JavaScript config and component packages are unsupported.
|
|
58
|
+
|
|
59
|
+
The packaged `docs/MIGRATING_TO_V5.md` and `docs/PARITY_AUDIT.md` contain the full
|
|
60
|
+
migration details and compatibility limits.
|
|
61
|
+
|
|
62
|
+
Changes: [PR #24](https://github.com/faraquet/mjml-rb/pull/24).
|
|
63
|
+
Upstream reference: [MJML 5.4.1](https://github.com/mjmlio/mjml/releases/tag/v5.4.1).
|
data/lib/mjml-rb/cli.rb
CHANGED
|
@@ -24,14 +24,12 @@ module MjmlRb
|
|
|
24
24
|
parser.parse!(argv)
|
|
25
25
|
options[:positional] = argv
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
explicit = Options.normalize(options[:config])
|
|
28
|
+
rc_config = ConfigFile.load(explicit[:mjml_config_path] || Dir.pwd)
|
|
29
|
+
configured = explicit[:use_mjml_config_options] ? rc_config.fetch(:options, {}) : {}
|
|
30
|
+
config = Options.merge({beautify: true}, Options.merge(configured, explicit))
|
|
32
31
|
input_mode, input_values = resolve_input(options)
|
|
33
32
|
output_mode = resolve_output(options)
|
|
34
|
-
config = options[:config]
|
|
35
33
|
|
|
36
34
|
case input_mode
|
|
37
35
|
when :watch
|
|
@@ -56,7 +54,7 @@ module MjmlRb
|
|
|
56
54
|
@stderr.puts("\nConfiguration error:")
|
|
57
55
|
@stderr.puts(e.message)
|
|
58
56
|
1
|
|
59
|
-
rescue OptionParser::ParseError => e
|
|
57
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
60
58
|
@stderr.puts(e.message)
|
|
61
59
|
1
|
|
62
60
|
end
|
|
@@ -72,10 +70,7 @@ module MjmlRb
|
|
|
72
70
|
stdout: false,
|
|
73
71
|
output: nil,
|
|
74
72
|
no_stdout_file_comment: false,
|
|
75
|
-
config: {
|
|
76
|
-
beautify: true,
|
|
77
|
-
minify: false
|
|
78
|
-
}
|
|
73
|
+
config: {}
|
|
79
74
|
}
|
|
80
75
|
end
|
|
81
76
|
|
|
@@ -91,13 +86,14 @@ module MjmlRb
|
|
|
91
86
|
opts.on("-o", "--output PATH", "Output file or directory") { |v| options[:output] = v }
|
|
92
87
|
opts.on("-c", "--config KEY=VALUE", "Compiler config key/value") do |entry|
|
|
93
88
|
key, value = parse_key_value(entry)
|
|
94
|
-
options[:config][
|
|
89
|
+
options[:config][key] = parse_typed_value(value)
|
|
95
90
|
end
|
|
91
|
+
opts.on("--filePath PATH", "Base path for stdin includes") { |v| options[:config]["filePath"] = v }
|
|
96
92
|
opts.on("--noStdoutFileComment", "Skip file comment header on stdout") do
|
|
97
93
|
options[:no_stdout_file_comment] = true
|
|
98
94
|
end
|
|
99
95
|
opts.on("-V", "--version", "Show version") do
|
|
100
|
-
@stdout.puts("mjml-ruby #{MjmlRb::VERSION}")
|
|
96
|
+
@stdout.puts("mjml-ruby #{MjmlRb::VERSION} (MJML #{MjmlRb::MJML_COMPATIBILITY_VERSION})")
|
|
101
97
|
raise SystemExit, 0
|
|
102
98
|
end
|
|
103
99
|
opts.on("-h", "--help", "Show help") do
|
|
@@ -106,7 +102,7 @@ module MjmlRb
|
|
|
106
102
|
end
|
|
107
103
|
end.tap do
|
|
108
104
|
inline_config.each do |key, value|
|
|
109
|
-
options[:config][
|
|
105
|
+
options[:config][key] = value
|
|
110
106
|
end
|
|
111
107
|
end
|
|
112
108
|
end
|
|
@@ -147,10 +143,6 @@ module MjmlRb
|
|
|
147
143
|
end
|
|
148
144
|
end
|
|
149
145
|
|
|
150
|
-
def normalize_key(key)
|
|
151
|
-
key.to_s.tr("-", "_").to_sym
|
|
152
|
-
end
|
|
153
|
-
|
|
154
146
|
def resolve_input(options)
|
|
155
147
|
inputs = {}
|
|
156
148
|
inputs[:read] = options[:read] unless options[:read].empty?
|
|
@@ -187,10 +179,17 @@ module MjmlRb
|
|
|
187
179
|
end
|
|
188
180
|
|
|
189
181
|
def process_input(input, mode, config)
|
|
182
|
+
if input[:file]
|
|
183
|
+
config = config.merge(
|
|
184
|
+
actual_path: input[:file],
|
|
185
|
+
file_path: config[:file_path] || File.dirname(input[:file])
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
190
189
|
case mode
|
|
191
190
|
when :validate
|
|
192
191
|
compiler = Compiler.new(config.merge(validation_level: "strict"))
|
|
193
|
-
{ file: input[:file], compiled: compiler.
|
|
192
|
+
{ file: input[:file], compiled: compiler.validate(input[:mjml]) }
|
|
194
193
|
else
|
|
195
194
|
compiler = Compiler.new(config)
|
|
196
195
|
{ file: input[:file], compiled: compiler.compile(input[:mjml]) }
|
|
@@ -208,6 +207,8 @@ module MjmlRb
|
|
|
208
207
|
raise CLIError, "Validation failed"
|
|
209
208
|
end
|
|
210
209
|
|
|
210
|
+
return 0 if options[:validate].any?
|
|
211
|
+
|
|
211
212
|
case output_mode
|
|
212
213
|
when :stdout
|
|
213
214
|
print_stdout(processed, !options[:no_stdout_file_comment])
|
|
@@ -287,20 +288,29 @@ module MjmlRb
|
|
|
287
288
|
raise CLIError, "Need an output directory when watching multiple files"
|
|
288
289
|
end
|
|
289
290
|
|
|
290
|
-
state =
|
|
291
|
+
state = {}
|
|
292
|
+
dependencies = {}
|
|
293
|
+
# Compile initially to discover the include graph; each later successful
|
|
294
|
+
# parse replaces its dependency set, including missing local files.
|
|
295
|
+
compile_file = lambda do |file|
|
|
296
|
+
processed = process_input({file: file, mjml: File.read(file)}, :read, config)
|
|
297
|
+
discovered = [file] + processed[:compiled].dependencies
|
|
298
|
+
discovered.concat(dependencies[file] || []) unless processed[:compiled].success?
|
|
299
|
+
dependencies[file] = discovered.uniq
|
|
300
|
+
state[file] = dependency_state(dependencies[file])
|
|
301
|
+
emit_results([processed], :output, output: output_path || "", no_stdout_file_comment: true, validate: [])
|
|
302
|
+
@stdout.puts("#{file} - Successfully compiled") if processed[:compiled].success?
|
|
303
|
+
end
|
|
304
|
+
files.each { |file| compile_file.call(file) }
|
|
291
305
|
@stdout.puts("Watching #{files.size} file(s)...")
|
|
292
306
|
|
|
293
307
|
loop do
|
|
294
308
|
files.each do |file|
|
|
295
|
-
current =
|
|
296
|
-
next if current
|
|
297
|
-
|
|
309
|
+
current = dependency_state(dependencies[file])
|
|
310
|
+
next if current == state[file]
|
|
298
311
|
state[file] = current
|
|
299
312
|
begin
|
|
300
|
-
|
|
301
|
-
processed = process_input(input, :read, config)
|
|
302
|
-
emit_results([processed], :output, output: output_path, no_stdout_file_comment: true, validate: [])
|
|
303
|
-
@stdout.puts("#{file} - Successfully compiled")
|
|
313
|
+
compile_file.call(file)
|
|
304
314
|
rescue StandardError => e
|
|
305
315
|
@stderr.puts("#{file} - Error while compiling file: #{e.message}")
|
|
306
316
|
end
|
|
@@ -309,10 +319,12 @@ module MjmlRb
|
|
|
309
319
|
end
|
|
310
320
|
end
|
|
311
321
|
|
|
312
|
-
def
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
322
|
+
def dependency_state(paths)
|
|
323
|
+
paths.to_h do |path|
|
|
324
|
+
stat = File.stat(path) rescue nil
|
|
325
|
+
[path, stat && [stat.mtime, stat.size, stat.ino]]
|
|
326
|
+
end
|
|
316
327
|
end
|
|
328
|
+
|
|
317
329
|
end
|
|
318
330
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module MjmlRb
|
|
2
|
+
# Port of mjml-column's v5 gutter arithmetic. One immutable layout value is
|
|
3
|
+
# passed to both the normal column and its Outlook cell.
|
|
4
|
+
class ColumnGutter
|
|
5
|
+
def initialize(width:, container_width:, gutter:, count:, index:, direction: "ltr", grouped: false)
|
|
6
|
+
@unit = width.to_s.end_with?("px") ? "px" : "%"
|
|
7
|
+
@width = width.to_f
|
|
8
|
+
@container = container_width.to_f
|
|
9
|
+
@gutter = gutter.to_s
|
|
10
|
+
@count, @index, @rtl, @grouped = count, index, direction == "rtl", grouped
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def desktop_width
|
|
14
|
+
reduced = [(@width - normalized_gutter(@unit) * (@count - 1) / @count).round(6), 0].max
|
|
15
|
+
return reduced if @unit == "%"
|
|
16
|
+
|
|
17
|
+
floor = reduced.floor
|
|
18
|
+
extras = [[(@count * (reduced - floor)).round, 0].max, @count].min
|
|
19
|
+
floor + (@index < extras ? 1 : 0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def width
|
|
23
|
+
"#{number(desktop_width)}#{@unit}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def width_class
|
|
27
|
+
"mj-column-#{@unit == '%' ? 'per' : 'px'}-#{number(desktop_width).tr('.', '-')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def padding_class
|
|
31
|
+
gutter = normalized_gutter(@unit)
|
|
32
|
+
gutter = gutter.round if @unit == "px"
|
|
33
|
+
"mj-column-gutter-#{@count}-#{@index + 1}-#{@unit == '%' ? 'per' : 'px'}-#{number(gutter).tr('.', '-')}#{@rtl ? '-rtl' : ''}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def desktop_padding(unit = @unit)
|
|
37
|
+
gutter = normalized_gutter(unit)
|
|
38
|
+
gutter = gutter.round if unit == "px"
|
|
39
|
+
leading = unit == "px" ? (gutter / 2.0).ceil : gutter / 2.0
|
|
40
|
+
trailing = unit == "px" ? (gutter / 2.0).floor : gutter / 2.0
|
|
41
|
+
first, last = @index.zero?, @index == @count - 1
|
|
42
|
+
right = @rtl ? (first ? 0 : trailing) : (last ? 0 : leading)
|
|
43
|
+
left = @rtl ? (last ? 0 : leading) : (first ? 0 : trailing)
|
|
44
|
+
padding([0, right, 0, left], unit)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mobile_padding
|
|
48
|
+
return desktop_padding if @grouped
|
|
49
|
+
half = normalized_gutter("%") / 2.0
|
|
50
|
+
padding([@index.zero? ? 0 : half, 0, @index == @count - 1 ? 0 : half, 0], "%")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mobile_width
|
|
54
|
+
return "100%" unless @grouped
|
|
55
|
+
percentage = @unit == "%" ? desktop_width : desktop_width / @container * 100
|
|
56
|
+
"#{number(percentage)}%"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def grouped?
|
|
60
|
+
@grouped
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalized_gutter(unit)
|
|
66
|
+
value = @gutter.to_f
|
|
67
|
+
source_unit = @gutter.end_with?("%") ? "%" : "px"
|
|
68
|
+
return value if unit == source_unit
|
|
69
|
+
return 0 if @container.zero?
|
|
70
|
+
unit == "%" ? value / @container * 100 : @container * value / 100
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def padding(values, unit)
|
|
74
|
+
values.map { |value| "#{number(unit == 'px' ? value.round : value)}#{unit}" }.join(" ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def number(value)
|
|
78
|
+
value.round(6).to_s.sub(/\.0+$/, "")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/mjml-rb/compiler.rb
CHANGED
|
@@ -1,36 +1,19 @@
|
|
|
1
1
|
require_relative "result"
|
|
2
|
+
require_relative "options"
|
|
3
|
+
require_relative "config_file"
|
|
4
|
+
require_relative "formatter"
|
|
2
5
|
require_relative "validator"
|
|
3
6
|
require_relative "parser"
|
|
4
7
|
require_relative "renderer"
|
|
5
8
|
|
|
6
9
|
module MjmlRb
|
|
7
10
|
class Compiler
|
|
8
|
-
DEFAULT_OPTIONS =
|
|
9
|
-
|
|
10
|
-
minify: false,
|
|
11
|
-
keep_comments: true,
|
|
12
|
-
ignore_includes: false,
|
|
13
|
-
printer_support: false,
|
|
14
|
-
preprocessors: [],
|
|
15
|
-
validation_level: "soft",
|
|
16
|
-
file_path: ".",
|
|
17
|
-
actual_path: "."
|
|
18
|
-
}.freeze
|
|
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
|
-
|
|
11
|
+
DEFAULT_OPTIONS = Options::DEFAULTS
|
|
12
|
+
VALID_OPTION_KEYS = Options::KEYS
|
|
25
13
|
VALID_VALIDATION_LEVELS = %w[soft strict skip].freeze
|
|
26
14
|
|
|
27
15
|
def initialize(options = {})
|
|
28
|
-
|
|
29
|
-
validate_options!(normalized)
|
|
30
|
-
@options = DEFAULT_OPTIONS.merge(normalized)
|
|
31
|
-
@parser = Parser.new
|
|
32
|
-
@validator = Validator.new(parser: @parser)
|
|
33
|
-
@renderer = Renderer.new
|
|
16
|
+
@options = Options.normalize(options)
|
|
34
17
|
end
|
|
35
18
|
|
|
36
19
|
def mjml2html(mjml, options = {})
|
|
@@ -38,125 +21,52 @@ module MjmlRb
|
|
|
38
21
|
end
|
|
39
22
|
|
|
40
23
|
def compile(mjml, options = {})
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
merged = @options.merge(normalized)
|
|
24
|
+
do_compile(mjml, effective_options(options))
|
|
25
|
+
end
|
|
44
26
|
|
|
45
|
-
|
|
27
|
+
# Validation shares parsing and include diagnostics but never invokes a renderer.
|
|
28
|
+
def validate(mjml, options = {})
|
|
29
|
+
merged = effective_options(options)
|
|
30
|
+
parsed = Parser.new.parse_result(mjml, merged)
|
|
31
|
+
validation = Validator.new.validate(parsed, merged)
|
|
32
|
+
Result.new(**validation, dependencies: parsed.dependencies)
|
|
33
|
+
rescue Parser::ParseError => e
|
|
34
|
+
Result.new(errors: [format_error(e.message, line: e.line, file: e.file)], dependencies: e.dependencies)
|
|
46
35
|
end
|
|
47
36
|
|
|
48
37
|
private
|
|
49
38
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
file_path: merged[:file_path],
|
|
57
|
-
actual_path: merged[:actual_path]
|
|
58
|
-
)
|
|
59
|
-
|
|
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]
|
|
39
|
+
def effective_options(options)
|
|
40
|
+
explicit = Options.merge(@options, Options.normalize(options))
|
|
41
|
+
config = ConfigFile.load(explicit[:mjml_config_path] || Dir.pwd)
|
|
42
|
+
configured = explicit[:use_mjml_config_options] ? config.fetch(:options, {}) : {}
|
|
43
|
+
Options.merge(DEFAULT_OPTIONS, Options.merge(configured, explicit))
|
|
44
|
+
end
|
|
66
45
|
|
|
67
|
-
|
|
46
|
+
def do_compile(mjml, merged)
|
|
47
|
+
parsed = Parser.new.parse_result(mjml, merged)
|
|
48
|
+
validation = Validator.new.validate(parsed, merged)
|
|
49
|
+
return Result.new(**validation, dependencies: parsed.dependencies) if strict_validation_failed?(merged, validation[:errors])
|
|
68
50
|
|
|
69
|
-
html =
|
|
70
|
-
Result.new(
|
|
71
|
-
html: post_process(html, merged),
|
|
72
|
-
errors: all_errors,
|
|
73
|
-
warnings: all_warnings
|
|
74
|
-
)
|
|
51
|
+
html = Renderer.new.render(parsed.ast, merged)
|
|
52
|
+
Result.new(html: Formatter.new(merged).format(html), **validation, dependencies: parsed.dependencies)
|
|
75
53
|
rescue Parser::ParseError => e
|
|
76
|
-
Result.new(errors: [format_error(e.message, line: e.line)])
|
|
54
|
+
Result.new(errors: [format_error(e.message, line: e.line, file: e.file)], dependencies: e.dependencies)
|
|
77
55
|
rescue StandardError => e
|
|
78
|
-
Result.new(errors: [format_error(e.message)])
|
|
79
|
-
end
|
|
80
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
def validate_if_needed(ast, options)
|
|
96
|
-
return { errors: [], warnings: [] } if options[:validation_level].to_s == "skip"
|
|
97
|
-
@validator.validate(ast, options)
|
|
56
|
+
Result.new(errors: [format_error(e.message)], dependencies: parsed&.dependencies || [])
|
|
98
57
|
end
|
|
99
58
|
|
|
100
59
|
def strict_validation_failed?(options, validation_errors)
|
|
101
60
|
options[:validation_level].to_s == "strict" && !validation_errors.empty?
|
|
102
61
|
end
|
|
103
62
|
|
|
104
|
-
def
|
|
105
|
-
output = html.to_s.dup
|
|
106
|
-
output = strip_comments(output) unless options[:keep_comments]
|
|
107
|
-
output = beautify(output) if truthy?(options[:beautify])
|
|
108
|
-
output = minify(output) if truthy?(options[:minify])
|
|
109
|
-
output
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def strip_comments(html)
|
|
113
|
-
html.gsub(/<!--.*?-->/m, "")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def beautify(html)
|
|
117
|
-
# Keep it deterministic without external formatters.
|
|
118
|
-
html.gsub(/>\s*</, ">\n<")
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def minify(html)
|
|
122
|
-
html.gsub(/>\s+</, "><").gsub(/\s{2,}/, " ").strip
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def truthy?(value)
|
|
126
|
-
return value if value == true || value == false
|
|
127
|
-
!(%w[false 0 no off].include?(value.to_s.strip.downcase))
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def symbolize_keys(hash)
|
|
131
|
-
hash.each_with_object({}) do |(k, v), memo|
|
|
132
|
-
memo[k.to_s.tr("-", "_").to_sym] = v
|
|
133
|
-
end
|
|
134
|
-
end
|
|
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
|
-
|
|
153
|
-
def format_error(message, line: nil)
|
|
63
|
+
def format_error(message, line: nil, file: nil)
|
|
154
64
|
{
|
|
155
65
|
line: line,
|
|
156
66
|
message: message.to_s,
|
|
157
67
|
tag_name: nil,
|
|
158
68
|
formatted_message: message.to_s
|
|
159
|
-
}
|
|
69
|
+
}.tap { |error| error[:file] = file if file }
|
|
160
70
|
end
|
|
161
71
|
end
|
|
162
72
|
end
|
|
@@ -2,9 +2,10 @@ require "set"
|
|
|
2
2
|
|
|
3
3
|
module MjmlRb
|
|
4
4
|
class ComponentRegistry
|
|
5
|
-
attr_reader :custom_components, :custom_dependencies, :custom_ending_tags
|
|
5
|
+
attr_reader :custom_components, :custom_dependencies, :custom_ending_tags, :revision
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
|
+
@revision = 0
|
|
8
9
|
@custom_components = []
|
|
9
10
|
@custom_dependencies = {}
|
|
10
11
|
@custom_ending_tags = Set.new
|
|
@@ -12,6 +13,10 @@ module MjmlRb
|
|
|
12
13
|
|
|
13
14
|
def register(klass, dependencies: {}, ending_tags: [])
|
|
14
15
|
validate_component!(klass)
|
|
16
|
+
klass.tags.each do |tag|
|
|
17
|
+
existing = component_class_for_tag(tag)
|
|
18
|
+
raise ArgumentError, "Component tag already registered: #{tag}" if existing && existing != klass
|
|
19
|
+
end
|
|
15
20
|
@custom_components << klass unless @custom_components.include?(klass)
|
|
16
21
|
dependencies.each do |parent, children|
|
|
17
22
|
@custom_dependencies[parent] = ((@custom_dependencies[parent] || []) + Array(children)).uniq
|
|
@@ -46,9 +51,14 @@ module MjmlRb
|
|
|
46
51
|
invalidate_caches!
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
def components
|
|
55
|
+
tag_class_cache.dup
|
|
56
|
+
end
|
|
57
|
+
|
|
49
58
|
private
|
|
50
59
|
|
|
51
60
|
def invalidate_caches!
|
|
61
|
+
@revision += 1
|
|
52
62
|
@tag_class_cache = nil
|
|
53
63
|
@dependency_rules_cache = nil
|
|
54
64
|
@ending_tags_cache = nil
|
|
@@ -91,17 +91,18 @@ module MjmlRb
|
|
|
91
91
|
noinput.mj-accordion-checkbox { display:block!important; }
|
|
92
92
|
@media yahoo, only screen and (min-width:0) {
|
|
93
93
|
.mj-accordion-element { display:block; }
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
.mj-accordion-checkbox[type="checkbox"], .mj-accordion-less { display:none!important; }
|
|
95
|
+
.mj-accordion-checkbox[type="checkbox"] + * .mj-accordion-title { cursor:pointer; touch-action:manipulation; -webkit-user-select:none; -moz-user-select:none; user-select:none; }
|
|
96
|
+
.mj-accordion-checkbox[type="checkbox"] + * .mj-accordion-content { overflow:hidden; display:none; }
|
|
97
|
+
.mj-accordion-checkbox[type="checkbox"] + * .mj-accordion-more { display:block!important; }
|
|
98
|
+
.mj-accordion-checkbox:checked + * .mj-accordion-content { display:block; }
|
|
99
|
+
.mj-accordion-checkbox:checked + * .mj-accordion-more { display:none!important; }
|
|
100
|
+
.mj-accordion-checkbox:checked + * .mj-accordion-less { display:block!important; }
|
|
101
101
|
}
|
|
102
102
|
.moz-text-html input.mj-accordion-checkbox + * .mj-accordion-title { cursor:auto; touch-action:auto; -webkit-user-select:auto; -moz-user-select:auto; user-select:auto; }
|
|
103
103
|
.moz-text-html input.mj-accordion-checkbox + * .mj-accordion-content { overflow:hidden; display:block; }
|
|
104
104
|
.moz-text-html input.mj-accordion-checkbox + * .mj-accordion-ico { display:none; }
|
|
105
|
+
@goodbye { @gmail }
|
|
105
106
|
CSS
|
|
106
107
|
|
|
107
108
|
DEFAULT_ATTRIBUTES = {
|
|
@@ -217,7 +218,7 @@ module MjmlRb
|
|
|
217
218
|
content << render_accordion_text(nil, attrs) unless has_text
|
|
218
219
|
|
|
219
220
|
css_class = attrs["css-class"] ? %( class="#{escape_attr(attrs["css-class"])}") : ""
|
|
220
|
-
%(<tr#{css_class}><td style="#{td_style}"><label class="mj-accordion-element" style="#{label_style}"
|
|
221
|
+
%(<tr#{css_class}><td style="#{td_style}"><label class="mj-accordion-element" style="#{label_style}"><!--[if !mso | IE]><!--><input class="mj-accordion-checkbox" type="checkbox" style="display:none;" /><!--<![endif]--><div>#{content.join("\n")}</div></label></td></tr>)
|
|
221
222
|
end
|
|
222
223
|
|
|
223
224
|
def render_accordion_title(node, attrs)
|
|
@@ -250,8 +251,9 @@ module MjmlRb
|
|
|
250
251
|
"border-bottom" => title_attrs["border"] || DEFAULTS["border"]
|
|
251
252
|
)
|
|
252
253
|
title_content = node ? raw_inner_for_body(node) : ""
|
|
253
|
-
title_cell = %(<td style="#{td_style}">#{title_content}</td>)
|
|
254
|
+
title_cell = %(<td#{html_attrs("class" => title_attrs["css-class"])} style="#{td_style}">#{title_content}</td>)
|
|
254
255
|
icon_cell = %(<td class="mj-accordion-ico" style="#{td2_style}"><img src="#{escape_attr(title_attrs["icon-wrapped-url"] || DEFAULTS["icon-wrapped-url"])}" alt="#{escape_attr(title_attrs["icon-wrapped-alt"] || DEFAULTS["icon-wrapped-alt"])}" class="mj-accordion-more" style="#{icon_style}" /><img src="#{escape_attr(title_attrs["icon-unwrapped-url"] || DEFAULTS["icon-unwrapped-url"])}" alt="#{escape_attr(title_attrs["icon-unwrapped-alt"] || DEFAULTS["icon-unwrapped-alt"])}" class="mj-accordion-less" style="#{icon_style}" /></td>)
|
|
256
|
+
icon_cell = "<!--[if !mso | IE]><!-->#{icon_cell}<!--<![endif]-->"
|
|
255
257
|
cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
|
|
256
258
|
|
|
257
259
|
%(<div class="mj-accordion-title"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr>#{cells}</tr></tbody></table></div>)
|
|
@@ -51,7 +51,7 @@ module MjmlRb
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def raw_inner_for_body(node)
|
|
54
|
-
renderer.send(:annotate_raw_html, raw_inner(node))
|
|
54
|
+
renderer.send(:protect_raw_html, renderer.send(:annotate_raw_html, raw_inner(node)))
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Like raw_inner but HTML-escapes text nodes. Use for components such as
|
|
@@ -7,6 +7,7 @@ module MjmlRb
|
|
|
7
7
|
|
|
8
8
|
ALLOWED_ATTRIBUTES = {
|
|
9
9
|
"width" => "unit(px)",
|
|
10
|
+
"id" => "string",
|
|
10
11
|
"background-color" => "color"
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
@@ -27,15 +28,17 @@ module MjmlRb
|
|
|
27
28
|
div_attributes = {
|
|
28
29
|
"aria-label" => context[:title].to_s.empty? ? nil : context[:title],
|
|
29
30
|
"aria-roledescription" => "email",
|
|
30
|
-
"class" => body_attrs["css-class"],
|
|
31
31
|
"role" => "article",
|
|
32
32
|
"lang" => context[:lang],
|
|
33
33
|
"dir" => context[:dir],
|
|
34
|
-
"style" => style_join("background-color" => background_color)
|
|
34
|
+
"style" => style_join("word-spacing" => "normal", "background-color" => background_color)
|
|
35
35
|
}
|
|
36
36
|
children = render_children(node, context, parent: "mj-body")
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
body_attributes = {"id" => body_attrs["id"], "class" => body_attrs["css-class"], "style" => div_attributes["style"]}
|
|
39
|
+
preview = context[:preview].to_s
|
|
40
|
+
preview_block = preview.empty? ? "" : %(<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">#{escape_html(preview)}</div>)
|
|
41
|
+
%(<body#{html_attrs(body_attributes)}>\n #{preview_block}\n <div#{html_attrs(div_attributes)}>#{children}</div>\n </body>)
|
|
39
42
|
end
|
|
40
43
|
end
|
|
41
44
|
end
|
|
@@ -9,7 +9,7 @@ module MjmlRb
|
|
|
9
9
|
|
|
10
10
|
ALLOWED_ATTRIBUTES = {
|
|
11
11
|
"align" => "enum(left,center,right)",
|
|
12
|
-
"border-radius" => "
|
|
12
|
+
"border-radius" => "string",
|
|
13
13
|
"container-background-color" => "color",
|
|
14
14
|
"icon-width" => "unit(px,%)",
|
|
15
15
|
"left-icon" => "string",
|
|
@@ -21,7 +21,7 @@ module MjmlRb
|
|
|
21
21
|
"right-icon" => "string",
|
|
22
22
|
"thumbnails" => "enum(visible,hidden,supported)",
|
|
23
23
|
"tb-border" => "string",
|
|
24
|
-
"tb-border-radius" => "
|
|
24
|
+
"tb-border-radius" => "string",
|
|
25
25
|
"tb-hover-border-color" => "color",
|
|
26
26
|
"tb-selected-border-color" => "color",
|
|
27
27
|
"tb-width" => "unit(px,%)"
|
|
@@ -13,9 +13,9 @@ module MjmlRb
|
|
|
13
13
|
"title" => "string",
|
|
14
14
|
"src" => "string",
|
|
15
15
|
"thumbnails-src" => "string",
|
|
16
|
-
"border-radius" => "
|
|
16
|
+
"border-radius" => "string",
|
|
17
17
|
"tb-border" => "string",
|
|
18
|
-
"tb-border-radius" => "
|
|
18
|
+
"tb-border-radius" => "string"
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
DEFAULT_ATTRIBUTES = {
|