mjml-rb 0.2.37 → 0.2.38

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: 022de3642d6473c98ee598b12dd209cb4f6cfb8f6ce50a230768a9e29a6506e3
4
- data.tar.gz: 786a9cad787fb6f3d3a305f5a7dae786387822a55c67d9d58bf489a63f5e4a34
3
+ metadata.gz: 556f39bab241ad23362562a49530aa44547347cdadb66a72f80acdff43533814
4
+ data.tar.gz: f1fd9bde9c1bfde3d041ef67c4b61837e82612fc51a39feaa4fc4c88e75f7464
5
5
  SHA512:
6
- metadata.gz: 30ee8b1a6c75a23db416db7b7f41e0881b8e80c2bca25d097ba622159909e07dd2a0ccb801caff5bb78c3c50b4f3a1bab09c26ff419f51094957e54bddbd5b30
7
- data.tar.gz: 4a1550e5f7d6862abf6c2a3836f3b622b1c21027af1869558e4a8122671d738ba26174ad92ab0bbdf35c651c476240f107b1a27bd4fd61460630bd0e66b7044e
6
+ metadata.gz: 1061b94568afb17dd9734b397871d383890e624fcc44dd430708ce0194d370bad171a08fdba62c4e0ebb14250024b7e6003c071c8ac72d5401e9ec8b4229c28b
7
+ data.tar.gz: 1c7529382d699d2893ed2a99236d03cb58f9fa0792574cd45d13d64a4e7e911ca5c01fbd332ead728de4a54721cc8002293030badd99b1256b07e8da154a6124
data/README.md CHANGED
@@ -12,12 +12,20 @@
12
12
  > feedback, bug reports, test cases, optimizations, proposals, and pull requests.
13
13
  > No warranty of any kind is provided.
14
14
 
15
- This directory contains a Ruby-first implementation of the main MJML user-facing tooling:
15
+ This gem provides a Ruby-first implementation of the main MJML tooling:
16
16
 
17
17
  - library API compatible with `mjml2html`
18
18
  - command-line interface (`mjml`)
19
- - migration and validation commands
20
- - pure Ruby parser + AST + renderer (no external native renderer dependency)
19
+ - validation commands
20
+ - pure Ruby parser, AST, validator, and renderer
21
+ - no Node.js runtime and no shelling out to the official npm renderer
22
+
23
+ ## Compatibility
24
+
25
+ This project targets **MJML v4 only**.
26
+
27
+ - parsing, validation, and rendering are implemented against the MJML v4 document structure
28
+ - component rules and attribute validation follow the MJML v4 model
21
29
 
22
30
  ## Quick start
23
31
 
@@ -31,19 +39,100 @@ bundle exec ruby -Ilib -e 'require "mjml-rb"; puts MjmlRb.mjml2html("<mjml><mj-b
31
39
  ```bash
32
40
  bundle exec bin/mjml example.mjml -o output.html
33
41
  bundle exec bin/mjml --validate example.mjml
34
- bundle exec bin/mjml --migrate old.mjml -s
35
42
  ```
36
43
 
