mjml-rb 0.3.0 → 0.3.2

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: cfa6cc54a797f8b4323003dbcfd92fa4ef02b1e0b118e58acd14bb8e901e502d
4
- data.tar.gz: 12d0fb1d71e03a23811caac5b0417b7f32823b95870e47296ea51fd8bd713018
3
+ metadata.gz: 2a961ff4aa96b2bc79520f261eb233c0e8b77f363afa3d7e0c670fa622359ab7
4
+ data.tar.gz: 295a1289237c408dbcd2752c26bc45c9b16dd80edfe00933bcec864bf250bebf
5
5
  SHA512:
6
- metadata.gz: 689a1974e1c6e7477e202c3fb070c63eadf3b66617e2ea177feb6b45ae9d58cf9c1472dd7ddbe4e141d6583e67f83c93f76ca91c5f787d4a706241523c313361
7
- data.tar.gz: ad73e16bd469bdfd98ccff031aca1796f99c460c87ab6435ba272492ffeb4ffff2b1e77f1019c368324286942cc561239e20b4219e6010efcac7d2066a945bb6
6
+ metadata.gz: e544b134c2db7296c58cd9a5c6235b5780448a3673618ae5392ca6eeada84cafa67d88ad7cf1e8aab99aa41ab92feabcc3255ec7eb30ccdbd386b52cc73515ba
7
+ data.tar.gz: 3751e0e61fcfd95378c03d1024caa4dcc4ccc0e7dd1f92a08108afe98c5858cff3d2c31e8a551185a145c44e36e96d7d934fcfc4cd86168fad8ec74650192661
data/README.md CHANGED
@@ -95,6 +95,59 @@ compiler options in your application config:
95
95
  config.mjml_rb.compiler_options = { validation_level: "soft" }
