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
@@ -64,6 +64,7 @@ module MjmlRb
64
64
  HTML
65
65
 
66
66
  def render(document, options = {})
67
+ @formatting = options[:minify] || options[:beautify]
67
68
  head = find_child(document, "mj-head")
68
69
  body = find_child(document, "mj-body")
69
70
  raise ArgumentError, "Missing <mj-body>" unless body
@@ -84,6 +85,8 @@ module MjmlRb
84
85
 
85
86
  def build_context(head, options)
86
87
  context = {
88
+ sanitize_styles: options[:sanitize_styles],
89
+ template_syntax: options[:template_syntax] || Options::DEFAULTS[:template_syntax],
87
90
  title: "",
88
91
  preview: "",
89
92
  breakpoint: "480px",
@@ -115,7 +118,6 @@ module MjmlRb
115
118
  content = minify_outlook_conditionals(content)
116
119
  content = apply_html_attributes_to_content(content, context)
117
120
  title = context[:title].to_s
118
- preview = context[:preview]
119
121
  head_raw = Array(context[:head_raw]).join("\n")
120
122
  before_doctype = context[:before_doctype].to_s
121
123
  font_tags = build_font_tags(content, context[:inline_styles], context[:fonts])
@@ -127,7 +129,6 @@ module MjmlRb
127
129
  )
128
130
  component_styles_tag = build_style_tag(unique_strings(context[:component_head_styles]))
129
131
  user_styles_tag = build_style_tag(unique_strings(context[:user_styles]))
130
- 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>)
131
132
  html_attributes = {
132
133
  "lang" => context[:lang],
133
134
  "dir" => context[:dir],
@@ -135,10 +136,6 @@ module MjmlRb
135
136
  "xmlns:v" => "urn:schemas-microsoft-com:vml",
136
137
  "xmlns:o" => "urn:schemas-microsoft-com:office:office"
137
138
  }
138
- body_style = style_join(
139
- "word-spacing" => "normal",
140
- "background-color" => context[:background_color]
141
- )
142
139
 
143
140
  html = <<~HTML
144
141
  <!doctype html>
@@ -159,10 +156,7 @@ module MjmlRb
159
156
  #{user_styles_tag}
160
157
  #{head_raw}
161
158
  </head>
162
- <body style="#{body_style}">
163
- #{preview_block}
164
- #{content}
165
- </body>
159
+ #{content}
166
160
  </html>
167
161
  HTML
168
162
 
@@ -170,7 +164,8 @@ module MjmlRb
170
164
  html = preserve_raw_tag_spacing(html)
171
165
  html = strip_internal_raw_markers(html)
172
166
  html = merge_outlook_conditionals(html)
173
- before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
167
+ html = before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
168
+ @formatting ? html : html.gsub(/<!--mjml-rb:raw-(?:start|end)-->/, "")
174
169
  end
175
170
 
176
171
  def build_font_tags(content, inline_styles, fonts)
@@ -225,7 +220,7 @@ module MjmlRb
225
220
  return [100] if total == 0
226
221
 
227
222
  widths = columns.map do |col|
228
- w = col["width"]
223
+ w = resolved_attributes(col, context)["width"]
229
224
  if w && w.to_s =~ /(\d+(?:\.\d+)?)\s*%/
230
225
  $1.to_f
231
226
  elsif w && w.to_s =~ /(\d+(?:\.\d+)?)\s*px/
@@ -236,16 +231,7 @@ module MjmlRb
236
231
  end
237
232
  end
238
233
 
239
- specified = widths.compact.sum
240
- unset_count = widths.count(&:nil?)
241
-
242
- if unset_count > 0
243
- remaining = [100.0 - specified, 0.0].max
244
- each_unset = remaining / unset_count
245
- widths.map { |w| w || each_unset }
246
- else
247
- widths
248
- end
234
+ widths.map { |width| width || 100.0 / total }
249
235
  end
250
236
 
251
237
  def build_media_queries_tags(breakpoint, column_widths, force_owa_desktop: false, printer_support: false)
@@ -253,13 +239,13 @@ module MjmlRb
253
239
  return "" if widths.empty?
254
240
 
255
241
  base_rules = widths.map do |class_name, width_str|
256
- ".#{class_name} { width:#{width_str} !important; max-width: #{width_str}; }"
242
+ ".#{class_name} { #{media_query_declarations(width_str)} }"
257
243
  end
258
244
  moz_rules = widths.map do |class_name, width_str|
259
- ".moz-text-html .#{class_name} { width:#{width_str} !important; max-width: #{width_str}; }"
245
+ ".moz-text-html .#{class_name} { #{media_query_declarations(width_str)} }"
260
246
  end
261
247
  owa_rules = widths.map do |class_name, width_str|
262
- "[owa] .#{class_name} { width:#{width_str} !important; max-width: #{width_str}; }"
248
+ "[owa] .#{class_name} { #{media_query_declarations(width_str)} }"
263
249
  end
264
250
 
265
251
  bp = breakpoint.to_s.strip
@@ -283,29 +269,36 @@ module MjmlRb
283
269
  parts.join("\n")
284
270
  end
285
271
 
272
+ def media_query_declarations(value)
273
+ return "padding: #{value[:padding]} !important;" if value.is_a?(Hash)
274
+ "width:#{value} !important; max-width: #{value};"
275
+ end
276
+
286
277
  def build_style_tag(styles)
287
278
  return "" if styles.empty?
288
279
 
289
280
  "<style type=\"text/css\">#{styles.join("\n")}</style>"
290
281
  end
291
282
 
283
+ def outside_raw_regions(html)
284
+ html.split(/(<!--mjml-rb:raw-start-->.*?<!--mjml-rb:raw-end-->)/m).map do |part|
285
+ part.start_with?("<!--mjml-rb:raw-start-->") ? part : yield(part)
286
+ end.join
287
+ end
288
+
292
289
  def merge_outlook_conditionals(html)
293
- # MJML post-processes the HTML to merge adjacent Outlook conditional comments.
294
- # e.g. <![endif]-->\n<!--[if mso | IE]> become a single conditional block.
295
- html.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
290
+ outside_raw_regions(html) do |part|
291
+ part.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
292
+ end
296
293
  end
297
294
 
298
295
  def minify_outlook_conditionals(html)
299
- html.gsub(/(<!--\[if\s[^\]]+\]>)([\s\S]*?)(<!\[endif\]-->)/m) do
300
- prefix = Regexp.last_match(1)
301
- content = Regexp.last_match(2)
302
- suffix = Regexp.last_match(3)
303
-
304
- processed = content
305
- .gsub(/(^|>)(\s+)(<|$)/m, '\1\3')
306
- .gsub(/\s{2,}/m, " ")
307
-
308
- "#{prefix}#{processed}#{suffix}"
296
+ outside_raw_regions(html) do |part|
297
+ part.gsub(/(<!--\[if\s[^\]]+\]>)([\s\S]*?)(<!\[endif\]-->)/m) do
298
+ prefix, content, suffix = Regexp.last_match.captures
299
+ processed = content.gsub(/(^|>)(\s+)(<|$)/m, '\\1\\3').gsub(/\s{2,}/m, " ")
300
+ "#{prefix}#{processed}#{suffix}"
301
+ end
309
302
  end
310
303
  end
311
304
 
@@ -313,7 +306,7 @@ module MjmlRb
313
306
  rules = context[:html_attributes] || {}
314
307
  return content if rules.empty?
315
308
 
316
- root = html_attributes_fragment_root(content, context)
309
+ root = Nokogiri::HTML("<html>#{content}</html>")
317
310
 
318
311
  rules.each do |selector, attrs|
319
312
  next if selector.empty? || attrs.empty?
@@ -325,7 +318,7 @@ module MjmlRb
325
318
  end
326
319
  end
327
320
 
328
- root.inner_html
321
+ root.at_css("body").to_html
329
322
  end
330
323
 
331
324
  def html_attributes_fragment_root(content, context)
@@ -341,6 +334,9 @@ module MjmlRb
341
334
  def apply_inline_styles(html, context)
342
335
  css_blocks = unique_strings(context[:inline_styles]).reject { |css| css.nil? || css.strip.empty? }