37
- ## Implementation idea
44
+ ## Rails integration
45
+
46
+ In a Rails app, requiring the gem registers an `ActionView` template handler for
47
+ `.mjml` templates through a `Railtie`.
48
+
49
+ Create a view such as `app/views/user_mailer/welcome.html.mjml`:
50
+
51
+ ```mjml
52
+ <mjml>
53
+ <mj-body>
54
+ <mj-section>
55
+ <mj-column>
56
+ <mj-text>Hello from Rails</mj-text>
57
+ </mj-column>
58
+ </mj-section>
59
+ </mj-body>
60
+ </mjml>
61
+ ```
62
+
63
+ Then render it like any other Rails template:
64
+
65
+ ```ruby
66
+ class UserMailer < ApplicationMailer
67
+ def welcome
68
+ mail(to: "user@example.com", subject: "Welcome")
69
+ end
70
+ end
71
+ ```
72
+
73
+ Rails rendering uses strict MJML validation by default. You can override the
74
+ compiler options in your application config:
75
+
76
+ ```ruby
77
+ config.mjml_rb.compiler_options = { validation_level: "soft" }
78
+ ```
79
+
80
+ ## Architecture
81
+
82
+ The compile pipeline is intentionally simple and fully Ruby-based:
83
+
84
+ 1. `MjmlRb.mjml2html` calls `MjmlRb::Compiler`.
85
+ 2. `MjmlRb::Parser` normalizes the source, expands `mj-include`, and builds an `AstNode` tree.
86
+ 3. `MjmlRb::Validator` checks structural rules and supported attributes.
87
+ 4. `MjmlRb::Renderer` resolves head metadata, applies component defaults, and renders HTML.
88
+ 5. `MjmlRb::Compiler` post-processes the output and returns a `Result`.
89
+
90
+ The key architectural idea is that the project uses a small shared AST plus a component registry:
91
+
92
+ - the parser produces generic `AstNode` objects instead of component-specific node types
93
+ - structure rules live in `lib/mjml-rb/dependencies.rb`
94
+ - rendering logic lives in `lib/mjml-rb/components/*`
95
+ - head components populate a shared rendering context
96
+ - body components consume that context and emit the final HTML
97
+
98
+ That split keeps the compiler pipeline predictable:
99
+
100
+ - parsing is responsible for source normalization and include expansion
101
+ - validation is responsible for MJML structure and attribute checks
102
+ - rendering is responsible for HTML generation and responsive email output
103
+
104
+ ## Project structure
105
+
106
+ The main files are organized like this:
107
+
108
+ ```text
109
+ lib/mjml-rb.rb # public gem entry point
110
+ lib/mjml-rb/compiler.rb # orchestration: parse -> validate -> render
111
+ lib/mjml-rb/parser.rb # MJML/XML normalization, includes, AST building
112
+ lib/mjml-rb/ast_node.rb # shared tree representation
113
+ lib/mjml-rb/validator.rb # structural and attribute validation
114
+ lib/mjml-rb/dependencies.rb # allowed parent/child relationships
115
+ lib/mjml-rb/renderer.rb # HTML document assembly and render context
116
+ lib/mjml-rb/components/* # per-component rendering and head handling
117
+ lib/mjml-rb/result.rb # result object returned by the compiler
118
+ lib/mjml-rb/cli.rb # CLI implementation used by bin/mjml
119
+ docs/ARCHITECTURE.md # deeper architecture notes
120
+ docs/PARITY_AUDIT.md # npm vs Ruby parity tracking
121
+ ```
122
+
123
+ If you want the full internal walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
124
+
125
+ ## Implementation goal
38
126
 
39
- > **Zero-dependency pure-Ruby MJML renderer.**
127
+ > **Ruby MJML pipeline without the Node.js renderer.**
40
128
  >
41
129
  > The npm `mjml` package requires Node.js at build time (or runtime via a child
42
130
  > process / FFI bridge). This project replaces that entire pipeline with a single
43
131
  > Ruby library: XML parsing, AST construction, attribute resolution, validation,
44
- > and HTML rendering — all in Ruby, with no native extensions and no Node.js
45
- > dependency. Drop it into a Rails, Sinatra, or plain Ruby project and render
46
- > MJML templates the same way you render ERB — no extra runtime, no
132
+ > and HTML rendering — all in Ruby, with no Node.js runtime and no need to
133
+ > shell out to the official MJML renderer. Drop it into a Rails, Sinatra, or
134
+ > plain Ruby project and render MJML templates the same way you render ERB — no
135
+ > extra runtime, no
47
136
  > `package.json`, no `node_modules`.
48
137
 
49
- Remaining parity work is tracked in [npm ↔ Ruby Parity Audit](/docs/PARITY_AUDIT.md).
138
+ Remaining parity work is tracked in [npm ↔ Ruby Parity Audit](docs/PARITY_AUDIT.md).
data/lib/mjml-rb/cli.rb CHANGED
@@ -4,7 +4,6 @@ require "pathname"
4
4
  require "time"
5
5
 
6
6
  require_relative "compiler"
7
- require_relative "migrator"
8
7
  require_relative "version"
9
8
 
10
9
  module MjmlRb
@@ -58,7 +57,6 @@ module MjmlRb
58
57
  def default_options
