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,187 @@
1
+ require_relative "dependencies"
2
+ require_relative "parser"
3
+
4
+ module MjmlRb
5
+ class Validator
6
+ GLOBAL_ALLOWED_ATTRIBUTES = %w[css-class mj-class].freeze
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
+ def initialize(parser: Parser.new)
15
+ @parser = parser
16
+ end
17
+
18
+ def validate(mjml_or_ast, options = {})
19
+ root = mjml_or_ast.is_a?(AstNode) ? mjml_or_ast : parse_ast(mjml_or_ast, options)
20
+ return [error("Root element must be <mjml>", tag_name: root&.tag_name)] unless root&.tag_name == "mjml"
21
+
22
+ errors = []
23
+ errors << error("Missing <mj-body>", tag_name: "mjml") unless root.element_children.any? { |c| c.tag_name == "mj-body" }
24
+ walk(root, errors)
25
+ errors
26
+ rescue Parser::ParseError => e
27
+ [error(e.message, line: e.line)]
28
+ end
29
+
30
+ private
31
+
32
+ def parse_ast(mjml, options)
33
+ @parser.parse(
34
+ mjml,
35
+ keep_comments: options.fetch(:keep_comments, false),
36
+ preprocessors: options.fetch(:preprocessors, []),
37
+ ignore_includes: options.fetch(:ignore_includes, false),
38
+ file_path: options.fetch(:file_path, "."),
39
+ actual_path: options.fetch(:actual_path, ".")
40
+ )
41
+ end
42
+
43
+ def walk(node, errors)
44
+ return unless node.element?
45
+
46
+ validate_allowed_children(node, errors)
47
+ validate_required_attributes(node, errors)
48
+ validate_supported_attributes(node, errors)
49
+ validate_attribute_types(node, errors)
50
+
51
+ node.element_children.each { |child| walk(child, errors) }
52
+ end
53
+
54
+ def validate_allowed_children(node, errors)
55
+ allowed = Dependencies::RULES[node.tag_name]
56
+ return unless allowed
57
+
58
+ node.element_children.each do |child|
59
+ next if allowed_child?(child.tag_name, allowed)
60
+
61
+ errors << error(
62
+ "Element <#{child.tag_name}> is not allowed inside <#{node.tag_name}>",
63
+ tag_name: child.tag_name
64
+ )
65
+ end
66
+ end
67
+
68
+ def validate_required_attributes(node, errors)
69
+ required = REQUIRED_BY_TAG[node.tag_name] || []
70
+ required.each do |attr|
71
+ next if node.attributes.key?(attr)
72
+
73
+ errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>", tag_name: node.tag_name)
74
+ end
75
+ end
76
+
77
+ def validate_supported_attributes(node, errors)
78
+ allowed_attributes = allowed_attributes_for(node.tag_name)
79
+ return if allowed_attributes.empty?
80
+
81
+ node.attributes.each_key do |attribute_name|
82
+ next if allowed_attributes.key?(attribute_name)
83
+ next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
84
+
85
+ errors << error("Attribute `#{attribute_name}` is not allowed for <#{node.tag_name}>", tag_name: node.tag_name)
86
+ end
87
+ end
88
+
89
+ def validate_attribute_types(node, errors)
90
+ allowed_attributes = allowed_attributes_for(node.tag_name)
91
+ return if allowed_attributes.empty?
92
+
93
+ node.attributes.each do |attribute_name, attribute_value|
94
+ next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
95
+
96
+ expected_type = allowed_attributes[attribute_name]
97
+ next unless expected_type
98
+ next if valid_attribute_value?(attribute_value, expected_type)
99
+
100
+ errors << error(
101
+ "Attribute `#{attribute_name}` on <#{node.tag_name}> has invalid value `#{attribute_value}` for type `#{expected_type}`",
102
+ tag_name: node.tag_name
103
+ )
104
+ end
105
+ end
106
+
107
+ def allowed_attributes_for(tag_name)
108
+ component_class = component_class_for_tag(tag_name)
109
+ return {} unless component_class
110
+
111
+ if component_class.respond_to?(:allowed_attributes_for)
112
+ component_class.allowed_attributes_for(tag_name)
113
+ else
114
+ component_class.allowed_attributes
115
+ end
116
+ end
117
+
118
+ def component_class_for_tag(tag_name)
119
+ MjmlRb::Components.constants.filter_map do |name|
120
+ value = MjmlRb::Components.const_get(name)
121
+ value if value.is_a?(Class) && value < MjmlRb::Components::Base
122
+ rescue NameError
123
+ nil
124
+ end.find { |klass| klass.tags.include?(tag_name) }
125
+ end
126
+
127
+ def valid_attribute_value?(value, expected_type)
128
+ return true if value.nil?
129
+
130
+ case expected_type
131
+ when "string"
132
+ true
133
+ when "color"
134
+ color?(value)
135
+ when /\Aenum\((.+)\)\z/
136
+ Regexp.last_match(1).split(",").map(&:strip).include?(value)
137
+ when /\Aunit\((.+)\)(?:\{(\d+),(\d+)\})?\z/
138
+ units = Regexp.last_match(1).split(",").map(&:strip)
139
+ min_count = Regexp.last_match(2)&.to_i || 1
140
+ max_count = Regexp.last_match(3)&.to_i || 1
141
+ unit_values?(value, units, min_count: min_count, max_count: max_count)
142
+ else
143
+ true
144
+ end
145
+ end
146
+
147
+ def color?(value)
148
+ value.match?(/\A#[0-9a-fA-F]{3,8}\z/) ||
149
+ value.match?(/\Argb[a]?\([^)]+\)\z/i) ||
150
+ value.match?(/\Ahsl[a]?\([^)]+\)\z/i) ||
151
+ value.match?(/\A[a-z]+\z/i)
152
+ end
153
+
154
+ def unit_values?(value, units, min_count:, max_count:)
155
+ parts = value.to_s.strip.split(/\s+/)
156
+ return false if parts.empty? || parts.size < min_count || parts.size > max_count
157
+
158
+ parts.all? { |part| unit_value?(part, units) }
159
+ end
160
+
161
+ def unit_value?(value, units)
162
+ return true if value.match?(/\A0(?:\.0+)?\z/)
163
+
164
+ units.any? do |unit|
165
+ value.match?(/\A-?\d+(?:\.\d+)?#{Regexp.escape(unit)}\z/)
166
+ end
167
+ end
168
+
169
+ def allowed_child?(tag_name, allowed_patterns)
170
+ allowed_patterns.any? do |pattern|
171
+ case pattern
172
+ when Regexp then pattern.match?(tag_name)
173
+ else pattern == tag_name
174
+ end
175
+ end
176
+ end
177
+
178
+ def error(message, line: nil, tag_name: nil)
179
+ {
180
+ line: line,
181
+ message: message,
182
+ tag_name: tag_name,
183
+ formatted_message: message
184
+ }
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,3 @@
1
+ module MjmlRb
2
+ VERSION = "0.2.1".freeze
3
+ end
data/lib/mjml-rb.rb ADDED
@@ -0,0 +1,30 @@
1
+ require_relative "mjml-rb/version"
2
+ require_relative "mjml-rb/result"
3
+ require_relative "mjml-rb/ast_node"
4
+ require_relative "mjml-rb/dependencies"
5
+ require_relative "mjml-rb/parser"
6
+ require_relative "mjml-rb/renderer"
7
+ require_relative "mjml-rb/compiler"
8
+ require_relative "mjml-rb/validator"
9
+ require_relative "mjml-rb/migrator"
10
+ require_relative "mjml-rb/cli"
11
+
12
+ module MjmlRb
13
+ # Print an experimental warning once at load time unless suppressed.
14
+ # Suppress by setting the environment variable MJML_RB_NO_WARN=1.
15
+ warn <<~WARNING unless ENV["MJML_RB_NO_WARN"] == "1"
16
+ WARNING: mjml-rb #{VERSION} is EXPERIMENTAL software.
17
+ It is an unofficial Ruby port of MJML, not affiliated with or endorsed by
18
+ the MJML team. Output may differ from the reference npm renderer. Not all
19
+ components are fully implemented. Use in production at your own risk.
20
+ Set MJML_RB_NO_WARN=1 to suppress this message.
21
+ WARNING
22
+
23
+ class << self
24
+ def mjml2html(mjml, options = {})
25
+ Compiler.new(options).compile(mjml).to_h
26
+ end
27
+
28
+ alias to_html mjml2html
29
+ end
30
+ end
data/mjml-rb.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ require_relative "lib/mjml-rb/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "mjml-rb"
5
+ spec.version = MjmlRb::VERSION
6
+ spec.authors = ["Andrei Andriichuk"]
7
+ spec.email = ["andreiandriichuk@gmail.com"]
8
+
9
+ spec.summary = "Ruby implementation of the MJML toolchain"
10
+ spec.description = "Ruby-first MJML compiler API and CLI with compatibility-focused behavior."
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = ">= 3.0"
13
+
14
+ spec.homepage = "https://github.com/faraquet/mjml-rails"
15
+ spec.files = Dir.chdir(__dir__) do
16
+ Dir.glob("{bin,lib}/**/*") + ["Gemfile", "LICENSE", "mjml-rb.gemspec", "README.md"]
17
+ end
18
+ spec.bindir = "bin"
19
+ spec.executables = ["mjml"]
20
+ spec.require_paths = ["lib"]
21
+ spec.add_dependency "nokogiri"
22
+ spec.add_dependency "rexml"
23
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mjml-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrei Andriichuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rexml
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Ruby-first MJML compiler API and CLI with compatibility-focused behavior.
41
+ email:
42
+ - andreiandriichuk@gmail.com
43
+ executables:
44
+ - mjml
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - Gemfile
49
+ - LICENSE
50
+ - README.md
51
+ - bin/mjml
52
+ - lib/mjml-rb.rb
53
+ - lib/mjml-rb/ast_node.rb
54
+ - lib/mjml-rb/cli.rb
55
+ - lib/mjml-rb/compiler.rb
56
+ - lib/mjml-rb/components/accordion.rb
57
+ - lib/mjml-rb/components/base.rb
58
+ - lib/mjml-rb/components/body.rb
59
+ - lib/mjml-rb/components/breakpoint.rb
60
+ - lib/mjml-rb/components/button.rb
61
+ - lib/mjml-rb/components/column.rb
62
+ - lib/mjml-rb/components/divider.rb
63
+ - lib/mjml-rb/components/hero.rb
64
+ - lib/mjml-rb/components/html_attributes.rb
65
+ - lib/mjml-rb/components/image.rb
66
+ - lib/mjml-rb/components/navbar.rb
67
+ - lib/mjml-rb/components/section.rb
68
+ - lib/mjml-rb/components/social.rb
69
+ - lib/mjml-rb/components/spacer.rb
70
+ - lib/mjml-rb/components/table.rb
71
+ - lib/mjml-rb/components/text.rb
72
+ - lib/mjml-rb/dependencies.rb
73
+ - lib/mjml-rb/migrator.rb
74
+ - lib/mjml-rb/parser.rb
75
+ - lib/mjml-rb/renderer.rb
76
+ - lib/mjml-rb/result.rb
77
+ - lib/mjml-rb/validator.rb
78
+ - lib/mjml-rb/version.rb
79
+ - mjml-rb.gemspec
80
+ homepage: https://github.com/faraquet/mjml-rails
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.0'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.3
99
+ specification_version: 4
100
+ summary: Ruby implementation of the MJML toolchain
101
+ test_files: []