343
336
  return html if css_blocks.empty?
337
+ if context[:sanitize_styles] && css_blocks.any? { |css| context[:template_syntax].any? { |syntax| css.include?(syntax[:prefix] || syntax["prefix"]) } }
338
+ raise ArgumentError, "Template syntax in inline MJML CSS is unsupported; use non-inline mj-style or raw styles"
339
+ end
344
340
 
345
341
  document = parse_html_document(html)
346
342
  rules, = parse_inline_css_rules(css_blocks.join("\n"))
@@ -626,41 +622,13 @@ module MjmlRb
626
622
  end
627
623
 
628
624
  def component_registry
629
- @component_registry ||= begin
630
- registry = {}
631
- # Register component classes here as they are implemented.
632
- register_component(registry, Components::Body.new(self))
633
- register_component(registry, Components::Head.new(self))
634
- register_component(registry, Components::Attributes.new(self))
635
- register_component(registry, Components::Breakpoint.new(self))
636
- register_component(registry, Components::Accordion.new(self))
637
- register_component(registry, Components::Button.new(self))
638
- register_component(registry, Components::Carousel.new(self))
639
- register_component(registry, Components::CarouselImage.new(self))
640
- register_component(registry, Components::Group.new(self))
641
- register_component(registry, Components::Hero.new(self))
642
- register_component(registry, Components::Image.new(self))
643
- register_component(registry, Components::Navbar.new(self))
644
- register_component(registry, Components::Raw.new(self))
645
- register_component(registry, Components::Text.new(self))
646
- register_component(registry, Components::Divider.new(self))
647
- register_component(registry, Components::HtmlAttributes.new(self))
648
- register_component(registry, Components::Table.new(self))
649
- register_component(registry, Components::Social.new(self))
650
- register_component(registry, Components::Section.new(self))
651
- register_component(registry, Components::Column.new(self))
652
- register_component(registry, Components::Spacer.new(self))
653
-
654
- MjmlRb.component_registry.custom_components.each do |klass|
655
- register_component(registry, klass.new(self))
656
- end
657
-
658
- registry
625
+ registry = MjmlRb.component_registry
626
+ if @registry_revision != registry.revision
627
+ instances = {}
628
+ @component_registry = registry.components.transform_values { |klass| instances[klass] ||= klass.new(self) }
629
+ @registry_revision = registry.revision
659
630
  end
660
- end
661
-
662
- def register_component(registry, component)
663
- component.tags.each { |tag| registry[tag] = component }
631
+ @component_registry
664
632
  end
665
633
 
666
634
  def resolved_attributes(node, context)
@@ -708,6 +676,12 @@ module MjmlRb
708
676
  end.join
709
677
  end
710
678
 
679
+ def protect_raw_html(content)
680
+ return content if content.empty? || (!@formatting && !content.include?("<!--[if"))
681
+ # Use separate markers so source htmlmin:ignore regions can be nested.
682
+ "<!--mjml-rb:raw-start-->#{content}<!--mjml-rb:raw-end-->"
683
+ end
684
+
711
685
  def annotate_raw_html(content)
712
686
  return content if content.nil? || content.empty? || !content.include?("<")
713
687
  content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
@@ -763,7 +737,7 @@ module MjmlRb
763
737
  next unless child.tag_name == "mj-raw"
764
738
  next unless child["position"] == "file-start"
765
739
 
766
- raw_inner(child)
740
+ protect_raw_html(raw_inner(child))
767
741
  end.join("\n")
768
742
  end
769
743
 
@@ -778,7 +752,7 @@ module MjmlRb
778
752
  # Internal-only attributes set by the parser for metadata tracking.
779
753
  # These are excluded from the public attributes hash; data-mjml-raw
780
754
  # is intentionally kept because it is used by the rendering pipeline.
781
- INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-ending-tag].freeze
755
+ INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-line data-mjml-ending-tag].freeze
782
756
 
783
757
  def node_string_attributes(node)
784
758
  result = {}
@@ -1,11 +1,12 @@
1
1
  module MjmlRb
2
2
  class Result
