mjml-rb 0.3.1 → 0.3.3

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: a5afc17046611848190458b50e1b7d0670011e8f1e32a6723b63c7e7ff76029c
4
- data.tar.gz: ff832c4861cd1c28d76f47e0f66f257e8fa1c956dd6e0836826df83be3e4782d
3
+ metadata.gz: 99c98b34eee9aea7822dc4f59250b3b8ca008b98a17cd7e5063eb04595460c31
4
+ data.tar.gz: 6541eb2234e61dd3399dce72b6f0f45f4e810601ecf1c7ff2ccc9a6405d74b84
5
5
  SHA512:
6
- metadata.gz: e43bae28df235a80ad028bc43e37401c45c59c8e0d486f09e8d22faa786c1e18e62e696bde4cce668fcee4ea1c7d3d037cec3ec93d890b0e8e33404f98b776fe
7
- data.tar.gz: c35f403ee71148fa10f6e9d794a61695f35f74201e561fba1afecb4686c71388c01f13bf69c19b799214102ad142a6f86281d922b3ed5eed2ed1e348b446f737
6
+ metadata.gz: 3ae18240675e7e9bf5572634a09cb2ae1079e93c874088b917181245a8b20947f59368b07d911fc781cd96676a7953e9f2aaa650bf69ed7eae9e4e5ff2962782
7
+ data.tar.gz: d6ae46c90053abdfeeb2445c87c7ebc07fbd9e36ac47b335577b03d2bfa0debb2dd5c6f9ce9208910c338ae7e7798d29b0c98ed4fc2b9a8b2aaf9eedd045a2f4
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
@@ -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
@@ -1,5 +1,6 @@
1
1
  require "cgi"
2
2
  require "nokogiri"
3
+ require "set"
3
4
  require_relative "components/accordion"
4
5
  require_relative "components/attributes"
5
6
  require_relative "components/body"
@@ -539,6 +540,7 @@ module MjmlRb
539
540
  existing[property] = merged
540
541
  end
541
542
  normalize_background_fallbacks!(node, existing)
543
+ sync_html_attributes!(node, existing)
542
544
  node["style"] = serialize_css_declarations(existing)
543
545
  end
544
546
 
@@ -552,12 +554,59 @@ module MjmlRb
552
554
  important: declarations.fetch("background-color", {}).fetch(:important, false)
553
555
  }
554
556
  end
557
+ end
558
+
559
+ # Sync HTML attributes from inlined CSS declarations.
560
+ # Mirrors Juice's attribute syncing: width/height on TABLE/TD/TH/IMG,
561
+ # and style-to-attribute mappings (bgcolor, background, align, valign)
562
+ # on table-related elements.
563
+ # See: https://github.com/Automattic/juice/blob/master/lib/inline.js
564
+ WIDTH_HEIGHT_ELEMENTS = Set.new(%w[table td th img]).freeze
565
+ TABLE_ELEMENTS = Set.new(%w[table th tr td caption colgroup col thead tbody tfoot]).freeze
566
+ STYLE_TO_ATTRIBUTE = {
567
+ "background-color" => "bgcolor",
568
+ "background-image" => "background",
569
+ "text-align" => "align",
570
+ "vertical-align" => "valign"
571
+ }.freeze
572
+
573
+ def sync_html_attributes!(node, declarations)
574
+ tag = node.name.downcase
555
575
 
556
- return unless node.name == "td"
557
- return unless node["bgcolor"]
558
- return if %w[none transparent].include?(background_color.downcase)
576
+ # Sync width/height on TABLE, TD, TH, IMG
577
+ if WIDTH_HEIGHT_ELEMENTS.include?(tag)
578
+ %w[width height].each do |prop|
579
+ css_value = declaration_value(declarations[prop])
580
+ next if css_value.nil? || css_value.empty?
581
+
582
+ # Convert CSS px values to plain numbers for HTML attributes;
583
+ # keep other values (auto, %) as-is.
584
+ html_value = css_value.sub(/px\z/i, "")
585
+ node[prop] = html_value
586
+ end
587
+ end
559
588
 
560
- node["bgcolor"] = background_color
589
+ # Sync style-to-attribute mappings on table elements
590
+ if TABLE_ELEMENTS.include?(tag)
591
+ STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
592
+ css_value = declaration_value(declarations[css_prop])
593
+ next if css_value.nil? || css_value.empty?
594
+
595
+ case html_attr
596
+ when "bgcolor"
597
+ next if %w[none transparent].include?(css_value.downcase)
598
+ node[html_attr] = css_value
599
+ when "background"
600
+ # Extract url(...) from background-image
601
+ url = css_value[/url\(['"]?([^'")]+)['"]?\)/i, 1]
602
+ node[html_attr] = url if url
603
+ when "align"
604
+ node[html_attr] = css_value
605
+ when "valign"
606
+ node[html_attr] = css_value
607
+ end
608
+ end
609
+ end
561
610
  end
562
611
 
563
612
  def syncable_background?(value)
@@ -648,6 +697,11 @@ module MjmlRb
648
697
  register_component(registry, Components::Section.new(self))
649
698
  register_component(registry, Components::Column.new(self))
650
699
  register_component(registry, Components::Spacer.new(self))
700
+
701
+ MjmlRb.component_registry.custom_components.each do |klass|
702
+ register_component(registry, klass.new(self))
703
+ end
704
+
651
705
  registry
652
706
  end
653
707
  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.1".freeze
2
+ VERSION = "0.3.3".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.1
4
+ version: 0.3.3
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