mjml-rb 0.2.39 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7864f17e62acc702b5da45e9ca4322bc5207d486190de2ae7bab1a19e5109be
4
- data.tar.gz: 9a5587f0f059d2d422407d65ad149e0d1ba04b731409a42290d3b4aa2aa34fc3
3
+ metadata.gz: a5afc17046611848190458b50e1b7d0670011e8f1e32a6723b63c7e7ff76029c
4
+ data.tar.gz: ff832c4861cd1c28d76f47e0f66f257e8fa1c956dd6e0836826df83be3e4782d
5
5
  SHA512:
6
- metadata.gz: a910ab3a64a41505e662a31c97ad11b29a72bee403c5e98b70a2abafa7fee0e056a173e55232bde1d8a5b765a524a6acf1a99d0536c0c74a49ef67dcaa17265b
7
- data.tar.gz: 0d9e9e08cfc38bcde65ba9480ca5a10365b0ecc60a20695a57cbc027dbe4a86df6d92424d46597d2ae8c46c7e25f6e4ccb6575d6d43f0d4dd157c54939afec36
6
+ metadata.gz: e43bae28df235a80ad028bc43e37401c45c59c8e0d486f09e8d22faa786c1e18e62e696bde4cce668fcee4ea1c7d3d037cec3ec93d890b0e8e33404f98b776fe
7
+ data.tar.gz: c35f403ee71148fa10f6e9d794a61695f35f74201e561fba1afecb4686c71388c01f13bf69c19b799214102ad142a6f86281d922b3ed5eed2ed1e348b446f737
data/README.md CHANGED
@@ -51,15 +51,18 @@ By default, `.mjml` files are treated as raw MJML/XML source.
51
51
  If you want Slim-backed MJML templates, configure it explicitly:
52
52
 
53
53
  ```ruby
54
- config.mjml_rb.template_language = :slim
54
+ config.mjml_rb.rails_template_language = :slim
55
55
  ```
56
56
 
57
- Supported values are `:erb`, `:slim`, and `:haml`.
57
+ Supported values are `:slim` and `:haml`.
58
58
 
59
- With a configured `template_language`, `.mjml` templates are rendered through
60
- that template engine first, so partials and embedded Ruby can assemble MJML
61
- before the outer template is compiled to HTML. Without that setting, non-XML
62
- MJML source is rejected instead of being guessed.
59
+ With a configured `rails_template_language`, `.mjml` templates are rendered
60
+ through that template engine first, so partials and embedded Ruby can assemble
61
+ MJML before the outer template is compiled to HTML. Without that setting,
62
+ non-XML MJML source is rejected instead of being guessed.
63
+
64
+ For `:slim` or `:haml`, the matching Rails template handler must already be
65
+ registered in `ActionView` by the corresponding gem or integration layer.
63
66
 
64
67
  Create a view such as `app/views/user_mailer/welcome.html.mjml`:
65
68
 
@@ -39,6 +39,7 @@ module MjmlRb
39
39
 
40
40
  def render(tag_name:, node:, context:, attrs:, parent:)
41
41
  width_pct = context.delete(:_column_width_pct) || 100.0
42
+ mobile_width = context.delete(:_column_mobile_width)
42
43
  css_class = attrs["css-class"]
43
44
  a = self.class.default_attributes.merge(attrs)
44
45
 
@@ -63,7 +64,7 @@ module MjmlRb
63
64
  "direction" => a["direction"],
64
65
  "display" => "inline-block",
65
66
  "vertical-align" => vertical_align,
66
- "width" => "100%"
67
+ "width" => (mobile_width ? mobile_width_value(a, width_pct, context[:container_width]) : "100%")
67
68
  )
68
69
 
69
70
  column_markup =
@@ -168,6 +169,22 @@ module MjmlRb
168
169
  value && !value.empty?
169
170
  end
170
171
 
172
+ def mobile_width_value(attrs, width_pct, parent_width)
173
+ width = attrs["width"]
174
+
175
+ if present_attr?(width) && width.end_with?("%")
176
+ width
177
+ elsif present_attr?(width) && width.end_with?("px")
178
+ parent_width_px = parse_pixel_value(parent_width || "600px")
179
+ return "100%" if parent_width_px.zero?
180
+
181
+ percentage = (parse_pixel_value(width) / parent_width_px) * 100
182
+ "#{percentage.to_s.sub(/\.?0+$/, "")}%"
183
+ else
184
+ "#{width_pct.to_f.to_s.sub(/\.?0+$/, "")}%"
185
+ end
186
+ end
187
+
171
188
  def with_child_container_width(context, attrs, width_pct)