3
- attr_reader :html, :errors, :warnings
3
+ attr_reader :html, :errors, :warnings, :dependencies
4
4
 
5
- def initialize(html: "", errors: [], warnings: [])
5
+ def initialize(html: "", errors: [], warnings: [], dependencies: [])
6
6
  @html = html.to_s
7
7
  @errors = Array(errors)
8
8
  @warnings = Array(warnings)
9
+ @dependencies = Array(dependencies).freeze
9
10
  end
10
11
 
11
12
  def success?
@@ -5,28 +5,31 @@ module MjmlRb
5
5
  class Validator
6
6
  GLOBAL_ALLOWED_ATTRIBUTES = %w[css-class mj-class].freeze
7
7
 
8
- REQUIRED_BY_TAG = {
9
- "mj-image" => %w[src],
10
- "mj-font" => %w[name href],
11
- "mj-include" => %w[path]
12
- }.freeze
13
-
14
8
  def initialize(parser: Parser.new)
15
9
  @parser = parser
16
10
  end
17
11
 
18
12
  def validate(mjml_or_ast, options = {})
19
- root = mjml_or_ast.is_a?(Nokogiri::XML::Node) ? mjml_or_ast : parse_ast(mjml_or_ast, options)
13
+ options = Options::DEFAULTS.merge(Options.normalize(options))
14
+ parsed = if mjml_or_ast.is_a?(Parser::ParseResult)
15
+ mjml_or_ast
16
+ elsif mjml_or_ast.is_a?(Nokogiri::XML::Node)
17
+ Parser::ParseResult.new(ast: mjml_or_ast, diagnostics: [])
18
+ else
19
+ @parser.parse_result(mjml_or_ast, options)
20
+ end
21
+ root = parsed.ast
20
22
  unless root&.tag_name == "mjml"
21
23
  return { errors: [error("Root element must be <mjml>", tag_name: root&.tag_name)], warnings: [] }
22
24
  end
23
25
 
24
- issues = []
26
+ issues = parsed.diagnostics.dup
27
+ return classify_issues(issues, options) if options[:validation_level].to_s == "skip"
25
28
  issues << error("Missing <mj-body>", tag_name: "mjml") unless root.element_children.any? { |c| c.tag_name == "mj-body" }
26
29
  walk(root, issues)
27
30
  classify_issues(issues, options)
28
31
  rescue Parser::ParseError => e
29
- { errors: [error(e.message, line: e.line)], warnings: [] }
32
+ { errors: [error(e.message, line: e.line, file: e.file)], warnings: [] }
30
33
  end
31
34
 
32
35
  private
@@ -42,23 +45,11 @@ module MjmlRb
42
45
  end
43
46
  end
44
47
 
45
- def parse_ast(mjml, options)
46
- @parser.parse(
47
- mjml,
48
- keep_comments: options.fetch(:keep_comments, false),
49
- preprocessors: options.fetch(:preprocessors, []),
50
- ignore_includes: options.fetch(:ignore_includes, false),
51
- file_path: options.fetch(:file_path, "."),
52
- actual_path: options.fetch(:actual_path, ".")
53
- )
54
- end
55
-
56
48
  def walk(node, errors)
57
49
  return unless node.element?
58
50
 
59
51
  validate_known_tag(node, errors)
60
52
  validate_allowed_children(node, errors)
61
- validate_required_attributes(node, errors)
62
53
  validate_supported_attributes(node, errors)
63
54
  validate_attribute_types(node, errors)
64
55
 
@@ -94,19 +85,9 @@ module MjmlRb
94
85
  end
95
86
  end
96
87
 
97
- def validate_required_attributes(node, errors)
98
- required = REQUIRED_BY_TAG[node.tag_name] || []
99
- required.each do |attr|
100
- next if node.has_attribute?(attr)
101
-
102
- errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>",
103
- tag_name: node.tag_name, line: node.line, file: node.file)
104
- end
105
- end
106
-
107
88
  def validate_supported_attributes(node, errors)
108
89
  allowed_attributes = allowed_attributes_for(node.tag_name)
109
- return if allowed_attributes.empty?
90
+ return if node.tag_name == "mjml" || node.parent&.name == "mj-attributes"
110
91
 
111
92
  string_attrs(node).each_key do |attribute_name|
112
93
  next if allowed_attributes.key?(attribute_name)
@@ -218,7 +199,7 @@ module MjmlRb
218
199
  end
219
200
  end
220
201
 
221
- INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-ending-tag data-mjml-raw].freeze
202
+ INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-line data-mjml-ending-tag data-mjml-raw].freeze
222
203
 
223
204
  def string_attrs(node)
224
205
  result = {}
@@ -1,3 +1,4 @@
1
1
  module MjmlRb
2
- VERSION = "0.5.2".freeze
2
+ MJML_COMPATIBILITY_VERSION = "5.4.1".freeze
3
+ VERSION = "0.6.0".freeze
3
4
  end
data/lib/mjml-rb.rb CHANGED
@@ -16,8 +16,8 @@ module MjmlRb
16
16
  warn <<~WARNING unless ENV["MJML_RB_NO_WARN"] == "1"
17
17
  WARNING: mjml-rb #{VERSION} is EXPERIMENTAL software.
18
18
  It is an unofficial Ruby port of MJML, not affiliated with or endorsed by
19
- the MJML team. Output may differ from the reference npm renderer. Not all
20
- components are fully implemented. Use in production at your own risk.
19
+ the MJML team. It targets MJML #{MJML_COMPATIBILITY_VERSION}; parser, formatter
20
+ and CSS inliner behavior may differ. Use in production at your own risk.
21
21
  Set MJML_RB_NO_WARN=1 to suppress this message.
22
22
  WARNING
23
23
 
data/mjml-rb.gemspec CHANGED
@@ -13,11 +13,11 @@ 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", "Rakefile", "mjml-rb.gemspec", "README.md"]
16
+ Dir.glob("{bin,lib}/**/*") + Dir.glob("docs/**/*.md") + ["Gemfile", "LICENSE", "THIRD_PARTY_LICENSES.md", "Rakefile", "mjml-rb.gemspec", "README.md", "CHANGELOG.md"]
17
17
  end
18
18
  spec.bindir = "bin"
19
19
  spec.executables = ["mjml"]
20
20
  spec.require_paths = ["lib"]
21
- spec.add_dependency "css_parser", ">= 1.17"
21
+ spec.add_dependency "css_parser", ">= 1.17", "< 4"
22
22
  spec.add_dependency "nokogiri", ">= 1.13"
23
23
  end
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.5.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '1.17'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '1.17'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: nokogiri
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -45,13 +51,23 @@ executables:
45
51
  extensions: []
46
52
  extra_rdoc_files: []
47
53
  files:
54
+ - CHANGELOG.md
48
55
  - Gemfile
49
56
  - LICENSE
50
57
  - README.md
51
58
  - Rakefile
59
+ - THIRD_PARTY_LICENSES.md
52
60
  - bin/mjml
61
+ - docs/ARCHITECTURE.md
62
+ - docs/MIGRATING_TO_V5.md
63
+ - docs/MJML_V5_SUPPORT_PLAN.md
64
+ - docs/PARITY_AUDIT.md
65
+ - docs/RELEASING.md
66
+ - docs/USAGE.md
67
+ - docs/releases/v0.6.0.md
53
68
  - lib/mjml-rb.rb
54
69
  - lib/mjml-rb/cli.rb
70
+ - lib/mjml-rb/column_gutter.rb
55
71
  - lib/mjml-rb/compiler.rb
56
72
  - lib/mjml-rb/component_registry.rb
57
73
  - lib/mjml-rb/components/accordion.rb
@@ -79,7 +95,9 @@ files:
79
95
  - lib/mjml-rb/components/text.rb
80
96
  - lib/mjml-rb/config_file.rb
81
97
  - lib/mjml-rb/dependencies.rb
98
+ - lib/mjml-rb/formatter.rb
82
99
  - lib/mjml-rb/node_compat.rb
100
+ - lib/mjml-rb/options.rb
83
101
  - lib/mjml-rb/parser.rb
84
102
  - lib/mjml-rb/railtie.rb
85
103
  - lib/mjml-rb/renderer.rb