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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8c04515032bc1da0a7fcbbad05d5d2b8bb10f684cb34e3b25920f96e10e63207
4
+ data.tar.gz: 778c683334aecab61498fde20ea06ba55e417c5080fd1776dacf5668f609988e
5
+ SHA512:
6
+ metadata.gz: 3c2b46d74bb5804fd9db901c529aec0fd2a4d9cb9b8b97872ce5d7bef2fc22077cfa1b0471f368ab268b5c3d8b231e0cbc13f5b565da937692689233b98a55ae
7
+ data.tar.gz: 37e1b8ce0fa0134c5cbe41e8977fb3f0722dcbcea69728f2b810a1bf615a81691f27fa55805ccd52f37b23c078d957ed3fbc388f9898a9cad4fd667773fe2781
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrei Andriichuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # MJML Ruby Implementation
2
+
3
+ > **⚠️ EXPERIMENTAL — USE AT YOUR OWN RISK**
4
+ >
5
+ > This is an **unofficial, experimental** Ruby port of the MJML email framework.
6
+ > It is **not affiliated with or endorsed by the MJML team**.
7
+ > The output HTML may differ from the reference `mjml` npm package in subtle ways,
8
+ > and not all components or attributes are fully implemented yet.
9
+ > **Do not use in production without thorough testing of every template against
10
+ > the official npm renderer.** API and output format may change without notice.
11
+ > No warranty of any kind is provided.
12
+
13
+ This directory contains a Ruby-first implementation of the main MJML user-facing tooling:
14
+
15
+ - library API compatible with `mjml2html`
16
+ - command-line interface (`mjml`)
17
+ - migration and validation commands
18
+ - pure Ruby parser + AST + renderer (no external native renderer dependency)
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ bundle install
24
+ bundle exec ruby -Ilib -e 'require "mjml-rb"; puts MjmlRb.mjml2html("<mjml><mj-body><mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section></mj-body></mjml>")[:html]'
25
+ ```
26
+
27
+ ## CLI usage
28
+
29
+ ```bash
30
+ bundle exec bin/mjml example.mjml -o output.html
31
+ bundle exec bin/mjml --validate example.mjml
32
+ bundle exec bin/mjml --migrate old.mjml -s
33
+ ```
34
+
35
+ ## Migration status
36
+
37
+ The table below tracks current JS-to-Ruby migration status for MJML components in this repo.
38
+
39
+ | Component | Status | Notes |
40
+ | --- | --- | --- |
41
+ | `mj-body` | migrated | Ruby component exists and matches the upstream `mj-body` behavior currently vendored in `lib/mjml-rb/components/mjml-body`. |
42
+ | `mj-section` | migrated | Implemented in `section.rb`. |
43
+ | `mj-wrapper` | migrated | Implemented via `section.rb`. |
44
+ | `mj-column` | migrated | Implemented in `column.rb`. |
45
+ | `mj-group` | migrated | Rendered directly by the renderer. |
46
+ | `mj-text` | migrated | Implemented in `text.rb`. |
47
+ | `mj-image` | migrated | Implemented in `image.rb`. |
48
+ | `mj-button` | migrated | Implemented in `button.rb`. |
49
+ | `mj-divider` | migrated | Implemented in `divider.rb`. |
50
+ | `mj-table` | migrated | Implemented in `table.rb`. |
51
+ | `mj-social` | migrated | Implemented in `social.rb`. |
52
+ | `mj-social-element` | migrated | Implemented in `social.rb`. |
53
+ | `mj-accordion` | migrated | Implemented in `accordion.rb`. |
54
+ | `mj-accordion-element` | migrated | Implemented in `accordion.rb`. |
55
+ | `mj-accordion-title` | migrated | Implemented in `accordion.rb`. |
56
+ | `mj-accordion-text` | migrated | Implemented in `accordion.rb`. |
57
+ | `mj-spacer` | migrated | Implemented in `spacer.rb`. |
58
+ | `mj-hero` | migrated | Implemented in `hero.rb` with fixed/fluid modes, inner content wrapper, and Outlook VML background fallback. |
59
+ | `mj-navbar` | migrated | Implemented in `navbar.rb`, including `base-url` propagation and breakpoint-aware hamburger CSS. |
60
+ | `mj-navbar-link` | migrated | Implemented in `navbar.rb` as an ending-tag navbar child component. |
61
+ | `mj-raw` | partial | Supported as passthrough content, but not as a dedicated migrated component. |
62
+ | `mj-head` | partial | Core tags such as `mj-title`, `mj-preview`, `mj-style`, `mj-font`, and `mj-attributes` are supported. |
63
+ | `mj-attributes` | partial | `mj-all`, `mj-class`, and per-tag defaults are supported. |
64
+ | `mj-all` | partial | Supported through `mj-attributes`. |
65
+ | `mj-class` | partial | Supported through `mj-attributes`. |
66
+ | `mj-title` | partial | Supported through head context. |
67
+ | `mj-preview` | partial | Supported through head context. |
68
+ | `mj-style` | partial | Supported, including inline CSS application. |
69
+ | `mj-font` | partial | Supported for font link injection. |
70
+ | `mj-carousel` | not migrated | Declared in dependency rules but no renderer implementation yet. |
71
+ | `mj-carousel-image` | not migrated | Declared in dependency rules but no renderer implementation yet. |
72
+ | `mj-breakpoint` | migrated | Supported in `mj-head` and used to control desktop column media-query widths. |
73
+ | `mj-html-attributes` | migrated | Supported in `mj-head` and applied to the rendered HTML via CSS selectors. |
74
+ | `mj-selector` | migrated | Supported as the selector container for `mj-html-attribute` rules. |
75
+ | `mj-html-attribute` | migrated | Supported for injecting custom HTML attributes into matched rendered nodes. |
76
+
77
+ Remaining top-level migration work is mainly `mj-carousel` and `mj-carousel-image`, plus bringing the remaining renderer-owned partial implementations closer to upstream JS behavior.
78
+
79
+ TODO: `mj-html-attributes` currently uses `Nokogiri` to parse rendered HTML and apply CSS-selector-based attribute injections. Rewrite this path to avoid `Nokogiri` if we want to keep the runtime dependency surface minimal.
data/bin/mjml ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "mjml-rb"
5
+
6
+ exit(MjmlRb::CLI.new.run(ARGV))
@@ -0,0 +1,34 @@
1
+ module MjmlRb
2
+ class AstNode
3
+ attr_reader :tag_name, :attributes, :children, :content, :line
4
+
5
+ def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil)
6
+ @tag_name = tag_name.to_s
7
+ @attributes = attributes.transform_keys(&:to_s)
8
+ @children = Array(children)
9
+ @content = content
10
+ @line = line
11
+ end
12
+
13
+ def text?
14
+ @tag_name == "#text"
15
+ end
16
+
17
+ def comment?
18
+ @tag_name == "#comment"
19
+ end
20
+
21
+ def element?
22
+ !text? && !comment?
23
+ end
24
+
25
+ def text_content
26
+ return @content.to_s if text?
27
+ @children.map(&:text_content).join
28
+ end
29
+
30
+ def element_children
31
+ @children.select(&:element?)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,305 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "pathname"
4
+ require "time"
5
+
6
+ require_relative "compiler"
7
+ require_relative "migrator"
8
+ require_relative "version"
9
+
10
+ module MjmlRb
11
+ class CLI
12
+ class CLIError < StandardError; end
13
+
14
+ def initialize(stdout: $stdout, stderr: $stderr)
15
+ @stdout = stdout
16
+ @stderr = stderr
17
+ end
18
+
19
+ def run(argv = ARGV)
20
+ raw_argv = argv.dup
21
+ argv, inline_config = extract_inline_config(raw_argv)
22
+ options = default_options
23
+
24
+ parser = option_parser(options, inline_config)
25
+ parser.parse!(argv)
26
+ options[:positional] = argv
27
+
28
+ input_mode, input_values = resolve_input(options)
29
+ output_mode = resolve_output(options)
30
+ config = options[:config]
31
+
32
+ case input_mode
33
+ when :watch
34
+ watch_files(input_values, config, options[:output])
35
+ 0
36
+ when :stdin
37
+ mjml = $stdin.read
38
+ processed = process_input({ file: nil, mjml: mjml }, input_mode, config)
39
+ emit_results([processed], output_mode, options)
40
+ else
41
+ inputs = load_inputs(input_values)
42
+ raise CLIError, "No input files found" if inputs.empty?
43
+
44
+ processed = inputs.map { |input| process_input(input, input_mode, config) }
45
+ emit_results(processed, output_mode, options)
46
+ end
47
+ rescue CLIError => e
48
+ @stderr.puts("\nCommand line error:")
49
+ @stderr.puts(e.message)
50
+ 1
51
+ rescue OptionParser::ParseError => e
52
+ @stderr.puts(e.message)
53
+ 1
54
+ end
55
+
56
+ private
57
+
58
+ def default_options
59
+ {
60
+ read: [],
61
+ migrate: [],
62
+ validate: [],
63
+ watch: [],
64
+ stdin: false,
65
+ stdout: false,
66
+ output: nil,
67
+ no_stdout_file_comment: false,
68
+ config: {
69
+ beautify: true,
70
+ minify: false
71
+ }
72
+ }
73
+ end
74
+
75
+ def option_parser(options, inline_config)
76
+ OptionParser.new do |opts|
77
+ opts.banner = "Usage: mjml [options] [files]"
78
+
79
+ 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
+ opts.on("-v", "--validate FILES", Array, "Validate MJML files") { |v| options[:validate].concat(v) }
82
+ opts.on("-w", "--watch FILES", Array, "Watch and compile files when modified") { |v| options[:watch].concat(v) }
83
+ opts.on("-i", "--stdin", "Read MJML from stdin") { options[:stdin] = true }
84
+ opts.on("-s", "--stdout", "Output HTML to stdout") { options[:stdout] = true }
85
+ opts.on("-o", "--output PATH", "Output file or directory") { |v| options[:output] = v }
86
+ opts.on("-c", "--config KEY=VALUE", "Compiler config key/value") do |entry|
87
+ key, value = parse_key_value(entry)
88
+ options[:config][normalize_key(key)] = parse_typed_value(value)
89
+ end
90
+ opts.on("--noStdoutFileComment", "Skip file comment header on stdout") do
91
+ options[:no_stdout_file_comment] = true
92
+ end
93
+ opts.on("-V", "--version", "Show version") do
94
+ @stdout.puts("mjml-ruby #{MjmlRb::VERSION}")
95
+ raise SystemExit, 0
96
+ end
97
+ opts.on("-h", "--help", "Show help") do
98
+ @stdout.puts(opts)
99
+ raise SystemExit, 0
100
+ end
101
+ end.tap do
102
+ inline_config.each do |key, value|
103
+ options[:config][normalize_key(key)] = value
104
+ end
105
+ end
106
+ end
107
+
108
+ def extract_inline_config(argv)
109
+ config = {}
110
+ remaining = []
111
+
112
+ argv.each do |arg|
113
+ if arg.start_with?("--config.")
114
+ key, value = parse_key_value(arg.sub("--config.", ""))
115
+ config[key] = parse_typed_value(value)
116
+ else
117
+ remaining << arg
118
+ end
119
+ end
120
+
121
+ [remaining, config]
122
+ end
123
+
124
+ def parse_key_value(input)
125
+ key, value = input.split("=", 2)
126
+ raise CLIError, "Invalid config entry: #{input}" if key.to_s.empty?
127
+ [key, value]
128
+ end
129
+
130
+ def parse_typed_value(value)
131
+ return true if value.nil?
132
+ return false if value == "false"
133
+ return true if value == "true"
134
+ return value.to_i if value.match?(/\A-?\d+\z/)
135
+ return value.to_f if value.match?(/\A-?\d+\.\d+\z/)
136
+
137
+ begin
138
+ JSON.parse(value)
139
+ rescue JSON::ParserError
140
+ value
141
+ end
142
+ end
143
+
144
+ def normalize_key(key)
145
+ key.to_s.tr("-", "_").to_sym
146
+ end
147
+
148
+ def resolve_input(options)
149
+ inputs = {}
150
+ inputs[:read] = options[:read] unless options[:read].empty?
151
+ inputs[:migrate] = options[:migrate] unless options[:migrate].empty?
152
+ inputs[:validate] = options[:validate] unless options[:validate].empty?
153
+ inputs[:watch] = options[:watch] unless options[:watch].empty?
154
+ inputs[:stdin] = true if options[:stdin]
155
+ inputs[:read] = options[:positional] unless options[:positional].empty?
156
+
157
+ raise CLIError, "No input argument received" if inputs.empty?
158
+ raise CLIError, "Too many input arguments received" if inputs.keys.size > 1
159
+
160
+ key = inputs.keys.first
161
+ [key, inputs[key]]
162
+ end
163
+
164
+ def resolve_output(options)
165
+ outputs = []
166
+ outputs << :output if !options[:output].nil?
167
+ outputs << :stdout if options[:stdout]
168
+
169
+ raise CLIError, "Too many output arguments received" if outputs.size > 1
170
+ outputs.first || :stdout
171
+ end
172
+
173
+ def load_inputs(patterns)
174
+ expand_paths(Array(patterns)).map { |path| { file: path, mjml: File.read(path) } }
175
+ rescue Errno::ENOENT => e
176
+ raise CLIError, "Cannot read file: #{e.message}"
177
+ end
178
+
179
+ def expand_paths(patterns)
180
+ paths = patterns.flat_map { |pattern| Dir.glob(pattern, File::FNM_EXTGLOB) }
181
+ paths.uniq.select { |path| File.file?(path) }
182
+ end
183
+
184
+ def process_input(input, mode, config)
185
+ case mode
186
+ when :migrate
187
+ html = Migrator.new.migrate(input[:mjml])
188
+ { file: input[:file], compiled: Result.new(html: html) }
189
+ when :validate
190
+ compiler = Compiler.new(config.merge(validation_level: "strict"))
191
+ { file: input[:file], compiled: compiler.compile(input[:mjml]) }
192
+ else
193
+ compiler = Compiler.new(config)
194
+ { file: input[:file], compiled: compiler.compile(input[:mjml]) }
195
+ end
196
+ end
197
+
198
+ def emit_results(processed, output_mode, options)
199
+ processed.each { |item| emit_errors(item[:compiled], item[:file]) }
200
+
201
+ invalid = processed.any? { |item| !item[:compiled].errors.empty? }
202
+ if options[:validate].any? && invalid
203
+ raise CLIError, "Validation failed"
204
+ end
205
+
206
+ case output_mode
207
+ when :stdout
208
+ print_stdout(processed, !options[:no_stdout_file_comment])
209
+ when :output
210
+ write_outputs(processed, options[:output])
211
+ else
212
+ raise CLIError, "Command line error: No output option available"
213
+ end
214
+
215
+ invalid ? 2 : 0
216
+ end
217
+
218
+ def emit_errors(result, file)
219
+ return if result.errors.empty?
220
+ result.errors.each do |error|
221
+ prefix = file ? "File: #{file}\n" : ""
222
+ @stderr.puts("#{prefix}#{error[:formatted_message] || error[:message]}")
223
+ end
224
+ end
225
+
226
+ def print_stdout(processed, add_file_comment)
227
+ processed.each do |entry|
228
+ output = +""
229
+ output << "<!-- FILE: #{entry[:file]} -->\n" if add_file_comment
230
+ output << entry[:compiled].html
231
+ output << "\n"
232
+ @stdout.write(output)
233
+ end
234
+ end
235
+
236
+ def write_outputs(processed, output_path)
237
+ if processed.size > 1 && !directory?(output_path) && output_path != ""
238
+ raise CLIError, "Multiple input files require an existing output directory or empty output path"
239
+ end
240
+
241
+ processed.each do |entry|
242
+ output_name = guess_output_name(entry[:file], output_path)
243
+ dir = File.dirname(output_name)
244
+ raise CLIError, "Output directory does not exist for path: #{output_name}" unless directory?(dir)
245
+
246
+ File.write(output_name, entry[:compiled].html)
247
+ end
248
+ end
249
+
250
+ def guess_output_name(input_file, output_path)
251
+ return replace_extension(File.basename(input_file)) if output_path.to_s.empty?
252
+ return File.join(output_path, replace_extension(File.basename(input_file))) if directory?(output_path)
253
+
254
+ output_path
255
+ end
256
+
257
+ def replace_extension(path)
258
+ return path.sub(/\.mjml\z/, ".html") if path.end_with?(".mjml")
259
+ return path if path.match?(/\.[^\/]+\z/)
260
+
261
+ "#{path}.html"
262
+ end
263
+
264
+ def directory?(path)
265
+ File.directory?(File.expand_path(path.to_s))
266
+ rescue StandardError
267
+ false
268
+ end
269
+
270
+ def watch_files(patterns, config, output_path)
271
+ files = expand_paths(Array(patterns))
272
+ raise CLIError, "No input files found" if files.empty?
273
+ if files.size > 1 && !directory?(output_path) && output_path != ""
274
+ raise CLIError, "Need an output directory when watching multiple files"
275
+ end
276
+
277
+ state = files.each_with_object({}) { |path, memo| memo[path] = file_mtime(path) }
278
+ @stdout.puts("Watching #{files.size} file(s)...")
279
+
280
+ loop do
281
+ files.each do |file|
282
+ current = file_mtime(file)
283
+ next if current.nil? || current <= state[file]
284
+
285
+ state[file] = current
286
+ begin
287
+ input = { file: file, mjml: File.read(file) }
288
+ processed = process_input(input, :read, config)
289
+ emit_results([processed], :output, output: output_path, no_stdout_file_comment: true, validate: [])
290
+ @stdout.puts("#{file} - Successfully compiled")
291
+ rescue StandardError => e
292
+ @stderr.puts("#{file} - Error while compiling file: #{e.message}")
293
+ end
294
+ end
295
+ sleep 0.5
296
+ end
297
+ end
298
+
299
+ def file_mtime(path)
300
+ File.mtime(path)
301
+ rescue StandardError
302
+ nil
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,109 @@
1
+ require_relative "result"
2
+ require_relative "validator"
3
+ require_relative "parser"
4
+ require_relative "renderer"
5
+
6
+ module MjmlRb
7
+ class Compiler
8
+ DEFAULT_OPTIONS = {
9
+ beautify: false,
10
+ minify: false,
11
+ keep_comments: true,
12
+ ignore_includes: false,
13
+ preprocessors: [],
14
+ validation_level: "soft",
15
+ file_path: ".",
16
+ actual_path: "."
17
+ }.freeze
18
+
19
+ def initialize(options = {})
20
+ @options = DEFAULT_OPTIONS.merge(symbolize_keys(options))
21
+ @parser = Parser.new
22
+ @validator = Validator.new(parser: @parser)
23
+ @renderer = Renderer.new
24
+ end
25
+
26
+ def mjml2html(mjml, options = {})
27
+ compile(mjml, options)
28
+ end
29
+
30
+ def compile(mjml, options = {})
31
+ merged = @options.merge(symbolize_keys(options))
32
+ ast = @parser.parse(
33
+ mjml,
34
+ keep_comments: merged[:keep_comments],
35
+ preprocessors: Array(merged[:preprocessors]),
36
+ ignore_includes: merged[:ignore_includes],
37
+ file_path: merged[:file_path],
38
+ actual_path: merged[:actual_path]
39
+ )
40
+
41
+ validation_errors = validate_if_needed(ast, merged)
42
+ return Result.new(errors: validation_errors) if strict_validation_failed?(merged, validation_errors)
43
+
44
+ html = @renderer.render(ast, merged)
45
+ result = Result.new(
46
+ html: post_process(html, merged),
47
+ errors: validation_errors,
48
+ warnings: []
49
+ )
50
+ result
51
+ rescue Parser::ParseError => e
52
+ Result.new(errors: [format_error(e.message, line: e.line)])
53
+ rescue StandardError => e
54
+ Result.new(errors: [format_error(e.message)])
55
+ end
56
+
57
+ private
58
+
59
+ def validate_if_needed(ast, options)
60
+ return [] if options[:validation_level].to_s == "skip"
61
+ @validator.validate(ast, options)
62
+ end
63
+
64
+ def strict_validation_failed?(options, validation_errors)
65
+ options[:validation_level].to_s == "strict" && !validation_errors.empty?
66
+ end
67
+
68
+ def post_process(html, options)
69
+ output = html.to_s.dup
70
+ output = strip_comments(output) unless options[:keep_comments]
71
+ output = beautify(output) if truthy?(options[:beautify])
72
+ output = minify(output) if truthy?(options[:minify])
73
+ output
74
+ end
75
+
76
+ def strip_comments(html)
77
+ html.gsub(/<!--.*?-->/m, "")
78
+ end
79
+
80
+ def beautify(html)
81
+ # Keep it deterministic without external formatters.
82
+ html.gsub(/>\s*</, ">\n<")
83
+ end
84
+
85
+ def minify(html)
86
+ html.gsub(/>\s+</, "><").gsub(/\s{2,}/, " ").strip
87
+ end
88
+
89
+ def truthy?(value)
90
+ return value if value == true || value == false
91
+ !(%w[false 0 no off].include?(value.to_s.strip.downcase))
92
+ end
93
+
94
+ def symbolize_keys(hash)
95
+ hash.each_with_object({}) do |(k, v), memo|
96
+ memo[k.to_s.tr("-", "_").to_sym] = v
97
+ end
98
+ end
99
+
100
+ def format_error(message, line: nil)
101
+ {
102
+ line: line,
103
+ message: message.to_s,
104
+ tag_name: nil,
105
+ formatted_message: message.to_s
106
+ }
107
+ end
108
+ end
109
+ end