mjml-rb 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ require "cgi"
2
+ require "nokogiri"
3
+ require_relative "components/accordion"
4
+ require_relative "components/body"
5
+ require_relative "components/breakpoint"
6
+ require_relative "components/button"
7
+ require_relative "components/hero"
8
+ require_relative "components/image"
9
+ require_relative "components/navbar"
10
+ require_relative "components/text"
11
+ require_relative "components/divider"
12
+ require_relative "components/html_attributes"
13
+ require_relative "components/table"
14
+ require_relative "components/social"
15
+ require_relative "components/section"
16
+ require_relative "components/column"
17
+ require_relative "components/spacer"
18
+
19
+ module MjmlRb
20
+ class Renderer
21
+ DEFAULT_FONTS = {
22
+ "Roboto" => "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700"
23
+ }.freeze
24
+
25
+ DOCUMENT_RESET_CSS = <<~CSS.freeze
26
+ #outlook a { padding:0; }
27
+ body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
28
+ table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
29
+ img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
30
+ p { display:block;margin:13px 0; }
31
+ CSS
32
+
33
+ def render(document, options = {})
34
+ head = find_child(document, "mj-head")
35
+ body = find_child(document, "mj-body")
36
+ raise ArgumentError, "Missing <mj-body>" unless body
37
+
38
+ context = build_context(head, options)
39
+ context[:lang] = options[:lang] || document.attributes["lang"] || "und"
40
+ context[:dir] = options[:dir] || document.attributes["dir"] || "auto"
41
+ context[:column_widths] = {}
42
+ append_component_head_styles(document, context)
43
+ content = render_node(body, context, parent: "mjml")
44
+ append_column_width_styles(context)
45
+ build_html_document(content, context)
46
+ end
47
+
48
+ private
49
+
50
+ def build_context(head, options)
51
+ context = {
52
+ title: "",
53
+ preview: "",
54
+ breakpoint: "480px",
55
+ head_styles: [],
56
+ inline_styles: [],
57
+ body_styles: [],
58
+ html_attributes: {},
59
+ fonts: DEFAULT_FONTS.merge(hash_or_empty(options[:fonts])),
60
+ global_defaults: {},
61
+ tag_defaults: {},
62
+ classes: {}
63
+ }
64
+
65
+ return context unless head
66
+
67
+ head.element_children.each do |node|
68
+ case node.tag_name
69
+ when "mj-title"
70
+ context[:title] = node.text_content.strip
71
+ when "mj-preview"
72
+ context[:preview] = node.text_content.strip
73
+ when "mj-style"
74
+ context[:head_styles] << node.text_content
75
+ context[:inline_styles] << node.text_content if node.attributes["inline"] == "inline"
76
+ when "mj-font"
77
+ name = node.attributes["name"]
78
+ href = node.attributes["href"]
79
+ context[:fonts][name] = href if name && href
80
+ when "mj-breakpoint"
81
+ width = node.attributes["width"].to_s.strip
82
+ context[:breakpoint] = width unless width.empty?
83
+ when "mj-attributes"
84
+ absorb_attribute_node(node, context)
85
+ when "mj-html-attributes"
86
+ absorb_html_attributes_node(node, context)
87
+ when "mj-raw"
88
+ context[:body_styles] << raw_inner(node)
89
+ end
90
+ end
91
+
92
+ context
93
+ end
94
+
95
+ def absorb_attribute_node(attributes_node, context)
96
+ attributes_node.element_children.each do |child|
97
+ case child.tag_name
98
+ when "mj-all"
99
+ context[:global_defaults].merge!(child.attributes)
100
+ when "mj-class"
101
+ name = child.attributes["name"]
102
+ next unless name
103
+
104
+ context[:classes][name] = child.attributes.reject { |k, _| k == "name" }
105
+ else
106
+ context[:tag_defaults][child.tag_name] ||= {}
107
+ context[:tag_defaults][child.tag_name].merge!(child.attributes)
108
+ end
109
+ end
110
+ end
111
+
112
+ def absorb_html_attributes_node(html_attributes_node, context)
113
+ html_attributes_node.element_children.each do |selector|
114
+ next unless selector.tag_name == "mj-selector"
115
+
116
+ path = selector.attributes["path"].to_s.strip
117
+ next if path.empty?
118
+
119
+ custom_attrs = selector.element_children.each_with_object({}) do |child, memo|
120
+ next unless child.tag_name == "mj-html-attribute"
121
+
122
+ name = child.attributes["name"].to_s.strip
123
+ next if name.empty?
124
+
125
+ memo[name] = child.text_content
126
+ end
127
+ next if custom_attrs.empty?
128
+
129
+ context[:html_attributes][path] ||= {}
130
+ context[:html_attributes][path].merge!(custom_attrs)
131
+ end
132
+ end
133
+
134
+ def build_html_document(content, context)
135
+ title = context[:title].to_s
136
+ preview = context[:preview]
137
+ head_styles = ([DOCUMENT_RESET_CSS] + unique_strings(context[:head_styles])).join("\n")
138
+ font_links = context[:fonts].values.uniq.map { |href| %(<link href="#{escape_attr(href)}" rel="stylesheet" type="text/css">) }.join("\n")
139
+ preview_block = preview.empty? ? "" : %(<div style="display:none;max-height:0;overflow:hidden;opacity:0;">#{escape_html(preview)}</div>)
140
+ html_attributes = { "lang" => context[:lang], "dir" => context[:dir] }
141
+ body_style = style_join(
142
+ "margin" => "0",
143
+ "padding" => "0",
144
+ "background" => context[:background_color] || "#ffffff"
145
+ )
146
+
147
+ html = <<~HTML
148
+ <!doctype html>
149
+ <html#{html_attrs(html_attributes)}>
150
+ <head>
151
+ <meta charset="utf-8">
152
+ <meta name="viewport" content="width=device-width, initial-scale=1">
153
+ <title>#{escape_html(title)}</title>
154
+ #{font_links}
155
+ <style type="text/css">#{head_styles}</style>
156
+ </head>
157
+ <body style="#{body_style}">
158
+ #{preview_block}
159
+ #{content}
160
+ </body>
161
+ </html>
162
+ HTML
163
+
164
+ html = apply_html_attributes(html, context)
165
+ apply_inline_styles(html, context)
166
+ end
167
+
168
+ def render_children(node, context, parent:)
169
+ node.children.map { |child| render_node(child, context, parent: parent) }.join("\n")
170
+ end
171
+
172
+ def render_node(node, context, parent:)
173
+ return escape_html(node.content.to_s) if node.text?
174
+ return "" if node.comment?
175
+
176
+ attrs = resolved_attributes(node, context)
177
+ if (component = component_for(node.tag_name))
178
+ return component.render(tag_name: node.tag_name, node: node, context: context, attrs: attrs, parent: parent)
179
+ end
180
+
181
+ case node.tag_name
182
+ when "mj-group"
183
+ render_group(node, context)
184
+ when "mj-raw"
185
+ raw_inner(node)
186
+ else
187
+ render_children(node, context, parent: node.tag_name)
188
+ end
189
+ end
190
+
191
+ def render_group(node, context, width_pct = 100)
192
+ items = node.element_children.select { |e| e.tag_name == "mj-column" }
193
+ widths = compute_column_widths(items, context)
194
+ items.each_with_index.map do |item, i|
195
+ context[:_column_width_pct] = widths[i]
196
+ render_node(item, context, parent: "mj-group")
197
+ end.join("\n")
198
+ end
199
+
200
+ def compute_column_widths(columns, context)
201
+ total = columns.size
202
+ return [100] if total == 0
203
+
204
+ widths = columns.map do |col|
205
+ w = col.attributes["width"]
206
+ if w && w.to_s =~ /(\d+(?:\.\d+)?)\s*%/
207
+ $1.to_f
208
+ elsif w && w.to_s =~ /(\d+(?:\.\d+)?)\s*px/
209
+ container = (context[:container_width] || "600px").to_f
210
+ container > 0 ? ($1.to_f / container * 100) : nil
211
+ else
212
+ nil
213
+ end
214
+ end
215
+
216
+ specified = widths.compact.sum
217
+ unset_count = widths.count(&:nil?)
218
+
219
+ if unset_count > 0
220
+ remaining = [100.0 - specified, 0.0].max
221
+ each_unset = remaining / unset_count
222
+ widths.map { |w| w || each_unset }
223
+ else
224
+ widths
225
+ end
226
+ end
227
+
228
+ def append_column_width_styles(context)
229
+ widths = context[:column_widths] || {}
230
+ return if widths.empty?
231
+
232
+ css = widths.map do |suffix, pct|
233
+ ".mj-column-per-#{suffix} { width:#{pct}% !important; max-width: #{pct}%; }"
234
+ end.join("\n")
235
+ breakpoint = context[:breakpoint].to_s.strip
236
+ if breakpoint.empty?
237
+ context[:head_styles] << css
238
+ else
239
+ context[:head_styles] << "@media only screen and (min-width:#{breakpoint}) {\n#{css}\n}"
240
+ end
241
+ end
242
+
243
+ def merge_outlook_conditionals(html)
244
+ # MJML post-processes the HTML to merge adjacent Outlook conditional comments.
245
+ # e.g. <![endif]-->\n<!--[if mso | IE]> become a single conditional block.
246
+ html.gsub(/<!\[endif\]-->\s*<!--\[if mso \| IE\]>/m, "")
247
+ end
248
+
249
+ def apply_html_attributes(html, context)
250
+ rules = context[:html_attributes] || {}
251
+ return html if rules.empty?
252
+
253
+ document = Nokogiri::HTML(html)
254
+
255
+ rules.each do |selector, attrs|
256
+ next if selector.empty? || attrs.empty?
257
+
258
+ begin
259
+ document.css(selector).each do |node|
260
+ attrs.each do |name, value|
261
+ node[name] = value.to_s
262
+ end
263
+ end
264
+ rescue Nokogiri::CSS::SyntaxError
265
+ next
266
+ end
267
+ end
268
+
269
+ document.to_html
270
+ end
271
+
272
+ def apply_inline_styles(html, context)
273
+ css_blocks = unique_strings(context[:inline_styles]).reject { |css| css.nil? || css.strip.empty? }
274
+ return html if css_blocks.empty?
275
+
276
+ document = Nokogiri::HTML(html)
277
+ parse_inline_css_rules(css_blocks.join("\n")).each do |selector, declarations|
278
+ next if selector.empty? || declarations.empty?
279
+
280
+ begin
281
+ document.css(selector).each do |node|
282
+ merge_inline_style!(node, declarations)
283
+ end
284
+ rescue Nokogiri::CSS::SyntaxError
285
+ next
286
+ end
287
+ end
288
+
289
+ document.to_html
290
+ end
291
+
292
+ def parse_inline_css_rules(css)
293
+ stripped_css = strip_css_comments(css.to_s)
294
+ plain_css = strip_css_at_rules(stripped_css)
295
+
296
+ plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
297
+ selectors = selector_group.split(",").map(&:strip).reject(&:empty?)
298
+ declaration_map = parse_css_declarations(declarations)
299
+ selectors.map { |selector| [selector, declaration_map] }
300
+ end
301
+ end
302
+
303
+ def strip_css_comments(css)
304
+ css.gsub(%r{/\*.*?\*/}m, "")
305
+ end
306
+
307
+ def strip_css_at_rules(css)
308
+ result = +""
309
+ index = 0
310
+
311
+ while index < css.length
312
+ if css[index] == "@"
313
+ brace_index = css.index("{", index)
314
+ semicolon_index = css.index(";", index)
315
+
316
+ if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
317
+ index = semicolon_index + 1
318
+ next
319
+ end
320
+
321
+ if brace_index
322
+ depth = 1
323
+ cursor = brace_index + 1
324
+ while cursor < css.length && depth.positive?
325
+ depth += 1 if css[cursor] == "{"
326
+ depth -= 1 if css[cursor] == "}"
327
+ cursor += 1
328
+ end
329
+ index = cursor
330
+ next
331
+ end
332
+ end
333
+
334
+ result << css[index]
335
+ index += 1
336
+ end
337
+
338
+ result
339
+ end
340
+
341
+ def parse_css_declarations(declarations)
342
+ declarations.split(";").each_with_object({}) do |entry, memo|
343
+ property, value = entry.split(":", 2).map { |part| part&.strip }
344
+ next if property.nil? || property.empty? || value.nil? || value.empty?
345
+
346
+ memo[property] = value.sub(/\s*!important\s*\z/, "").strip
347
+ end
348
+ end
349
+
350
+ def merge_inline_style!(node, declarations)
351
+ existing = parse_css_declarations(node["style"].to_s)
352
+ declarations.each do |property, value|
353
+ existing[property] = value
354
+ end
355
+ node["style"] = existing.map { |property, value| "#{property}: #{value}" }.join("; ")
356
+ end
357
+
358
+ def append_component_head_styles(document, context)
359
+ component_registry.each_value.uniq.each do |component|
360
+ next unless component.respond_to?(:head_style)
361
+
362
+ style = if component.method(:head_style).arity == 1
363
+ component.head_style(context[:breakpoint])
364
+ else
365
+ component.head_style
366
+ end
367
+ next if style.nil? || style.empty?
368
+
369
+ tags = if component.respond_to?(:head_style_tags)
370
+ component.head_style_tags
371
+ else
372
+ component.tags
373
+ end
374
+ next unless Array(tags).any? { |tag| contains_tag?(document, tag) }
375
+
376
+ context[:head_styles] << style
377
+ end
378
+ end
379
+
380
+ def component_for(tag_name)
381
+ component_registry[tag_name]
382
+ end
383
+
384
+ def component_registry
385
+ @component_registry ||= begin
386
+ registry = {}
387
+ # Register component classes here as they are implemented.
388
+ register_component(registry, Components::Body.new(self))
389
+ register_component(registry, Components::Breakpoint.new(self))
390
+ register_component(registry, Components::Accordion.new(self))
391
+ register_component(registry, Components::Button.new(self))
392
+ register_component(registry, Components::Hero.new(self))
393
+ register_component(registry, Components::Image.new(self))
394
+ register_component(registry, Components::Navbar.new(self))
395
+ register_component(registry, Components::Text.new(self))
396
+ register_component(registry, Components::Divider.new(self))
397
+ register_component(registry, Components::Table.new(self))
398
+ register_component(registry, Components::Social.new(self))
399
+ register_component(registry, Components::Section.new(self))
400
+ register_component(registry, Components::Column.new(self))
401
+ register_component(registry, Components::Spacer.new(self))
402
+ registry
403
+ end
404
+ end
405
+
406
+ def register_component(registry, component)
407
+ component.tags.each { |tag| registry[tag] = component }
408
+ end
409
+
410
+ def resolved_attributes(node, context)
411
+ attrs = {}
412
+ attrs.merge!(context[:global_defaults] || {})
413
+ attrs.merge!(context[:tag_defaults][node.tag_name] || {})
414
+
415
+ node_classes = node.attributes["mj-class"].to_s.split(/\s+/).reject(&:empty?)
416
+ node_classes.each do |klass|
417
+ attrs.merge!(context[:classes][klass] || {})
418
+ end
419
+
420
+ attrs.merge!(node.attributes)
421
+ attrs
422
+ end
423
+
424
+ def html_inner(node)
425
+ if node.respond_to?(:children)
426
+ node.children.map do |child|
427
+ if child.text?
428
+ escape_html(child.content.to_s)
429
+ elsif child.comment?
430
+ "<!--#{child.content}-->"
431
+ else
432
+ serialize_node(child)
433
+ end
434
+ end.join
435
+ else
436
+ escape_html(node.text_content)
437
+ end
438
+ end
439
+
440
+ def raw_inner(node)
441
+ if node.respond_to?(:children)
442
+ node.children.map do |child|
443
+ if child.text?
444
+ child.content.to_s
445
+ elsif child.comment?
446
+ "<!--#{child.content}-->"
447
+ else
448
+ serialize_node(child)
449
+ end
450
+ end.join
451
+ else
452
+ node.text_content
453
+ end
454
+ end
455
+
456
+ def serialize_node(node)
457
+ attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
458
+ return "<#{node.tag_name}#{attrs} />" if node.children.empty?
459
+
460
+ inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
461
+ "<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
462
+ end
463
+
464
+ def style_join(hash)
465
+ hash.each_with_object([]) do |(key, value), memo|
466
+ next if value.nil? || value.to_s.empty?
467
+ memo << "#{key}:#{value}"
468
+ end.join(";")
469
+ end
470
+
471
+ def hash_or_empty(value)
472
+ value.is_a?(Hash) ? value : {}
473
+ end
474
+
475
+ def find_child(node, tag_name)
476
+ node.element_children.find { |child| child.tag_name == tag_name }
477
+ end
478
+
479
+ def contains_tag?(node, tag_name)
480
+ return false unless node.respond_to?(:tag_name)
481
+ return true if node.tag_name == tag_name
482
+
483
+ node.children.any? { |child| child.respond_to?(:children) && contains_tag?(child, tag_name) }
484
+ end
485
+
486
+ def escape_html(value)
487
+ CGI.escapeHTML(value.to_s)
488
+ end
489
+
490
+ def escape_attr(value)
491
+ CGI.escapeHTML(value.to_s)
492
+ end
493
+
494
+ def html_attrs(hash)
495
+ attrs = hash.each_with_object([]) do |(key, value), memo|
496
+ next if value.nil? || value.to_s.empty?
497
+ memo << %(#{key}="#{escape_attr(value)}")
498
+ end
499
+ return "" if attrs.empty?
500
+
501
+ " #{attrs.join(' ')}"
502
+ end
503
+
504
+ def unique_strings(values)
505
+ Array(values).each_with_object([]) do |value, memo|
506
+ next if value.nil? || value.empty?
507
+ next if memo.include?(value)
508
+
509
+ memo << value
510
+ end
511
+ end
512
+ end
513
+ end
@@ -0,0 +1,23 @@
1
+ module MjmlRb
2
+ class Result
3
+ attr_reader :html, :errors, :warnings
4
+
5
+ def initialize(html: "", errors: [], warnings: [])
6
+ @html = html.to_s
7
+ @errors = Array(errors)
8
+ @warnings = Array(warnings)
9
+ end
10
+
11
+ def success?
12
+ @errors.empty?
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ html: @html,
18
+ errors: @errors,
19
+ warnings: @warnings
20
+ }
21
+ end
22
+ end
23
+ end