mjml-rb 0.5.1 → 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/attributes.rb +5 -5
- data/lib/mjml-rb/components/base.rb +5 -1
- data/lib/mjml-rb/components/body.rb +6 -3
- data/lib/mjml-rb/components/breakpoint.rb +1 -1
- 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/head.rb +3 -3
- data/lib/mjml-rb/components/hero.rb +7 -2
- data/lib/mjml-rb/components/html_attributes.rb +3 -3
- 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 +19 -28
- data/lib/mjml-rb/components/table.rb +2 -2
- 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 +19 -0
- data/lib/mjml-rb/options.rb +81 -0
- data/lib/mjml-rb/parser.rb +158 -131
- data/lib/mjml-rb/renderer.rb +84 -100
- data/lib/mjml-rb/result.rb +3 -2
- data/lib/mjml-rb/validator.rb +26 -34
- data/lib/mjml-rb/version.rb +2 -1
- data/lib/mjml-rb.rb +3 -3
- data/mjml-rb.gemspec +2 -2
- metadata +20 -2
- data/lib/mjml-rb/ast_node.rb +0 -37
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -64,15 +64,16 @@ 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
|
|
70
71
|
|
|
71
72
|
context = build_context(head, options)
|
|
72
73
|
context[:before_doctype] = root_file_start_raw(document)
|
|
73
|
-
context[:lang] = options[:lang] || document
|
|
74
|
-
context[:dir] = options[:dir] || document
|
|
75
|
-
context[:force_owa_desktop] = document
|
|
74
|
+
context[:lang] = options[:lang] || document["lang"] || "und"
|
|
75
|
+
context[:dir] = options[:dir] || document["dir"] || "auto"
|
|
76
|
+
context[:force_owa_desktop] = document["owa"] == "desktop"
|
|
76
77
|
context[:printer_support] = options[:printer_support] || options[:printerSupport]
|
|
77
78
|
context[:column_widths] = {}
|
|
78
79
|
append_component_head_styles(document, context)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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} {
|
|
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} {
|
|
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} {
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -668,7 +636,7 @@ module MjmlRb
|
|
|
668
636
|
attrs.merge!(context[:global_defaults] || {})
|
|
669
637
|
attrs.merge!(context[:tag_defaults][node.tag_name] || {})
|
|
670
638
|
|
|
671
|
-
node_classes = node
|
|
639
|
+
node_classes = node["mj-class"].to_s.split(/\s+/).reject(&:empty?)
|
|
672
640
|
class_attrs = node_classes.each_with_object({}) do |klass, memo|
|
|
673
641
|
mj_class_attrs = (context[:classes] || {})[klass] || {}
|
|
674
642
|
if memo["css-class"] && mj_class_attrs["css-class"]
|
|
@@ -683,33 +651,35 @@ module MjmlRb
|
|
|
683
651
|
attrs.merge!(((context[:classes_default] || {})[klass] || {})[node.tag_name] || {})
|
|
684
652
|
end
|
|
685
653
|
|
|
686
|
-
attrs.merge!(node
|
|
654
|
+
attrs.merge!(node_string_attributes(node))
|
|
687
655
|
attrs
|
|
688
656
|
end
|
|
689
657
|
|
|
690
658
|
def html_inner(node)
|
|
691
|
-
|
|
692
|
-
node.inner_html
|
|
693
|
-
else
|
|
694
|
-
escape_html(node.text_content)
|
|
695
|
-
end
|
|
659
|
+
node.inner_html
|
|
696
660
|
end
|
|
697
661
|
|
|
698
662
|
def raw_inner(node)
|
|
699
|
-
# For ending-tag nodes whose content was preserved as raw HTML by the parser
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if node.
|
|
703
|
-
node.children.
|
|
704
|
-
if child.text?
|
|
705
|
-
child.content.to_s
|
|
706
|
-
else
|
|
707
|
-
child.to_html
|
|
708
|
-
end
|
|
709
|
-
end.join
|
|
710
|
-
else
|
|
711
|
-
node.text_content
|
|
663
|
+
# For ending-tag nodes whose content was preserved as raw HTML by the parser.
|
|
664
|
+
# The parser marks these with data-mjml-ending-tag; their children are CDATA
|
|
665
|
+
# nodes containing the raw HTML content.
|
|
666
|
+
if node.element? && node["data-mjml-ending-tag"]
|
|
667
|
+
return node.children.select { |c| c.cdata? || c.text? }.map(&:content).join
|
|
712
668
|
end
|
|
669
|
+
|
|
670
|
+
node.children.map do |child|
|
|
671
|
+
if child.text?
|
|
672
|
+
child.content.to_s
|
|
673
|
+
else
|
|
674
|
+
child.to_html
|
|
675
|
+
end
|
|
676
|
+
end.join
|
|
677
|
+
end
|
|
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-->"
|
|
713
683
|
end
|
|
714
684
|
|
|
715
685
|
def annotate_raw_html(content)
|
|
@@ -755,7 +725,7 @@ module MjmlRb
|
|
|
755
725
|
|
|
756
726
|
def with_inherited_mj_class(context, node)
|
|
757
727
|
previous = context[:inherited_mj_class]
|
|
758
|
-
current = node
|
|
728
|
+
current = node["mj-class"]
|
|
759
729
|
context[:inherited_mj_class] = (current && !current.empty?) ? current : previous
|
|
760
730
|
yield
|
|
761
731
|
ensure
|
|
@@ -765,9 +735,9 @@ module MjmlRb
|
|
|
765
735
|
def root_file_start_raw(document)
|
|
766
736
|
document.element_children.filter_map do |child|
|
|
767
737
|
next unless child.tag_name == "mj-raw"
|
|
768
|
-
next unless child
|
|
738
|
+
next unless child["position"] == "file-start"
|
|
769
739
|
|
|
770
|
-
raw_inner(child)
|
|
740
|
+
protect_raw_html(raw_inner(child))
|
|
771
741
|
end.join("\n")
|
|
772
742
|
end
|
|
773
743
|
|
|
@@ -779,6 +749,20 @@ module MjmlRb
|
|
|
779
749
|
result
|
|
780
750
|
end
|
|
781
751
|
|
|
752
|
+
# Internal-only attributes set by the parser for metadata tracking.
|
|
753
|
+
# These are excluded from the public attributes hash; data-mjml-raw
|
|
754
|
+
# is intentionally kept because it is used by the rendering pipeline.
|
|
755
|
+
INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-line data-mjml-ending-tag].freeze
|
|
756
|
+
|
|
757
|
+
def node_string_attributes(node)
|
|
758
|
+
result = {}
|
|
759
|
+
node.attributes.each do |name, attr|
|
|
760
|
+
next if INTERNAL_ATTRIBUTES.include?(name)
|
|
761
|
+
result[name] = attr.respond_to?(:value) ? attr.value : attr.to_s
|
|
762
|
+
end
|
|
763
|
+
result
|
|
764
|
+
end
|
|
765
|
+
|
|
782
766
|
def escape_html(value)
|
|
783
767
|
CGI.escapeHTML(value.to_s)
|
|
784
768
|
end
|
data/lib/mjml-rb/result.rb
CHANGED
|
@@ -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?
|
data/lib/mjml-rb/validator.rb
CHANGED
|
@@ -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
|
-
|
|
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,21 +85,11 @@ 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.attributes.key?(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
|
|
90
|
+
return if node.tag_name == "mjml" || node.parent&.name == "mj-attributes"
|
|
110
91
|
|
|
111
|
-
node.
|
|
92
|
+
string_attrs(node).each_key do |attribute_name|
|
|
112
93
|
next if allowed_attributes.key?(attribute_name)
|
|
113
94
|
next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
|
|
114
95
|
|
|
@@ -121,7 +102,7 @@ module MjmlRb
|
|
|
121
102
|
allowed_attributes = allowed_attributes_for(node.tag_name)
|
|
122
103
|
return if allowed_attributes.empty?
|
|
123
104
|
|
|
124
|
-
node.
|
|
105
|
+
string_attrs(node).each do |attribute_name, attribute_value|
|
|
125
106
|
next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
|
|
126
107
|
|
|
127
108
|
expected_type = allowed_attributes[attribute_name]
|
|
@@ -218,6 +199,17 @@ module MjmlRb
|
|
|
218
199
|
end
|
|
219
200
|
end
|
|
220
201
|
|
|
202
|
+
INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-line data-mjml-ending-tag data-mjml-raw].freeze
|
|
203
|
+
|
|
204
|
+
def string_attrs(node)
|
|
205
|
+
result = {}
|
|
206
|
+
node.attributes.each do |name, attr|
|
|
207
|
+
next if INTERNAL_ATTRIBUTES.include?(name)
|
|
208
|
+
result[name] = attr.respond_to?(:value) ? attr.value : attr.to_s
|
|
209
|
+
end
|
|
210
|
+
result
|
|
211
|
+
end
|
|
212
|
+
|
|
221
213
|
def error(message, line: nil, tag_name: nil, file: nil)
|
|
222
214
|
location = [
|
|
223
215
|
("line #{line}" if line),
|
data/lib/mjml-rb/version.rb
CHANGED
data/lib/mjml-rb.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
require_relative "mjml-rb/version"
|
|
2
2
|
require_relative "mjml-rb/result"
|
|
3
|
-
require_relative "mjml-rb/
|
|
3
|
+
require_relative "mjml-rb/node_compat"
|
|
4
4
|
require_relative "mjml-rb/dependencies"
|
|
5
5
|
require_relative "mjml-rb/component_registry"
|
|
6
6
|
require_relative "mjml-rb/config_file"
|
|
@@ -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.
|
|
20
|
-
|
|
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.
|
|
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,14 +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
|
-
- lib/mjml-rb/ast_node.rb
|
|
55
69
|
- lib/mjml-rb/cli.rb
|
|
70
|
+
- lib/mjml-rb/column_gutter.rb
|
|
56
71
|
- lib/mjml-rb/compiler.rb
|
|
57
72
|
- lib/mjml-rb/component_registry.rb
|
|
58
73
|
- lib/mjml-rb/components/accordion.rb
|
|
@@ -80,6 +95,9 @@ files:
|
|
|
80
95
|
- lib/mjml-rb/components/text.rb
|
|
81
96
|
- lib/mjml-rb/config_file.rb
|
|
82
97
|
- lib/mjml-rb/dependencies.rb
|
|
98
|
+
- lib/mjml-rb/formatter.rb
|
|
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
|
data/lib/mjml-rb/ast_node.rb
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
module MjmlRb
|
|
2
|
-
class AstNode
|
|
3
|
-
attr_reader :tag_name, :attributes, :children, :content, :line, :file
|
|
4
|
-
|
|
5
|
-
def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil, file: nil)
|
|
6
|
-
@tag_name = tag_name.to_s
|
|
7
|
-
@attributes = attributes.transform_keys(&:to_s)
|
|
8
|
-
@children = Array(children)
|
|
9
|
-
@content = content
|
|
10
|
-
@line = line
|
|
11
|
-
@file = file
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def text?
|
|
15
|
-
@tag_name == "#text"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def comment?
|
|
19
|
-
@tag_name == "#comment"
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def element?
|
|
23
|
-
!text? && !comment?
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def text_content
|
|
27
|
-
return @content.to_s if text?
|
|
28
|
-
result = +""
|
|
29
|
-
@children.each { |child| result << child.text_content }
|
|
30
|
-
result
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def element_children
|
|
34
|
-
@element_children ||= @children.select(&:element?)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|