59
58
  {
60
59
  read: [],
61
- migrate: [],
62
60
  validate: [],
63
61
  watch: [],
64
62
  stdin: false,
@@ -77,7 +75,6 @@ module MjmlRb
77
75
  opts.banner = "Usage: mjml [options] [files]"
78
76
 
79
77
  opts.on("-r", "--read FILES", Array, "Compile MJML files") { |v| options[:read].concat(v) }
80
- opts.on("-m", "--migrate FILES", Array, "Migrate MJML3 files") { |v| options[:migrate].concat(v) }
81
78
  opts.on("-v", "--validate FILES", Array, "Validate MJML files") { |v| options[:validate].concat(v) }
82
79
  opts.on("-w", "--watch FILES", Array, "Watch and compile files when modified") { |v| options[:watch].concat(v) }
83
80
  opts.on("-i", "--stdin", "Read MJML from stdin") { options[:stdin] = true }
@@ -148,7 +145,6 @@ module MjmlRb
148
145
  def resolve_input(options)
149
146
  inputs = {}
150
147
  inputs[:read] = options[:read] unless options[:read].empty?
151
- inputs[:migrate] = options[:migrate] unless options[:migrate].empty?
152
148
  inputs[:validate] = options[:validate] unless options[:validate].empty?
153
149
  inputs[:watch] = options[:watch] unless options[:watch].empty?
154
150
  inputs[:stdin] = true if options[:stdin]
@@ -183,9 +179,6 @@ module MjmlRb
183
179
 
184
180
  def process_input(input, mode, config)
185
181
  case mode
186
- when :migrate
187
- html = Migrator.new.migrate(input[:mjml])
188
- { file: input[:file], compiled: Result.new(html: html) }
189
182
  when :validate
190
183
  compiler = Compiler.new(config.merge(validation_level: "strict"))
191
184
  { file: input[:file], compiled: compiler.compile(input[:mjml]) }
@@ -61,7 +61,7 @@ module MjmlRb
61
61
  "font-size" => "unit(px)",
62
62
  "font-family" => "string",
63
63
  "font-weight" => "string",
64
- "letter-spacing" => "unit(px,em)",
64
+ "letter-spacing" => "unitWithNegative(px,em)",
65
65
  "line-height" => "unit(px,%,)",
66
66
  "color" => "color",
67
67
  "padding-bottom" => "unit(px,%)",
@@ -25,7 +25,7 @@ module MjmlRb
25
25
  "name" => "string",
26
26
  "title" => "string",
27
27
  "inner-padding" => "unit(px,%){1,4}",
28
- "letter-spacing" => "unit(px,em)",
28
+ "letter-spacing" => "unitWithNegative(px,em)",
29
29
  "line-height" => "unit(px,%,)",
30
30
  "padding-bottom" => "unit(px,%)",
31
31
  "padding-left" => "unit(px,%)",
@@ -7,7 +7,7 @@ module MjmlRb
7
7
 
8
8
  ALLOWED_ATTRIBUTES = {
9
9
  "mj-style" => {
10
- "inline" => "string"
10
+ "inline" => "enum(inline)"
11
11
  },
12
12
  "mj-font" => {
13
13
  "name" => "string",
@@ -6,7 +6,7 @@ module MjmlRb
6
6
  TAGS = ["mj-hero"].freeze
7
7
 
8
8
  ALLOWED_ATTRIBUTES = {
9
- "mode" => "string",
9
+ "mode" => "enum(fixed-height,fluid-height)",
10
10
  "height" => "unit(px,%)",
11
11
  "background-url" => "string",
12
12
  "background-width" => "unit(px,%)",
@@ -10,7 +10,7 @@ module MjmlRb
10
10
  NAVBAR_ALLOWED_ATTRIBUTES = {
11
11
  "align" => "enum(left,center,right)",
12
12
  "base-url" => "string",
13
- "hamburger" => "string",
13
+ "hamburger" => "enum(hamburger)",
14
14
  "ico-align" => "enum(left,center,right)",
15
15
  "ico-open" => "string",
16
16
  "ico-close" => "string",
@@ -29,7 +29,7 @@ module MjmlRb
29
29
  "padding-right" => "unit(px,%)",
30
30
  "padding-bottom" => "unit(px,%)",
31
31
  "ico-text-decoration" => "string",
32
- "ico-line-height" => "unit(px,%)"
32
+ "ico-line-height" => "unit(px,%,)"
33
33
  }.freeze
34
34
 
35
35
  NAVBAR_LINK_ALLOWED_ATTRIBUTES = {
@@ -42,8 +42,8 @@ module MjmlRb
42
42
  "name" => "string",
43
43
  "target" => "string",
44
44
  "rel" => "string",
45
- "letter-spacing" => "string",
46
- "line-height" => "unit(px,%)",
45
+ "letter-spacing" => "unitWithNegative(px,em)",
46
+ "line-height" => "unit(px,%,)",
47
47
  "padding-bottom" => "unit(px,%)",
48
48
  "padding-left" => "unit(px,%)",
49
49
  "padding-right" => "unit(px,%)",
@@ -8,23 +8,23 @@ module MjmlRb
8
8
  ALLOWED_ATTRIBUTES = {
9
9
  "align" => "enum(left,right,center)",
10
10
  "border" => "string",
11
- "cellpadding" => "string",
12
- "cellspacing" => "string",
11
+ "cellpadding" => "integer",
12
+ "cellspacing" => "integer",
13
13
  "color" => "color",
14
14
  "container-background-color" => "color",
15
15
  "font-family" => "string",
16
- "font-size" => "string",
16
+ "font-size" => "unit(px)",
17
17
  "font-weight" => "string",
18
- "line-height" => "string",
18
+ "line-height" => "unit(px,%,)",
19
19
  "padding" => "unit(px,%){1,4}",
20
20
  "padding-top" => "unit(px,%)",
21
21
  "padding-right" => "unit(px,%)",
22
22
  "padding-bottom" => "unit(px,%)",
23
23
  "padding-left" => "unit(px,%)",
24
- "role" => "string",
25
- "table-layout" => "enum(auto,fixed)",
24
+ "role" => "enum(none,presentation)",
25
+ "table-layout" => "enum(auto,fixed,initial,inherit)",
26
26
  "vertical-align" => "enum(top,bottom,middle)",
27
- "width" => "string"
27
+ "width" => "unit(px,%,auto)"
28
28
  }.freeze
29
29
 
30
30
  DEFAULTS = {
@@ -11,12 +11,12 @@ module MjmlRb
11
11
  "color" => "color",
12
12
  "container-background-color" => "color",
13
13
  "font-family" => "string",
14
- "font-size" => "string",
14
+ "font-size" => "unit(px)",
15
15
  "font-style" => "string",
16
16
  "font-weight" => "string",
17
- "height" => "string",
18
- "letter-spacing" => "string",
19
- "line-height" => "string",
17
+ "height" => "unit(px,%)",
18
+ "letter-spacing" => "unitWithNegative(px,em)",
19
+ "line-height" => "unit(px,%,)",
20
20
  "padding" => "unit(px,%){1,4}",
21
21
  "padding-top" => "unit(px,%)",
22
22
  "padding-right" => "unit(px,%)",
@@ -0,0 +1,16 @@
1
+ require_relative "template_handler"
2
+
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
+
8
+ initializer "mjml_rb.action_view" do |app|
9
+ MjmlRb.rails_compiler_options = app.config.mjml_rb.compiler_options.to_h
10
+
11
+ ActiveSupport.on_load(:action_view) do
12
+ MjmlRb.register_action_view_template_handler!
13
+ end
14
+ end
15
+ end
16
+ end
@@ -209,7 +209,7 @@ module MjmlRb
209
209
 
210
210
  def render_node(node, context, parent:)
211
211
  return escape_html(node.content.to_s) if node.text?
212
- return "" if node.comment?
212
+ return "<!--#{node.content}-->" if node.comment?
213
213
 
214
214
  attrs = resolved_attributes(node, context)
215
215
  if (component = component_for(node.tag_name))
@@ -0,0 +1,17 @@
1
+ module MjmlRb
2
+ class TemplateHandler
3
+ class << self
4
+ def call(template, source = nil)
5
+ <<~RUBY
6
+ mjml_compiler_options = (::MjmlRb.rails_compiler_options || {}).dup
7
+ mjml_result = ::MjmlRb::Compiler.new(mjml_compiler_options).compile(#{template.source.inspect})
8
+ if mjml_result.errors.any?
9
+ raise "MJML compilation failed for #{template.identifier}: \#{mjml_result.errors.map { |error| error[:formatted_message] || error[:message] }.join(', ')}"
10
+ end
11
+ mjml_html = mjml_result.html.to_s
12
+ mjml_html.respond_to?(:html_safe) ? mjml_html.html_safe : mjml_html
13
+ RUBY
14
+ end
15
+ end
16
+ end
17
+ end
@@ -160,8 +160,13 @@ module MjmlRb
160
160
  color?(value)
161
161
  when /\Aenum\((.+)\)\z/
162
162
  Regexp.last_match(1).split(",").map(&:strip).include?(value)
163
+ when /\AunitWithNegative\((.+)\)(?:\{(\d+),(\d+)\})?\z/
164
+ units = Regexp.last_match(1).split(",", -1).map(&:strip)
165
+ min_count = Regexp.last_match(2)&.to_i || 1
166
+ max_count = Regexp.last_match(3)&.to_i || 1
167
+ unit_values?(value, units, min_count: min_count, max_count: max_count)
163
168
  when /\Aunit\((.+)\)(?:\{(\d+),(\d+)\})?\z/
164
- units = Regexp.last_match(1).split(",").map(&:strip)
169
+ units = Regexp.last_match(1).split(",", -1).map(&:strip)
165
170
  min_count = Regexp.last_match(2)&.to_i || 1
166
171
  max_count = Regexp.last_match(3)&.to_i || 1
167
172
  unit_values?(value, units, min_count: min_count, max_count: max_count)
@@ -188,7 +193,11 @@ module MjmlRb
188
193
  return true if value.match?(/\A0(?:\.0+)?\z/)
189
194
 
190
195
  units.any? do |unit|
191
- value == unit || value.match?(/\A-?\d+(?:\.\d+)?#{Regexp.escape(unit)}\z/)
196
+ if unit.empty?
197
+ value.match?(/\A-?\d+(?:\.\d+)?\z/)
198
+ else
199
+ value == unit || value.match?(/\A-?\d+(?:\.\d+)?#{Regexp.escape(unit)}\z/)
200
+ end
192
201
  end
193
202
  end
194
203
 
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.2.37".freeze
2
+ VERSION = "0.2.38".freeze
3
3
  end
data/lib/mjml-rb.rb CHANGED
@@ -6,7 +6,6 @@ require_relative "mjml-rb/parser"
6
6
  require_relative "mjml-rb/renderer"
7
7
  require_relative "mjml-rb/compiler"
8
8
  require_relative "mjml-rb/validator"
9
- require_relative "mjml-rb/migrator"
10
9
  require_relative "mjml-rb/cli"
11
10
 
12
11
  module MjmlRb
@@ -21,6 +20,21 @@ module MjmlRb
21
20
  WARNING
22
21
 
23
22
  class << self
23
+ def rails_compiler_options
24
+ @rails_compiler_options ||= {validation_level: "strict"}
25
+ end
26
+
27
+ def rails_compiler_options=(options)
28
+ @rails_compiler_options = options || {}
29
+ end
30
+
31
+ def register_action_view_template_handler!
32
+ return unless defined?(ActionView::Template)
33
+
34
+ require_relative "mjml-rb/template_handler"
35
+ ActionView::Template.register_template_handler(:mjml, TemplateHandler)
36
+ end
37
+
24
38
  def mjml2html(mjml, options = {})
25
39
  Compiler.new(options).compile(mjml).to_h
26
40
  end
@@ -28,3 +42,9 @@ module MjmlRb
28
42
  alias to_html mjml2html
29
43
  end
30
44
  end
45
+
46
+ MjmlRb.register_action_view_template_handler! if defined?(ActionView::Template)
47
+
48
+ if defined?(Rails::Railtie)
49
+ require_relative "mjml-rb/railtie"
50
+ 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.2.37
4
+ version: 0.2.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -76,10 +76,11 @@ files:
76
76
  - lib/mjml-rb/components/table.rb
77
77
  - lib/mjml-rb/components/text.rb
78
78
  - lib/mjml-rb/dependencies.rb
79
- - lib/mjml-rb/migrator.rb
80
79
  - lib/mjml-rb/parser.rb
80
+ - lib/mjml-rb/railtie.rb
81
81
  - lib/mjml-rb/renderer.rb
82
82
  - lib/mjml-rb/result.rb
83
+ - lib/mjml-rb/template_handler.rb
83
84
  - lib/mjml-rb/validator.rb
84
85
  - lib/mjml-rb/version.rb
85
86
  - mjml-rb.gemspec
@@ -1,18 +0,0 @@
1
- module MjmlRb
2
- class Migrator
3
- TAG_RENAMES = {
4
- "mj-container" => "mj-body"
5
- }.freeze
6
-
7
- def migrate(mjml)
8
- output = mjml.to_s.dup
9
-
10
- TAG_RENAMES.each do |from, to|
11
- output.gsub!("<#{from}", "<#{to}")
12
- output.gsub!("</#{from}>", "</#{to}>")
13
- end
14
-
15
- output
16
- end
17
- end
18
- end