172
189
  previous_container_width = context[:container_width]
173
190
  context[:container_width] = child_container_width(context, attrs, width_pct)
@@ -77,6 +77,7 @@ module MjmlRb
77
77
  width_pct = widths[column_index] || 100.0
78
78
  column_index += 1
79
79
  context[:_column_width_pct] = width_pct
80
+ context[:_column_mobile_width] = true
80
81
  td_style = style_join(
81
82
  "vertical-align" => resolved_attributes(child, context)["vertical-align"] || "top",
82
83
  "width" => "#{(group_width_px * width_pct / 100.0).round}px"
@@ -238,11 +238,11 @@ module MjmlRb
238
238
  def hero_container_width(attrs, container_width)
239
239
  width = parse_unit_value(container_width)
240
240
  content_width = width - padding_side(attrs, "left") - padding_side(attrs, "right")
241
- "#{[content_width, 0].max}px"
241
+ px_length([content_width, 0].max)
242
242
  end
243
243
 
244
244
  def normalize_container_width(value)
245
- "#{parse_unit_value(value)}px"
245
+ px_length(parse_unit_value(value))
246
246
  end
247
247
 
248
248
  def padding_side(attrs, side)
@@ -280,6 +280,17 @@ module MjmlRb
280
280
  def blank?(value)
281
281
  value.nil? || value.to_s.empty?
282
282
  end
283
+
284
+ def px_length(value)
285
+ "#{format_number(value)}px"
286
+ end
287
+
288
+ def format_number(value)
289
+ number = value.to_f
290
+ return number.to_i.to_s if number == number.to_i
291
+
292
+ number.to_s.sub(/\.?0+$/, "")
293
+ end
283
294
  end
284
295
  end
285
296
  end
@@ -64,7 +64,7 @@ module MjmlRb
64
64
  render_wrapper(node, context, attrs)
65
65
  else
66
66
  a = self.class.default_attributes.merge(attrs)
67
- if a["full-width"] == "full-width"
67
+ if a["full-width"] == "full-width" && !context[:_inside_full_width_wrapper]
68
68
  render_full_width_section(node, context, a)
69
69
  else
70
70
  render_section(node, context, a)
@@ -254,8 +254,8 @@ module MjmlRb
254
254
 
255
255
  # Build v:fill attributes
256
256
  fill_pairs = [
257
- ["origin", "#{v_origin_x}, #{v_origin_y}"],
258
- ["position", "#{v_pos_x}, #{v_pos_y}"],
257
+ ["origin", "#{format_vml_number(v_origin_x)}, #{format_vml_number(v_origin_y)}"],
258
+ ["position", "#{format_vml_number(v_pos_x)}, #{format_vml_number(v_pos_y)}"],
259
259
  ["src", bg_url],
260
260
  ["color", bg_color],
261
261
  ["type", vml_type]
@@ -287,6 +287,13 @@ module MjmlRb
287
287
  end
288
288
  end
289
289
 
290
+ def format_vml_number(value)
291
+ number = value.to_f
292
+ return number.to_i.to_s if number == number.to_i
293
+
294
+ number.to_s.sub(/\.?0+$/, "")
295
+ end
296
+
290
297
  def vml_size_attributes(bg_size)
291
298
  case bg_size
292
299
  when "cover"
@@ -639,7 +646,9 @@ module MjmlRb
639
646
  box_width = container_px - pad_left - pad_right - border_left - border_right
640
647
 
641
648
  previous_container_width = context[:container_width]
649
+ previous_full_width_wrapper = context[:_inside_full_width_wrapper]
642
650
  context[:container_width] = "#{box_width}px"
651
+ context[:_inside_full_width_wrapper] = full_width
643
652
 
644
653
  div_attrs = {"class" => (full_width ? nil : css_class), "style" => div_style}
645
654
  inner = merge_outlook_conditionals(render_wrapped_children_wrapper(node, context, container_px, a["gap"]))
@@ -686,6 +695,7 @@ module MjmlRb
686
695
  end
687
696
  ensure
688
697
  context[:container_width] = previous_container_width if previous_container_width
698
+ context[:_inside_full_width_wrapper] = previous_full_width_wrapper
689
699
  end
690
700
 
691
701
  # Wrap each child mj-section/mj-wrapper in an Outlook conditional <tr><td>.
@@ -703,7 +713,7 @@ module MjmlRb
703
713
  child_attrs = resolved_attributes(child, context)
704
714
  child_css = child_attrs["css-class"]
705
715
  outlook_class = suffix_css_classes(child_css)
706
- td_open = %(<!--[if mso | IE]><tr><td class="#{escape_attr(outlook_class)}" width="#{container_px}px" ><![endif]-->)
716
+ td_open = %(<!--[if mso | IE]><tr><td class="#{escape_attr(outlook_class)}" width="#{container_px}" ><![endif]-->)
707
717
  td_close = %(<!--[if mso | IE]></td></tr><![endif]-->)
708
718
  child_html = with_wrapper_child_gap(context, index.zero? ? nil : gap) do
709
719
  render_node(child, context, parent: "mj-wrapper")
@@ -1,17 +1,19 @@
1
- require_relative "template_handler"
1
+ if defined?(Rails::Railtie)
2
+ require_relative "template_handler"
2
3
 
3
- module MjmlRb
4
- class Railtie < Rails::Railtie
5
- config.mjml_rb = ActiveSupport::OrderedOptions.new
6
- config.mjml_rb.compiler_options = {validation_level: "strict"}
7
- config.mjml_rb.template_language = nil
4
+ module MjmlRb
5
+ class Railtie < Rails::Railtie
6
+ config.mjml_rb = ActiveSupport::OrderedOptions.new
7
+ config.mjml_rb.compiler_options = {validation_level: "strict"}
8
+ config.mjml_rb.rails_template_language = nil
8
9
 
9
- initializer "mjml_rb.action_view" do |app|
10
- MjmlRb.rails_compiler_options = app.config.mjml_rb.compiler_options.to_h
11
- MjmlRb.rails_template_language = app.config.mjml_rb.template_language
10
+ initializer "mjml_rb.action_view" do |app|
11
+ MjmlRb.rails_compiler_options = app.config.mjml_rb.compiler_options.to_h
12
+ MjmlRb.rails_template_language = app.config.mjml_rb.rails_template_language
12
13
 
13
- ActiveSupport.on_load(:action_view) do
14
- MjmlRb.register_action_view_template_handler!
14
+ ActiveSupport.on_load(:action_view) do
15
+ MjmlRb.register_action_view_template_handler!
16
+ end
15
17
  end
16
18
  end
17
19
  end
@@ -113,6 +113,7 @@ module MjmlRb
113
113
 
114
114
  def build_html_document(content, context)
115
115
  content = minify_outlook_conditionals(content)
116
+ content = apply_html_attributes_to_content(content, context)
116
117
  title = context[:title].to_s
117
118
  preview = context[:preview]
118
119
  head_raw = Array(context[:head_raw]).join("\n")
@@ -165,7 +166,6 @@ module MjmlRb
165
166
  </html>
166
167
  HTML
167
168
 
168
- html = apply_html_attributes(html, context)
169
169
  html = apply_inline_styles(html, context)
170
170
  html = merge_outlook_conditionals(html)
171
171
  before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
@@ -307,23 +307,33 @@ module MjmlRb
307
307
  end
308
308
  end
309
309
 
310
- def apply_html_attributes(html, context)
310
+ def apply_html_attributes_to_content(content, context)
311
311
  rules = context[:html_attributes] || {}
312
- return html if rules.empty?
312
+ return content if rules.empty?
313
313
 
314
- document = Nokogiri::HTML(html)
314
+ root = html_attributes_fragment_root(content, context)
315
315
 
316
316
  rules.each do |selector, attrs|
317
317
  next if selector.empty? || attrs.empty?
318
318
 
319
- select_nodes(document, selector).each do |node|
319
+ select_nodes(root, selector).each do |node|
320
320
  attrs.each do |name, value|
321
321
  node[name] = value.to_s
322
322
  end
323
323
  end
324
324
  end
325
325
 
326
- document.to_html
326
+ root.inner_html
327
+ end
328
+
329
+ def html_attributes_fragment_root(content, context)
330
+ wrapper_attrs = {
331
+ "data-mjml-body-root" => "true",
332
+ "lang" => context[:lang],
333
+ "dir" => context[:dir]
334
+ }
335
+ fragment = Nokogiri::HTML::DocumentFragment.parse("<div#{html_attrs(wrapper_attrs)}>#{content}</div>")
336
+ fragment.at_css("div[data-mjml-body-root='true']")
327
337
  end
328
338
 
329
339
  def apply_inline_styles(html, context)
@@ -1,114 +1,65 @@
1
+ require "action_view"
2
+ require "action_view/template"
3
+
1
4
  module MjmlRb
2
5
  class TemplateHandler
3
- MJML_CAPTURE_DEPTH_IVAR = :@_mjml_rb_capture_depth
4
- TEMPLATE_ENGINES = {
5
- slim: {
6
- require: "slim",
7
- gem_name: "slim"
8
- },
9
- haml: {
10
- require: "haml",
11
- gem_name: "haml"
12
- }
13
- }.freeze
14
-
15
- class << self
16
- def call(template, source = nil)
17
- <<~RUBY
18
- ::MjmlRb::TemplateHandler.render(self, #{template.source.inspect}, #{template.identifier.inspect}, local_assigns)
19
- RUBY
20
- end
6
+ SUPPORTED_TEMPLATE_LANGUAGES = %w[:slim :haml].freeze
21
7
 
22
- def render(view_context, source, identifier, local_assigns = {})
23
- if capture_mode?(view_context)
24
- return mark_html_safe(render_source(view_context, source, local_assigns))
25
- end
8
+ def call(template, source = nil)
9
+ compiled_source = compile_source(template, source)
10
+ template_path = template.respond_to?(:virtual_path) ? template.virtual_path : template.identifier
26
11
 
27
- with_capture_mode(view_context) do
28
- mjml_source = render_source(view_context, source, local_assigns).to_s
29
- mjml_result = ::MjmlRb::Compiler.new(::MjmlRb.rails_compiler_options || {}).compile(mjml_source)
12
+ if /<mjml.*?>/i.match?(compiled_source)
13
+ "::MjmlRb::TemplateHandler.render_compiled_source(begin;#{compiled_source};end, #{template_path.inspect})"
14
+ else
15
+ "::MjmlRb::TemplateHandler.render_partial_source(begin;#{compiled_source};end)"
16
+ end
17
+ end
30
18
 
31
- if mjml_result.errors.any?
32
- raise "MJML compilation failed for #{identifier}: #{mjml_result.errors.map { |error| error[:formatted_message] || error[:message] }.join(', ')}"
33
- end
19
+ def self.render_compiled_source(mjml_source, template_path)
20
+ mjml_result = ::MjmlRb::Compiler.new(::MjmlRb.rails_compiler_options || {}).compile(mjml_source.to_s)
34
21
 
35
- mark_html_safe(mjml_result.html.to_s)
36
- end
22
+ if mjml_result.errors.any?
23
+ raise "MJML compilation failed for #{template_path}: #{mjml_result.errors.map { |error| error[:formatted_message] || error[:message] }.join(', ')}"
37
24
  end
38
25
 
39
- private
40
-
41
- def render_source(view_context, source, local_assigns)
42
- language = ::MjmlRb.rails_template_language
43
- if language.nil?
44
- stripped_source = source.to_s.lstrip
45
- return source.to_s if stripped_source.start_with?("<")
46
-
47
- raise "MJML Rails template_language is not configured for non-XML templates. Supported values: nil, :erb, :slim, :haml"
48
- end
49
-
50
- case language
51
- when :erb
52
- render_erb_source(view_context, source.to_s, local_assigns)
53
- when :slim, :haml
54
- render_template_language_source(view_context, source.to_s, local_assigns, language)
55
- else
56
- raise "Unsupported MJML Rails template_language `#{language}`. Supported values: nil, :erb, :slim, :haml"
57
- end
58
- end
26
+ html = mjml_result.html.to_s
27
+ html.respond_to?(:html_safe) ? html.html_safe : html
28
+ end
59
29
 
60
- def render_erb_source(view_context, source, local_assigns)
61
- require "erb"
30
+ def self.render_partial_source(compiled_source)
31
+ output = compiled_source.to_s
32
+ output.respond_to?(:html_safe) ? output.html_safe : output
33
+ end
62
34
 
63
- local_assigns.each do |name, value|
64
- define_local_reader(view_context, name, value)
65
- end
35
+ private
66
36
 
67
- erb = ::ERB.new(source)
68
- erb.result(view_context.instance_eval { binding })
69
- end
37
+ def template_handler
38
+ language = MjmlRb.rails_template_language
39
+ raise missing_rails_template_language_error if language.nil?
70
40
 
71
- def render_template_language_source(view_context, source, local_assigns, language)
72
- engine = TEMPLATE_ENGINES.fetch(language)
73
- require engine[:require]
74
-
75
- case language
76
- when :slim
77
- ::Slim::Template.new { source }.render(view_context, local_assigns.transform_keys(&:to_sym))
78
- when :haml
79
- if defined?(::Haml::Template)
80
- ::Haml::Template.new { source }.render(view_context, local_assigns.transform_keys(&:to_sym))
81
- else
82
- raise "MJML Rails template_language is set to :haml, but this Haml version does not expose Haml::Template"
83
- end
84
- end
85
- rescue LoadError
86
- raise "MJML Rails template_language is set to :#{language}, but the `#{engine[:gem_name]}` gem is not available"
87
- end
41
+ handler = ActionView::Template.registered_template_handler(language)
42
+ return handler if handler
88
43
 
89
- def define_local_reader(view_context, name, value)
90
- singleton_class = class << view_context; self; end
91
- singleton_class.send(:define_method, name) do
92
- value
93
- end
94
- end
44
+ raise "MJML Rails rails_template_language `#{language}` is not registered with ActionView. Make sure the matching Rails template handler is loaded."
45
+ end
95
46
 
96
- def capture_mode?(view_context)
97
- view_context.instance_variable_get(MJML_CAPTURE_DEPTH_IVAR).to_i.positive?
98
- end
47
+ def compile_source(template, source)
48
+ return template.source.inspect if xml_source?(template.source)
99
49
 
100
- def with_capture_mode(view_context)
101
- depth = view_context.instance_variable_get(MJML_CAPTURE_DEPTH_IVAR).to_i
102
- view_context.instance_variable_set(MJML_CAPTURE_DEPTH_IVAR, depth + 1)
103
- yield
104
- ensure
105
- next_depth = view_context.instance_variable_get(MJML_CAPTURE_DEPTH_IVAR).to_i - 1
106
- view_context.instance_variable_set(MJML_CAPTURE_DEPTH_IVAR, [next_depth, 0].max)
107
- end
50
+ template_handler.call(template, source)
51
+ rescue RuntimeError => error
52
+ raise error unless error.message == missing_rails_template_language_error
108
53
 
109
- def mark_html_safe(value)
110
- value.respond_to?(:html_safe) ? value.html_safe : value
111
- end
54
+ %(raise #{missing_rails_template_language_error.inspect})
55
+ end
56
+
57
+ def xml_source?(source)
58
+ source.to_s.lstrip.start_with?("<")
59
+ end
60
+
61
+ def missing_rails_template_language_error
62
+ "MJML Rails rails_template_language is not configured for non-XML templates. Configure it with one of: #{SUPPORTED_TEMPLATE_LANGUAGES.join(', ')}. Otherwise use raw XML MJML."
112
63
  end
113
64
  end
114
65
  end
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.39".freeze
2
+ VERSION = "0.3.1".freeze
3
3
  end
data/lib/mjml-rb.rb CHANGED
@@ -40,7 +40,7 @@ module MjmlRb
40
40
  return unless defined?(ActionView::Template)
41
41
 
42
42
  require_relative "mjml-rb/template_handler"
43
- ActionView::Template.register_template_handler(:mjml, TemplateHandler)
43
+ ActionView::Template.register_template_handler(:mjml, TemplateHandler.new)
44
44
  end
45
45
 
46
46
  def mjml2html(mjml, options = {})
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.2.39
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk