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 +7 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/bin/mjml +6 -0
- data/lib/mjml-rb/ast_node.rb +34 -0
- data/lib/mjml-rb/cli.rb +305 -0
- data/lib/mjml-rb/compiler.rb +109 -0
- data/lib/mjml-rb/components/accordion.rb +210 -0
- data/lib/mjml-rb/components/base.rb +76 -0
- data/lib/mjml-rb/components/body.rb +46 -0
- data/lib/mjml-rb/components/breakpoint.rb +25 -0
- data/lib/mjml-rb/components/button.rb +157 -0
- data/lib/mjml-rb/components/column.rb +241 -0
- data/lib/mjml-rb/components/divider.rb +120 -0
- data/lib/mjml-rb/components/hero.rb +285 -0
- data/lib/mjml-rb/components/html_attributes.rb +32 -0
- data/lib/mjml-rb/components/image.rb +183 -0
- data/lib/mjml-rb/components/navbar.rb +279 -0
- data/lib/mjml-rb/components/section.rb +331 -0
- data/lib/mjml-rb/components/social.rb +303 -0
- data/lib/mjml-rb/components/spacer.rb +63 -0
- data/lib/mjml-rb/components/table.rb +162 -0
- data/lib/mjml-rb/components/text.rb +71 -0
- data/lib/mjml-rb/dependencies.rb +67 -0
- data/lib/mjml-rb/migrator.rb +18 -0
- data/lib/mjml-rb/parser.rb +169 -0
- data/lib/mjml-rb/renderer.rb +513 -0
- data/lib/mjml-rb/result.rb +23 -0
- data/lib/mjml-rb/validator.rb +187 -0
- data/lib/mjml-rb/version.rb +3 -0
- data/lib/mjml-rb.rb +30 -0
- data/mjml-rb.gemspec +23 -0
- metadata +101 -0
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
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,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
|
data/lib/mjml-rb/cli.rb
ADDED
|
@@ -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
|