96
96
  ```
97
97
 
98
+ ## Custom components
99
+
100
+ You can register custom MJML components written in Ruby:
101
+
102
+ ```ruby
103
+ class MjRating < MjmlRb::Components::Base
104
+ TAGS = ["mj-rating"].freeze
105
+ ALLOWED_ATTRIBUTES = { "stars" => "integer", "color" => "color" }.freeze
106
+ DEFAULT_ATTRIBUTES = { "stars" => "5", "color" => "#f4b400" }.freeze
107
+
108
+ def render(tag_name:, node:, context:, attrs:, parent:)
109
+ stars = (attrs["stars"] || "5").to_i
110
+ color = attrs["color"] || "#f4b400"
111
+ %(<div style="color:#{escape_attr(color)}">#{"\u2605" * stars}</div>)
112
+ end
113
+ end
114
+
115
+ MjmlRb.register_component(MjRating,
116
+ dependencies: { "mj-column" => ["mj-rating"] },
117
+ ending_tags: ["mj-rating"]
118
+ )
119
+ ```
120
+
121
+ The `dependencies` hash declares which parent tags accept the new component as a child. The `ending_tags` list tells the parser to treat content as raw HTML (like `mj-text`). Both are optional.
122
+
123
+ Once registered, the component works in MJML markup and is validated like any built-in component.
124
+
125
+ ## `.mjmlrc` config file
126
+
127
+ Place a `.mjmlrc` file (JSON) in your project root to auto-register custom components and set default compiler options:
128
+
129
+ ```json
130
+ {
131
+ "packages": [
132
+ "./lib/mjml_components/mj_rating.rb"
133
+ ],
134
+ "options": {
135
+ "beautify": true,
136
+ "validation-level": "soft"
137
+ }
138
+ }
139
+ ```
140
+
141
+ - **`packages`** — Ruby files to `require`. Each file should call `MjmlRb.register_component` to register its components.
142
+ - **`options`** — Default compiler options. CLI flags and programmatic options override these.
143
+
144
+ The CLI loads `.mjmlrc` automatically from the working directory. For the library API, load it explicitly:
145
+
146
+ ```ruby
147
+ MjmlRb::ConfigFile.load("/path/to/project")
148
+ result = MjmlRb.mjml2html(mjml_string)
149
+ ```
150
+
98
151
  ## Architecture
99
152
 
100
153
  The compile pipeline is intentionally simple and fully Ruby-based:
data/lib/mjml-rb/cli.rb CHANGED
@@ -24,6 +24,11 @@ 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
32
  input_mode, input_values = resolve_input(options)
28
33
  output_mode = resolve_output(options)
29
34
  config = options[:config]
@@ -0,0 +1,65 @@
1
+ require "set"
2
+
3
+ module MjmlRb
4
+ class ComponentRegistry
5
+ attr_reader :custom_components, :custom_dependencies, :custom_ending_tags
6
+
7
+ def initialize
8
+ @custom_components = []
9
+ @custom_dependencies = {}
10
+ @custom_ending_tags = Set.new
11
+ end
12
+
13
+ def register(klass, dependencies: {}, ending_tags: [])
14
+ validate_component!(klass)
15
+ @custom_components << klass unless @custom_components.include?(klass)
16
+ dependencies.each do |parent, children|
17
+ @custom_dependencies[parent] = ((@custom_dependencies[parent] || []) + Array(children)).uniq
18
+ end
19
+ @custom_ending_tags.merge(Array(ending_tags))
20
+ end
21
+
22
+ def component_class_for_tag(tag_name)
23
+ all_component_classes.find { |klass| klass.tags.include?(tag_name) }
24
+ end
25
+
26
+ def dependency_rules
27
+ merged = {}
28
+ Dependencies::RULES.each { |k, v| merged[k] = v.dup }
29
+ @custom_dependencies.each do |parent, children|
30
+ merged[parent] = ((merged[parent] || []) + Array(children)).uniq
31
+ end
32
+ merged
33
+ end
34
+
35
+ def ending_tags
36
+ Dependencies::ENDING_TAGS | @custom_ending_tags
37
+ end
38
+
39
+ def reset!
40
+ @custom_components.clear
41
+ @custom_dependencies.clear
42
+ @custom_ending_tags.clear
43
+ end
44
+
45
+ private
46
+
47
+ def all_component_classes
48
+ builtin = MjmlRb::Components.constants.filter_map do |name|
49
+ value = MjmlRb::Components.const_get(name)
50
+ value if value.is_a?(Class) && value < MjmlRb::Components::Base
51
+ rescue NameError
52
+ nil
53
+ end
54
+ (builtin + @custom_components).uniq
55
+ end
56
+
57
+ def validate_component!(klass)
58
+ raise ArgumentError, "Expected a Class, got #{klass.class}" unless klass.is_a?(Class)
59
+ unless klass.respond_to?(:tags) && klass.respond_to?(:allowed_attributes)
60
+ raise ArgumentError, "Component class must respond to .tags and .allowed_attributes (inherit from MjmlRb::Components::Base)"
61
+ end
62
+ raise ArgumentError, "Component must define at least one tag via TAGS" if klass.tags.empty?
63
+ end
64
+ end
65
+ end
@@ -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")
@@ -0,0 +1,34 @@
1
+ require "json"
2
+
3
+ module MjmlRb
4
+ class ConfigFile
5
+ DEFAULT_NAME = ".mjmlrc"
6
+
7
+ def self.load(dir = Dir.pwd)
8
+ path = File.join(dir, DEFAULT_NAME)
9
+ return {} unless File.exist?(path)
10
+
11
+ raw = JSON.parse(File.read(path))
12
+ config = {}
13
+
14
+ if raw["packages"].is_a?(Array)
15
+ raw["packages"].each do |pkg_path|
16
+ resolved = File.expand_path(pkg_path, dir)
17
+ require resolved
18
+ end
19
+ config[:packages_loaded] = raw["packages"]
20
+ end
21
+
22
+ if raw["options"].is_a?(Hash)
23
+ config[:options] = raw["options"].each_with_object({}) do |(k, v), memo|
24
+ memo[k.to_s.tr("-", "_").to_sym] = v
25
+ end
26
+ end
27
+
28
+ config
29
+ rescue JSON::ParserError => e
30
+ warn "WARNING: Failed to parse #{path}: #{e.message}"
31
+ {}
32
+ end
33
+ end
34
+ 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)
@@ -638,6 +648,11 @@ module MjmlRb
638
648
  register_component(registry, Components::Section.new(self))
639
649
  register_component(registry, Components::Column.new(self))
640
650
  register_component(registry, Components::Spacer.new(self))
651
+
652
+ MjmlRb.component_registry.custom_components.each do |klass|
653
+ register_component(registry, klass.new(self))
654
+ end
655
+
641
656
  registry
642
657
  end
643
658
  end
@@ -49,7 +49,7 @@ module MjmlRb
49
49
  validate_supported_attributes(node, errors)
50
50
  validate_attribute_types(node, errors)
51
51
 
52
- return if Dependencies::ENDING_TAGS.include?(node.tag_name)
52
+ return if MjmlRb.component_registry.ending_tags.include?(node.tag_name)
53
53
 
54
54
  node.element_children.each { |child| walk(child, errors) }
55
55
  end
@@ -66,9 +66,9 @@ module MjmlRb
66
66
  def validate_allowed_children(node, errors)
67
67
  # Ending-tag components treat content as raw HTML; REXML still parses
68
68
  # children structurally, so skip child validation for those tags.
69
- return if Dependencies::ENDING_TAGS.include?(node.tag_name)
69
+ return if MjmlRb.component_registry.ending_tags.include?(node.tag_name)
70
70
 
71
- allowed = Dependencies::RULES[node.tag_name]
71
+ allowed = MjmlRb.component_registry.dependency_rules[node.tag_name]
72
72
  return unless allowed
73
73
 
74
74
  node.element_children.each do |child|
@@ -134,12 +134,7 @@ module MjmlRb
134
134
  end
135
135
 
136
136
  def component_class_for_tag(tag_name)
137
- MjmlRb::Components.constants.filter_map do |name|
138
- value = MjmlRb::Components.const_get(name)
139
- value if value.is_a?(Class) && value < MjmlRb::Components::Base
140
- rescue NameError
141
- nil
142
- end.find { |klass| klass.tags.include?(tag_name) }
137
+ MjmlRb.component_registry.component_class_for_tag(tag_name)
143
138
  end
144
139
 
145
140
  def known_tag?(tag_name)
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.3.0".freeze
2
+ VERSION = "0.3.2".freeze
3
3
  end
data/lib/mjml-rb.rb CHANGED
@@ -2,6 +2,8 @@ require_relative "mjml-rb/version"
2
2
  require_relative "mjml-rb/result"
3
3
  require_relative "mjml-rb/ast_node"
4
4
  require_relative "mjml-rb/dependencies"
5
+ require_relative "mjml-rb/component_registry"
6
+ require_relative "mjml-rb/config_file"
5
7
  require_relative "mjml-rb/parser"
6
8
  require_relative "mjml-rb/renderer"
7
9
  require_relative "mjml-rb/compiler"
@@ -43,6 +45,14 @@ module MjmlRb
43
45
  ActionView::Template.register_template_handler(:mjml, TemplateHandler.new)
44
46
  end
45
47
 
48
+ def register_component(klass, dependencies: {}, ending_tags: [])
49
+ component_registry.register(klass, dependencies: dependencies, ending_tags: ending_tags)
50
+ end
51
+
52
+ def component_registry
53
+ @component_registry ||= ComponentRegistry.new
54
+ end
55
+
46
56
  def mjml2html(mjml, options = {})
47
57
  Compiler.new(options).compile(mjml).to_h
48
58
  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.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -53,6 +53,7 @@ files:
53
53
  - lib/mjml-rb/ast_node.rb
54
54
  - lib/mjml-rb/cli.rb
55
55
  - lib/mjml-rb/compiler.rb
56
+ - lib/mjml-rb/component_registry.rb
56
57
  - lib/mjml-rb/components/accordion.rb
57
58
  - lib/mjml-rb/components/attributes.rb
58
59
  - lib/mjml-rb/components/base.rb
@@ -75,6 +76,7 @@ files:
75
76
  - lib/mjml-rb/components/spacer.rb
76
77
  - lib/mjml-rb/components/table.rb
77
78
  - lib/mjml-rb/components/text.rb
79
+ - lib/mjml-rb/config_file.rb
78
80
  - lib/mjml-rb/dependencies.rb
79
81
  - lib/mjml-rb/parser.rb
80
82
  - lib/mjml-rb/railtie.rb