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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +60 -22
  4. data/THIRD_PARTY_LICENSES.md +25 -0
  5. data/docs/ARCHITECTURE.md +363 -0
  6. data/docs/MIGRATING_TO_V5.md +112 -0
  7. data/docs/MJML_V5_SUPPORT_PLAN.md +325 -0
  8. data/docs/PARITY_AUDIT.md +124 -0
  9. data/docs/RELEASING.md +35 -0
  10. data/docs/USAGE.md +1258 -0
  11. data/docs/releases/v0.6.0.md +63 -0
  12. data/lib/mjml-rb/cli.rb +43 -31
  13. data/lib/mjml-rb/column_gutter.rb +81 -0
  14. data/lib/mjml-rb/compiler.rb +32 -122
  15. data/lib/mjml-rb/component_registry.rb +11 -1
  16. data/lib/mjml-rb/components/accordion.rb +11 -9
  17. data/lib/mjml-rb/components/base.rb +1 -1
  18. data/lib/mjml-rb/components/body.rb +6 -3
  19. data/lib/mjml-rb/components/carousel.rb +2 -2
  20. data/lib/mjml-rb/components/carousel_image.rb +2 -2
  21. data/lib/mjml-rb/components/column.rb +16 -11
  22. data/lib/mjml-rb/components/group.rb +13 -4
  23. data/lib/mjml-rb/components/hero.rb +7 -2
  24. data/lib/mjml-rb/components/image.rb +1 -3
  25. data/lib/mjml-rb/components/raw.rb +1 -1
  26. data/lib/mjml-rb/components/section.rb +23 -8
  27. data/lib/mjml-rb/components/social.rb +18 -27
  28. data/lib/mjml-rb/components/table.rb +1 -1
  29. data/lib/mjml-rb/config_file.rb +25 -15
  30. data/lib/mjml-rb/dependencies.rb +1 -0
  31. data/lib/mjml-rb/formatter.rb +113 -0
  32. data/lib/mjml-rb/node_compat.rb +4 -0
  33. data/lib/mjml-rb/options.rb +81 -0
  34. data/lib/mjml-rb/parser.rb +138 -94
  35. data/lib/mjml-rb/renderer.rb +50 -76
  36. data/lib/mjml-rb/result.rb +3 -2
  37. data/lib/mjml-rb/validator.rb +14 -33
  38. data/lib/mjml-rb/version.rb +2 -1
  39. data/lib/mjml-rb.rb +2 -2
  40. data/mjml-rb.gemspec +2 -2
  41. 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
- rc_config = ConfigFile.load
28
- if rc_config[:options]
29
- options[:config] = rc_config[:options].merge(options[:config])
30
- end
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][normalize_key(key)] = parse_typed_value(value)
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][normalize_key(key)] = value
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.compile(input[:mjml]) }
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 = files.each_with_object({}) { |path, memo| memo[path] = file_mtime(path) }
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 = file_mtime(file)
296
- next if current.nil? || current <= state[file]
297
-
309
+ current = dependency_state(dependencies[file])
310
+ next if current == state[file]
298
311
  state[file] = current
299
312
  begin
300
- input = { file: file, mjml: File.read(file) }
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 file_mtime(path)
313
- File.mtime(path)
314
- rescue StandardError
315
- nil
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
@@ -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
- beautify: false,
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
- normalized = symbolize_keys(options)
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
- normalized = symbolize_keys(options)
42
- validate_options!(normalized)
43
- merged = @options.merge(normalized)
24
+ do_compile(mjml, effective_options(options))
25
+ end
44
26
 
45
- do_compile(mjml, merged)
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 do_compile(mjml, merged)
51
- ast = @parser.parse(
52
- mjml,
53
- keep_comments: merged[:keep_comments],
54
- preprocessors: Array(merged[:preprocessors]),
55
- ignore_includes: merged[:ignore_includes],
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
- return Result.new(errors: all_errors, warnings: all_warnings) if strict_validation_failed?(merged, all_errors)
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 = @renderer.render(ast, merged)
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 post_process(html, options)
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
- input.mj-accordion-checkbox, .mj-accordion-less { display:none!important; }
95
- input.mj-accordion-checkbox + * .mj-accordion-title { cursor:pointer; touch-action:manipulation; -webkit-user-select:none; -moz-user-select:none; user-select:none; }
96
- input.mj-accordion-checkbox + * .mj-accordion-content { overflow:hidden; display:none; }
97
- input.mj-accordion-checkbox + * .mj-accordion-more { display:block!important; }
98
- input.mj-accordion-checkbox:checked + * .mj-accordion-content { display:block; }
99
- input.mj-accordion-checkbox:checked + * .mj-accordion-more { display:none!important; }
100
- input.mj-accordion-checkbox:checked + * .mj-accordion-less { display:block!important; }
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}"><input class="mj-accordion-checkbox" type="checkbox" style="display:none;" /><div>#{content.join("\n")}</div></label></td></tr>)
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
- %(<div#{html_attrs(div_attributes)}>#{children}</div>)
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" => "unit(px,%){1,4}",
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" => "unit(px,%)",
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" => "unit(px,%){1,4}",
16
+ "border-radius" => "string",
17
17
  "tb-border" => "string",
18
- "tb-border-radius" => "unit(px,%){1,4}"
18
+ "tb-border-radius" => "string"
19
19
  }.freeze
20
20
 
21
21
  DEFAULT_ATTRIBUTES = {