mjml-rb 0.4.2 → 0.4.4
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 +4 -4
- data/README.md +55 -155
- data/lib/mjml-rb/components/accordion.rb +4 -4
- data/lib/mjml-rb/components/base.rb +4 -0
- data/lib/mjml-rb/components/button.rb +1 -1
- data/lib/mjml-rb/components/navbar.rb +1 -1
- data/lib/mjml-rb/components/raw.rb +1 -1
- data/lib/mjml-rb/components/section.rb +1 -1
- data/lib/mjml-rb/components/text.rb +1 -1
- data/lib/mjml-rb/parser.rb +116 -9
- data/lib/mjml-rb/renderer.rb +112 -61
- data/lib/mjml-rb/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 750fc042cf9ff6273cebd5907a096e6fc304d28a49b0872cf6eec93f9818e148
|
|
4
|
+
data.tar.gz: 138a44a38b430267f646cd771b4a9fa01e3c0e9609083291de5270280db36650
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d83da073643a75183303c6a14a6b0cbd2e06d864875c80a4bbfc65ef5c857b5a4ffb2971a0465cc58e46b400efe8a1d760994f643a420da0d549837799f8bf5
|
|
7
|
+
data.tar.gz: 1bd7d0c899cb6de244ab534d0b598b12f437c53ef556cb7fd5c56880ec4d6fc164dedb493ad49e45e60b52e5bd9180dd725b7f91790eb2952440df5d5219bb3a
|
data/README.md
CHANGED
|
@@ -11,198 +11,98 @@
|
|
|
11
11
|
> This is a **fully open source project** — feedback, bug reports, test cases,
|
|
12
12
|
> and pull requests are welcome!
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
A pure-Ruby MJML v4 compiler — no Node.js required.
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
16
|
+
- Library API compatible with `mjml2html`
|
|
17
|
+
- Command-line interface (`mjml`)
|
|
18
|
+
- Rails integration (ActionView template handler for `.mjml` views)
|
|
19
|
+
- Validation (soft, strict, skip)
|
|
20
|
+
- Custom component support
|
|
21
|
+
- Pure Ruby parser, AST, validator, and renderer
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Compatibility
|
|
25
|
-
|
|
26
|
-
This project targets **MJML v4 only**.
|
|
23
|
+
**[Full Usage Guide](docs/USAGE.md)** — API reference, all compiler options, component attribute tables, CLI flags, Rails setup, custom components, and more.
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
- component rules and attribute validation follow the MJML v4 model
|
|
25
|
+
## Installation
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
bundle install
|
|
35
|
-
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]'
|
|
27
|
+
```ruby
|
|
28
|
+
# Gemfile
|
|
29
|
+
gem "mjml-rb"
|
|
36
30
|
```
|
|
37
31
|
|
|
38
|
-
##
|
|
32
|
+
## Quick Start
|
|
39
33
|
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
```ruby
|
|
35
|
+
require "mjml-rb"
|
|
36
|
+
|
|
37
|
+
result = MjmlRb.mjml2html(<<~MJML)
|
|
38
|
+
<mjml>
|
|
39
|
+
<mj-body>
|
|
40
|
+
<mj-section>
|
|
41
|
+
<mj-column>
|
|
42
|
+
<mj-text>Hello World!</mj-text>
|
|
43
|
+
</mj-column>
|
|
44
|
+
</mj-section>
|
|
45
|
+
</mj-body>
|
|
46
|
+
</mjml>
|
|
47
|
+
MJML
|
|
48
|
+
|
|
49
|
+
puts result[:html] # compiled HTML
|
|
50
|
+
puts result[:errors] # validation errors (if any)
|
|
43
51
|
```
|
|
44
52
|
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
In a Rails app, requiring the gem registers an `ActionView` template handler for
|
|
48
|
-
`.mjml` templates through a `Railtie`.
|
|
49
|
-
|
|
50
|
-
By default, `.mjml` files are treated as raw MJML/XML source.
|
|
53
|
+
## CLI
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
```bash
|
|
56
|
+
mjml email.mjml -o email.html # compile to file
|
|
57
|
+
mjml -r "templates/*.mjml" -o output/ # batch compile
|
|
58
|
+
mjml -v email.mjml # validate only
|
|
59
|
+
mjml -i -s < email.mjml # stdin → stdout
|
|
56
60
|
```
|
|
57
61
|
|
|
58
|
-
|
|
62
|
+
See the [Usage Guide — CLI section](docs/USAGE.md#cli) for all flags and config options.
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
through that template engine first, so partials and embedded Ruby can assemble
|
|
62
|
-
MJML before the outer template is compiled to HTML. Without that setting,
|
|
63
|
-
non-XML MJML source is rejected instead of being guessed.
|
|
64
|
+
## Rails
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
registered in `ActionView` by the corresponding gem or integration layer.
|
|
66
|
+
Add the gem to your Gemfile — that's it. The `.mjml` template handler is registered automatically.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```mjml
|
|
68
|
+
```erb
|
|
69
|
+
<!-- app/views/user_mailer/welcome.html.mjml -->
|
|
71
70
|
<mjml>
|
|
72
71
|
<mj-body>
|
|
73
72
|
<mj-section>
|
|
74
73
|
<mj-column>
|
|
75
|
-
<mj-text>
|
|
74
|
+
<mj-text>Welcome, <%= @user.name %>!</mj-text>
|
|
76
75
|
</mj-column>
|
|
77
76
|
</mj-section>
|
|
78
77
|
</mj-body>
|
|
79
78
|
</mjml>
|
|
80
79
|
```
|
|
81
80
|
|
|
82
|
-
Then render it like any other Rails template:
|
|
83
|
-
|
|
84
81
|
```ruby
|
|
85
82
|
class UserMailer < ApplicationMailer
|
|
86
|
-
def welcome
|
|
87
|
-
|
|
83
|
+
def welcome(user)
|
|
84
|
+
@user = user
|
|
85
|
+
mail(to: user.email, subject: "Welcome")
|
|
88
86
|
end
|
|
89
87
|
end
|
|
90
88
|
```
|
|
91
89
|
|
|
92
|
-
|
|
93
|
-
compiler options in your application config:
|
|
94
|
-
|
|
95
|
-
```ruby
|
|
96
|
-
config.mjml_rb.compiler_options = { validation_level: "soft" }
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
## Custom components
|
|
90
|
+
Supports Slim and Haml via `config.mjml_rb.rails_template_language = :slim`. See the [Usage Guide — Rails section](docs/USAGE.md#rails-integration) for full configuration.
|
|
100
91
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```ruby
|
|
104
|
-
class MjRating < MjmlRb::Components::Base
|
|
105
|
-
TAGS = ["mj-rating"].freeze
|
|
106
|
-
ALLOWED_ATTRIBUTES = { "stars" => "integer", "color" => "color" }.freeze
|
|
107
|
-
DEFAULT_ATTRIBUTES = { "stars" => "5", "color" => "#f4b400" }.freeze
|
|
108
|
-
|
|
109
|
-
def render(tag_name:, node:, context:, attrs:, parent:)
|
|
110
|
-
stars = (attrs["stars"] || "5").to_i
|
|
111
|
-
color = attrs["color"] || "#f4b400"
|
|
112
|
-
%(<div style="color:#{escape_attr(color)}">#{"\u2605" * stars}</div>)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
MjmlRb.register_component(MjRating,
|
|
117
|
-
dependencies: { "mj-column" => ["mj-rating"] },
|
|
118
|
-
ending_tags: ["mj-rating"]
|
|
119
|
-
)
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
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.
|
|
123
|
-
|
|
124
|
-
Once registered, the component works in MJML markup and is validated like any built-in component.
|
|
125
|
-
|
|
126
|
-
## `.mjmlrc` config file
|
|
127
|
-
|
|
128
|
-
Place a `.mjmlrc` file (JSON) in your project root to auto-register custom components and set default compiler options:
|
|
92
|
+
## Architecture
|
|
129
93
|
|
|
130
|
-
```json
|
|
131
|
-
{
|
|
132
|
-
"packages": [
|
|
133
|
-
"./lib/mjml_components/mj_rating.rb"
|
|
134
|
-
],
|
|
135
|
-
"options": {
|
|
136
|
-
"beautify": true,
|
|
137
|
-
"validation-level": "soft"
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
94
|
```
|
|
141
|
-
|
|
142
|
-
- **`packages`** — Ruby files to `require`. Each file should call `MjmlRb.register_component` to register its components.
|
|
143
|
-
- **`options`** — Default compiler options. CLI flags and programmatic options override these.
|
|
144
|
-
|
|
145
|
-
The CLI loads `.mjmlrc` automatically from the working directory. For the library API, load it explicitly:
|
|
146
|
-
|
|
147
|
-
```ruby
|
|
148
|
-
MjmlRb::ConfigFile.load("/path/to/project")
|
|
149
|
-
result = MjmlRb.mjml2html(mjml_string)
|
|
95
|
+
MJML string → Parser → AST → Validator → Renderer → HTML
|
|
150
96
|
```
|
|
151
97
|
|
|
152
|
-
|
|
98
|
+
1. **Parser** — normalizes source, expands `mj-include`, builds `AstNode` tree
|
|
99
|
+
2. **Validator** — checks structure, hierarchy, and attribute types
|
|
100
|
+
3. **Renderer** — resolves head metadata, applies defaults, emits responsive HTML
|
|
153
101
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
1. `MjmlRb.mjml2html` calls `MjmlRb::Compiler`.
|
|
157
|
-
2. `MjmlRb::Parser` normalizes the source, expands `mj-include`, and builds an `AstNode` tree.
|
|
158
|
-
3. `MjmlRb::Validator` checks structural rules and supported attributes.
|
|
159
|
-
4. `MjmlRb::Renderer` resolves head metadata, applies component defaults, and renders HTML.
|
|
160
|
-
5. `MjmlRb::Compiler` post-processes the output and returns a `Result`.
|
|
161
|
-
|
|
162
|
-
The key architectural idea is that the project uses a small shared AST plus a component registry:
|
|
163
|
-
|
|
164
|
-
- the parser produces generic `AstNode` objects instead of component-specific node types
|
|
165
|
-
- structure rules live in `lib/mjml-rb/dependencies.rb`
|
|
166
|
-
- rendering logic lives in `lib/mjml-rb/components/*`
|
|
167
|
-
- head components populate a shared rendering context
|
|
168
|
-
- body components consume that context and emit the final HTML
|
|
169
|
-
|
|
170
|
-
That split keeps the compiler pipeline predictable:
|
|
171
|
-
|
|
172
|
-
- parsing is responsible for source normalization and include expansion
|
|
173
|
-
- validation is responsible for MJML structure and attribute checks
|
|
174
|
-
- rendering is responsible for HTML generation and responsive email output
|
|
175
|
-
|
|
176
|
-
## Project structure
|
|
177
|
-
|
|
178
|
-
The main files are organized like this:
|
|
179
|
-
|
|
180
|
-
```text
|
|
181
|
-
lib/mjml-rb.rb # public gem entry point
|
|
182
|
-
lib/mjml-rb/compiler.rb # orchestration: parse -> validate -> render
|
|
183
|
-
lib/mjml-rb/parser.rb # MJML/XML normalization, includes, AST building
|
|
184
|
-
lib/mjml-rb/ast_node.rb # shared tree representation
|
|
185
|
-
lib/mjml-rb/validator.rb # structural and attribute validation
|
|
186
|
-
lib/mjml-rb/dependencies.rb # allowed parent/child relationships
|
|
187
|
-
lib/mjml-rb/renderer.rb # HTML document assembly and render context
|
|
188
|
-
lib/mjml-rb/components/* # per-component rendering and head handling
|
|
189
|
-
lib/mjml-rb/result.rb # result object returned by the compiler
|
|
190
|
-
lib/mjml-rb/cli.rb # CLI implementation used by bin/mjml
|
|
191
|
-
docs/ARCHITECTURE.md # deeper architecture notes
|
|
192
|
-
docs/PARITY_AUDIT.md # npm vs Ruby parity tracking
|
|
193
|
-
```
|
|
102
|
+
For the full internal walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
194
103
|
|
|
195
|
-
|
|
104
|
+
Remaining parity work is tracked in [npm ↔ Ruby Parity Audit](docs/PARITY_AUDIT.md).
|
|
196
105
|
|
|
197
|
-
##
|
|
106
|
+
## License
|
|
198
107
|
|
|
199
|
-
|
|
200
|
-
>
|
|
201
|
-
> The npm `mjml` package requires Node.js at build time (or runtime via a child
|
|
202
|
-
> process / FFI bridge). This project replaces that entire pipeline with a single
|
|
203
|
-
> Ruby library: XML parsing, AST construction, attribute resolution, validation,
|
|
204
|
-
> and HTML rendering — all in Ruby, with no Node.js runtime and no need to
|
|
205
|
-
> shell out to the official MJML renderer. Drop it into a Rails, Sinatra, or
|
|
206
|
-
> plain Ruby project and render MJML templates the same way you render ERB — no
|
|
207
|
-
> extra runtime, no
|
|
208
|
-
> `package.json`, no `node_modules`.
|
|
108
|
+
MIT
|
|
@@ -173,7 +173,7 @@ module MjmlRb
|
|
|
173
173
|
when "mj-accordion-element"
|
|
174
174
|
render_accordion_element(child, context, accordion_attrs)
|
|
175
175
|
when "mj-raw"
|
|
176
|
-
|
|
176
|
+
raw_inner_for_body(child)
|
|
177
177
|
else
|
|
178
178
|
render_node(child, context, parent: "mj-accordion")
|
|
179
179
|
end
|
|
@@ -210,7 +210,7 @@ module MjmlRb
|
|
|
210
210
|
child_attrs = attrs.merge(resolved_attributes(child, context))
|
|
211
211
|
content << render_accordion_text(child, child_attrs)
|
|
212
212
|
when "mj-raw"
|
|
213
|
-
content <<
|
|
213
|
+
content << raw_inner_for_body(child)
|
|
214
214
|
end
|
|
215
215
|
end
|
|
216
216
|
end
|
|
@@ -249,7 +249,7 @@ module MjmlRb
|
|
|
249
249
|
"width" => "100%",
|
|
250
250
|
"border-bottom" => title_attrs["border"] || DEFAULTS["border"]
|
|
251
251
|
)
|
|
252
|
-
title_content = node ?
|
|
252
|
+
title_content = node ? raw_inner_for_body(node) : ""
|
|
253
253
|
title_cell = %(<td style="#{td_style}">#{title_content}</td>)
|
|
254
254
|
icon_cell = %(<td class="mj-accordion-ico" style="#{td2_style}"><img src="#{escape_attr(title_attrs["icon-wrapped-url"] || DEFAULTS["icon-wrapped-url"])}" alt="#{escape_attr(title_attrs["icon-wrapped-alt"] || DEFAULTS["icon-wrapped-alt"])}" class="mj-accordion-more" style="#{icon_style}" /><img src="#{escape_attr(title_attrs["icon-unwrapped-url"] || DEFAULTS["icon-unwrapped-url"])}" alt="#{escape_attr(title_attrs["icon-unwrapped-alt"] || DEFAULTS["icon-unwrapped-alt"])}" class="mj-accordion-less" style="#{icon_style}" /></td>)
|
|
255
255
|
cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
|
|
@@ -277,7 +277,7 @@ module MjmlRb
|
|
|
277
277
|
"width" => "100%",
|
|
278
278
|
"border-bottom" => text_attrs["border"] || DEFAULTS["border"]
|
|
279
279
|
)
|
|
280
|
-
content = node ?
|
|
280
|
+
content = node ? raw_inner_for_body(node) : ""
|
|
281
281
|
css_class = text_attrs["css-class"] ? %( class="#{escape_attr(text_attrs["css-class"])}") : ""
|
|
282
282
|
|
|
283
283
|
%(<div class="mj-accordion-content"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr><td#{css_class} style="#{td_style}">#{content}</td></tr></tbody></table></div>)
|
|
@@ -50,6 +50,10 @@ module MjmlRb
|
|
|
50
50
|
renderer.send(:raw_inner, node)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def raw_inner_for_body(node)
|
|
54
|
+
renderer.send(:annotate_raw_html, raw_inner(node))
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
# Like raw_inner but HTML-escapes text nodes. Use for components such as
|
|
54
58
|
# mj-text where the inner content is treated as HTML but bare text must
|
|
55
59
|
# be properly encoded (e.g. & -> &).
|
|
@@ -143,7 +143,7 @@ module MjmlRb
|
|
|
143
143
|
link_attrs["title"] = a["title"]
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
content =
|
|
146
|
+
content = raw_inner_for_body(node)
|
|
147
147
|
inner_tag = %(<#{tag}#{html_attrs(link_attrs)}>#{content}</#{tag}>)
|
|
148
148
|
table = %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{table_style}"><tbody><tr><td#{html_attrs(td_attrs)}>#{inner_tag}</td></tr></tbody></table>)
|
|
149
149
|
|
|
@@ -603,7 +603,7 @@ module MjmlRb
|
|
|
603
603
|
"overflow" => (has_border_radius ? "hidden" : nil),
|
|
604
604
|
"margin" => "0px auto",
|
|
605
605
|
"margin-top" => wrapper_gap,
|
|
606
|
-
"max-width" =>
|
|
606
|
+
"max-width" => "#{container_px}px"
|
|
607
607
|
}.merge(full_width ? {} : background_styles)
|
|
608
608
|
)
|
|
609
609
|
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -18,13 +18,22 @@ module MjmlRb
|
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
# Pre-compiled regex patterns to avoid rebuilding on every call
|
|
21
|
-
|
|
21
|
+
ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
|
|
22
22
|
|
|
23
23
|
VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
|
|
24
24
|
VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
|
|
25
25
|
VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
|
|
26
26
|
LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
|
|
27
27
|
BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
|
|
28
|
+
ROOT_LEVEL_HEAD_TAGS = %w[
|
|
29
|
+
mj-attributes
|
|
30
|
+
mj-breakpoint
|
|
31
|
+
mj-html-attributes
|
|
32
|
+
mj-font
|
|
33
|
+
mj-preview
|
|
34
|
+
mj-style
|
|
35
|
+
mj-title
|
|
36
|
+
].freeze
|
|
28
37
|
|
|
29
38
|
class ParseError < StandardError
|
|
30
39
|
attr_reader :line
|
|
@@ -49,6 +58,7 @@ module MjmlRb
|
|
|
49
58
|
|
|
50
59
|
xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
|
|
51
60
|
doc = Document.new(xml)
|
|
61
|
+
normalize_root_head_elements(doc)
|
|
52
62
|
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
53
63
|
rescue ParseException => e
|
|
54
64
|
raise ParseError.new("XML parse error: #{e.message}")
|
|
@@ -173,6 +183,7 @@ module MjmlRb
|
|
|
173
183
|
|
|
174
184
|
def extract_mjml_include_children(xml)
|
|
175
185
|
include_doc = Document.new(sanitize_bare_ampersands(xml))
|
|
186
|
+
normalize_root_head_elements(include_doc)
|
|
176
187
|
mjml_root = include_doc.root
|
|
177
188
|
return [[], []] unless mjml_root&.name == "mjml"
|
|
178
189
|
|
|
@@ -219,6 +230,38 @@ module MjmlRb
|
|
|
219
230
|
head
|
|
220
231
|
end
|
|
221
232
|
|
|
233
|
+
def normalize_root_head_elements(doc)
|
|
234
|
+
mjml_root = doc.root
|
|
235
|
+
return unless mjml_root&.name == "mjml"
|
|
236
|
+
|
|
237
|
+
head_nodes = []
|
|
238
|
+
normalized_head_children = []
|
|
239
|
+
root_head_elements = []
|
|
240
|
+
|
|
241
|
+
mjml_root.children.each do |child|
|
|
242
|
+
next unless child.is_a?(Element)
|
|
243
|
+
|
|
244
|
+
if child.name == "mj-head"
|
|
245
|
+
head_nodes << child
|
|
246
|
+
child.children.each { |head_child| normalized_head_children << deep_clone(head_child) }
|
|
247
|
+
elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
|
|
248
|
+
root_head_elements << child
|
|
249
|
+
normalized_head_children << deep_clone(child)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return if root_head_elements.empty? && head_nodes.length <= 1
|
|
254
|
+
|
|
255
|
+
head = head_nodes.first || ensure_head(doc)
|
|
256
|
+
return unless head
|
|
257
|
+
|
|
258
|
+
head.children.to_a.each { |child| head.delete(child) }
|
|
259
|
+
normalized_head_children.each { |child| head.add(child) }
|
|
260
|
+
|
|
261
|
+
root_head_elements.each { |child| mjml_root.delete(child) }
|
|
262
|
+
head_nodes.drop(1).each { |extra_head| mjml_root.delete(extra_head) }
|
|
263
|
+
end
|
|
264
|
+
|
|
222
265
|
def strip_xml_declaration(content)
|
|
223
266
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
224
267
|
end
|
|
@@ -239,20 +282,74 @@ module MjmlRb
|
|
|
239
282
|
end
|
|
240
283
|
|
|
241
284
|
def wrap_ending_tags_in_cdata(content)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
wrapped = +""
|
|
286
|
+
cursor = 0
|
|
287
|
+
|
|
288
|
+
while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
|
|
289
|
+
tag = match[1]
|
|
290
|
+
attrs = match[2].to_s
|
|
291
|
+
wrapped << content[cursor...match.begin(0)]
|
|
292
|
+
|
|
293
|
+
closing_range = find_matching_ending_tag(content, tag, match.end(0))
|
|
294
|
+
unless closing_range
|
|
295
|
+
wrapped << match[0]
|
|
296
|
+
cursor = match.end(0)
|
|
297
|
+
next
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
inner = content[match.end(0)...closing_range.begin(0)]
|
|
247
301
|
if inner.include?("<![CDATA[")
|
|
248
|
-
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
302
|
+
wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
249
303
|
else
|
|
250
304
|
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
251
305
|
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
252
306
|
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
253
|
-
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
307
|
+
wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
254
308
|
end
|
|
309
|
+
|
|
310
|
+
cursor = closing_range.end(0)
|
|
255
311
|
end
|
|
312
|
+
|
|
313
|
+
wrapped << content[cursor..] if cursor < content.length
|
|
314
|
+
wrapped
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def find_matching_ending_tag(content, tag_name, cursor)
|
|
318
|
+
open_tag_re = /<#{Regexp.escape(tag_name)}(\s[^<>]*?)?(?<!\/)>/mi
|
|
319
|
+
close_tag_re = %r{</#{Regexp.escape(tag_name)}\s*>}i
|
|
320
|
+
depth = 1
|
|
321
|
+
|
|
322
|
+
while cursor < content.length
|
|
323
|
+
cdata_index = content.index("<![CDATA[", cursor)
|
|
324
|
+
open_match = open_tag_re.match(content, cursor)
|
|
325
|
+
close_match = close_tag_re.match(content, cursor)
|
|
326
|
+
|
|
327
|
+
candidates = []
|
|
328
|
+
candidates << [:cdata, cdata_index, nil] if cdata_index
|
|
329
|
+
candidates << [:open, open_match.begin(0), open_match] if open_match
|
|
330
|
+
candidates << [:close, close_match.begin(0), close_match] if close_match
|
|
331
|
+
return nil if candidates.empty?
|
|
332
|
+
|
|
333
|
+
kind, _, match = candidates.min_by { |candidate| candidate[1] }
|
|
334
|
+
|
|
335
|
+
case kind
|
|
336
|
+
when :cdata
|
|
337
|
+
cdata_end = content.index("]]>", cdata_index + 9)
|
|
338
|
+
return nil unless cdata_end
|
|
339
|
+
|
|
340
|
+
cursor = cdata_end + 3
|
|
341
|
+
when :open
|
|
342
|
+
depth += 1
|
|
343
|
+
cursor = match.end(0)
|
|
344
|
+
when :close
|
|
345
|
+
depth -= 1
|
|
346
|
+
return match if depth.zero?
|
|
347
|
+
|
|
348
|
+
cursor = match.end(0)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
nil
|
|
256
353
|
end
|
|
257
354
|
|
|
258
355
|
def escape_cdata(content)
|
|
@@ -337,6 +434,7 @@ module MjmlRb
|
|
|
337
434
|
attrs = element.attributes.each_with_object({}) do |(name, val), h|
|
|
338
435
|
h[name] = val unless name.start_with?("data-mjml-")
|
|
339
436
|
end
|
|
437
|
+
attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
|
|
340
438
|
|
|
341
439
|
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
342
440
|
# the raw HTML directly as content instead of parsing structurally.
|
|
@@ -358,7 +456,10 @@ module MjmlRb
|
|
|
358
456
|
memo << element_to_ast(child, keep_comments: keep_comments)
|
|
359
457
|
when Text
|
|
360
458
|
text = child.value
|
|
361
|
-
|
|
459
|
+
next if text.empty?
|
|
460
|
+
next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
|
|
461
|
+
|
|
462
|
+
memo << AstNode.new(tag_name: "#text", content: text)
|
|
362
463
|
when Comment
|
|
363
464
|
memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
|
|
364
465
|
end
|
|
@@ -372,5 +473,11 @@ module MjmlRb
|
|
|
372
473
|
file: meta_file
|
|
373
474
|
)
|
|
374
475
|
end
|
|
476
|
+
|
|
477
|
+
def ignorable_whitespace_text?(text, parent_element_name:)
|
|
478
|
+
return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
|
|
479
|
+
|
|
480
|
+
text.match?(/[\r\n]/)
|
|
481
|
+
end
|
|
375
482
|
end
|
|
376
483
|
end
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -168,6 +168,8 @@ module MjmlRb
|
|
|
168
168
|
HTML
|
|
169
169
|
|
|
170
170
|
html = apply_inline_styles(html, context)
|
|
171
|
+
html = preserve_raw_tag_spacing(html)
|
|
172
|
+
html = strip_internal_raw_markers(html)
|
|
171
173
|
html = merge_outlook_conditionals(html)
|
|
172
174
|
before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
|
|
173
175
|
end
|
|
@@ -342,20 +344,27 @@ module MjmlRb
|
|
|
342
344
|
return html if css_blocks.empty?
|
|
343
345
|
|
|
344
346
|
document = parse_html_document(html)
|
|
345
|
-
rules,
|
|
347
|
+
rules, = parse_inline_css_rules(css_blocks.join("\n"))
|
|
348
|
+
merged_declarations_by_node = {}
|
|
349
|
+
touched_properties_by_node = Hash.new { |hash, node| hash[node] = Set.new }
|
|
346
350
|
|
|
347
351
|
rules.each do |selector, declarations|
|
|
348
352
|
next if selector.empty? || declarations.empty?
|
|
349
353
|
|
|
350
354
|
select_nodes(document, selector).each do |node|
|
|
351
|
-
|
|
355
|
+
existing = merged_declarations_by_node[node] ||= begin
|
|
356
|
+
source = node["data-mjml-raw"] == "true" ? :inline : :css
|
|
357
|
+
parsed = parse_css_declarations(node["style"].to_s, source: source)
|
|
358
|
+
touched_properties_by_node[node].merge(parsed.keys & HTML_ATTRIBUTE_SYNC_PROPERTIES.to_a) if source == :inline
|
|
359
|
+
parsed
|
|
360
|
+
end
|
|
361
|
+
merge_inline_declarations!(existing, declarations, touched_properties_by_node[node])
|
|
352
362
|
end
|
|
353
363
|
end
|
|
354
364
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
inject_preserved_at_rules(document, at_rules_css)
|
|
365
|
+
merged_declarations_by_node.each do |node, declarations|
|
|
366
|
+
finalize_inline_style!(node, declarations, touched_properties_by_node[node])
|
|
367
|
+
end
|
|
359
368
|
|
|
360
369
|
document.to_html
|
|
361
370
|
end
|
|
@@ -368,18 +377,6 @@ module MjmlRb
|
|
|
368
377
|
end
|
|
369
378
|
end
|
|
370
379
|
|
|
371
|
-
def inject_preserved_at_rules(document, at_rules_css)
|
|
372
|
-
return if at_rules_css.nil? || at_rules_css.strip.empty?
|
|
373
|
-
|
|
374
|
-
head = document.at_css("head")
|
|
375
|
-
return unless head
|
|
376
|
-
|
|
377
|
-
style = Nokogiri::XML::Node.new("style", document)
|
|
378
|
-
style["type"] = "text/css"
|
|
379
|
-
style.content = at_rules_css.strip
|
|
380
|
-
head.add_child(style)
|
|
381
|
-
end
|
|
382
|
-
|
|
383
380
|
def select_nodes(document, selector)
|
|
384
381
|
document.css(selector)
|
|
385
382
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
@@ -517,7 +514,7 @@ module MjmlRb
|
|
|
517
514
|
[a, b, c]
|
|
518
515
|
end
|
|
519
516
|
|
|
520
|
-
def parse_css_declarations(declarations)
|
|
517
|
+
def parse_css_declarations(declarations, source: :css)
|
|
521
518
|
declarations.split(";").each_with_object({}) do |entry, memo|
|
|
522
519
|
property, value = entry.split(":", 2).map { |part| part&.strip }
|
|
523
520
|
next if property.nil? || property.empty? || value.nil? || value.empty?
|
|
@@ -525,35 +522,26 @@ module MjmlRb
|
|
|
525
522
|
important = value.match?(/\s*!important\s*\z/)
|
|
526
523
|
memo[property] = {
|
|
527
524
|
value: value.sub(/\s*!important\s*\z/, "").strip,
|
|
528
|
-
important: important
|
|
525
|
+
important: important,
|
|
526
|
+
source: source
|
|
529
527
|
}
|
|
530
528
|
end
|
|
531
529
|
end
|
|
532
530
|
|
|
533
|
-
def
|
|
534
|
-
existing = parse_css_declarations(node["style"].to_s)
|
|
531
|
+
def merge_inline_declarations!(existing, declarations, touched_properties)
|
|
535
532
|
declarations.each do |property, value|
|
|
536
533
|
merged = merge_css_declaration(existing[property], value)
|
|
537
534
|
next if merged.equal?(existing[property])
|
|
538
535
|
|
|
539
536
|
existing.delete(property)
|
|
540
537
|
existing[property] = merged
|
|
538
|
+
touched_properties << property
|
|
541
539
|
end
|
|
542
|
-
normalize_background_fallbacks!(node, existing)
|
|
543
|
-
sync_html_attributes!(node, existing)
|
|
544
|
-
node["style"] = serialize_css_declarations(existing)
|
|
545
540
|
end
|
|
546
541
|
|
|
547
|
-
def
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
if syncable_background?(declaration_value(declarations["background"]))
|
|
552
|
-
declarations["background"] = {
|
|
553
|
-
value: background_color,
|
|
554
|
-
important: declarations.fetch("background-color", {}).fetch(:important, false)
|
|
555
|
-
}
|
|
556
|
-
end
|
|
542
|
+
def finalize_inline_style!(node, declarations, touched_properties)
|
|
543
|
+
sync_html_attributes!(node, declarations, touched_properties)
|
|
544
|
+
node["style"] = serialize_css_declarations(declarations)
|
|
557
545
|
end
|
|
558
546
|
|
|
559
547
|
# Sync HTML attributes from inlined CSS declarations.
|
|
@@ -569,15 +557,19 @@ module MjmlRb
|
|
|
569
557
|
"text-align" => "align",
|
|
570
558
|
"vertical-align" => "valign"
|
|
571
559
|
}.freeze
|
|
560
|
+
HTML_ATTRIBUTE_SYNC_PROPERTIES = Set.new((%w[width height] + STYLE_TO_ATTRIBUTE.keys)).freeze
|
|
572
561
|
|
|
573
|
-
def sync_html_attributes!(node, declarations)
|
|
562
|
+
def sync_html_attributes!(node, declarations, touched_properties = nil)
|
|
574
563
|
tag = node.name.downcase
|
|
575
564
|
|
|
576
565
|
# Sync width/height on TABLE, TD, TH, IMG
|
|
577
566
|
if WIDTH_HEIGHT_ELEMENTS.include?(tag)
|
|
578
567
|
%w[width height].each do |prop|
|
|
568
|
+
next if touched_properties && !touched_properties.include?(prop)
|
|
569
|
+
|
|
579
570
|
css_value = declaration_value(declarations[prop])
|
|
580
571
|
next if css_value.nil? || css_value.empty?
|
|
572
|
+
next if tag == "img" && prop == "width" && css_value.include?("%")
|
|
581
573
|
|
|
582
574
|
# Convert CSS px values to plain numbers for HTML attributes;
|
|
583
575
|
# keep other values (auto, %) as-is.
|
|
@@ -589,6 +581,8 @@ module MjmlRb
|
|
|
589
581
|
# Sync style-to-attribute mappings on table elements
|
|
590
582
|
if TABLE_ELEMENTS.include?(tag)
|
|
591
583
|
STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
|
|
584
|
+
next if touched_properties && !touched_properties.include?(css_prop)
|
|
585
|
+
|
|
592
586
|
css_value = declaration_value(declarations[css_prop])
|
|
593
587
|
next if css_value.nil? || css_value.empty?
|
|
594
588
|
|
|
@@ -609,26 +603,10 @@ module MjmlRb
|
|
|
609
603
|
end
|
|
610
604
|
end
|
|
611
605
|
|
|
612
|
-
def syncable_background?(value)
|
|
613
|
-
return true if value.nil? || value.empty?
|
|
614
|
-
|
|
615
|
-
normalized = value.downcase
|
|
616
|
-
!normalized.include?("url(") &&
|
|
617
|
-
!normalized.include?("gradient(") &&
|
|
618
|
-
!normalized.include?("/") &&
|
|
619
|
-
!normalized.include?(" no-repeat") &&
|
|
620
|
-
!normalized.include?(" repeat") &&
|
|
621
|
-
!normalized.include?(" fixed") &&
|
|
622
|
-
!normalized.include?(" scroll") &&
|
|
623
|
-
!normalized.include?(" center") &&
|
|
624
|
-
!normalized.include?(" top") &&
|
|
625
|
-
!normalized.include?(" bottom") &&
|
|
626
|
-
!normalized.include?(" left") &&
|
|
627
|
-
!normalized.include?(" right")
|
|
628
|
-
end
|
|
629
|
-
|
|
630
606
|
def merge_css_declaration(existing, incoming)
|
|
631
607
|
return incoming if existing.nil?
|
|
608
|
+
return incoming if incoming[:important] && !existing[:important]
|
|
609
|
+
return existing if existing[:source] == :inline
|
|
632
610
|
return existing if existing[:important] && !incoming[:important]
|
|
633
611
|
|
|
634
612
|
incoming
|
|
@@ -639,13 +617,49 @@ module MjmlRb
|
|
|
639
617
|
end
|
|
640
618
|
|
|
641
619
|
def serialize_css_declarations(declarations)
|
|
642
|
-
declarations.map do |property, declaration|
|
|
643
|
-
|
|
644
|
-
value = "#{value} !important" if declaration[:important]
|
|
645
|
-
"#{property}: #{value}"
|
|
620
|
+
ordered_css_declarations(declarations).map do |property, declaration|
|
|
621
|
+
"#{property}: #{declaration[:value]}"
|
|
646
622
|
end.join("; ")
|
|
647
623
|
end
|
|
648
624
|
|
|
625
|
+
SHORTHAND_LONGHAND_FAMILIES = {
|
|
626
|
+
"background" => /\Abackground-/,
|
|
627
|
+
"border" => /\Aborder(?:-(?!collapse|spacing)[a-z-]+)?\z/,
|
|
628
|
+
"border-radius" => /\Aborder-(?:top|bottom)-(?:left|right)-radius\z/,
|
|
629
|
+
"font" => /\Afont-/,
|
|
630
|
+
"list-style" => /\Alist-style-/,
|
|
631
|
+
"margin" => /\Amargin-(?:top|right|bottom|left)\z/,
|
|
632
|
+
"padding" => /\Apadding-(?:top|right|bottom|left)\z/
|
|
633
|
+
}.freeze
|
|
634
|
+
|
|
635
|
+
def ordered_css_declarations(declarations)
|
|
636
|
+
ordered = declarations.to_a
|
|
637
|
+
|
|
638
|
+
SHORTHAND_LONGHAND_FAMILIES.each do |shorthand, longhand_pattern|
|
|
639
|
+
family_indexes = ordered.each_index.select do |index|
|
|
640
|
+
property = ordered[index][0]
|
|
641
|
+
property == shorthand || property.match?(longhand_pattern)
|
|
642
|
+
end
|
|
643
|
+
next if family_indexes.length < 2
|
|
644
|
+
|
|
645
|
+
family_entries = family_indexes.map.with_index do |declaration_index, original_family_index|
|
|
646
|
+
[ordered[declaration_index], original_family_index]
|
|
647
|
+
end
|
|
648
|
+
next unless family_entries.any? { |((_, declaration), _)| declaration[:important] }
|
|
649
|
+
next unless family_entries.any? { |((_, declaration), _)| !declaration[:important] }
|
|
650
|
+
|
|
651
|
+
reordered_entries = family_entries.sort_by do |((_, declaration), original_family_index)|
|
|
652
|
+
[declaration[:important] ? 1 : 0, original_family_index]
|
|
653
|
+
end.map(&:first)
|
|
654
|
+
|
|
655
|
+
family_indexes.each_with_index do |declaration_index, reordered_index|
|
|
656
|
+
ordered[declaration_index] = reordered_entries[reordered_index]
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
ordered
|
|
661
|
+
end
|
|
662
|
+
|
|
649
663
|
def append_component_head_styles(document, context)
|
|
650
664
|
all_tags = collect_tag_names(document)
|
|
651
665
|
|
|
@@ -740,7 +754,7 @@ module MjmlRb
|
|
|
740
754
|
if node.respond_to?(:children)
|
|
741
755
|
node.children.map do |child|
|
|
742
756
|
if child.text?
|
|
743
|
-
escape_html(child.content.to_s)
|
|
757
|
+
serialize_text_content(escape_html(child.content.to_s))
|
|
744
758
|
elsif child.comment?
|
|
745
759
|
"<!--#{child.content}-->"
|
|
746
760
|
else
|
|
@@ -759,7 +773,7 @@ module MjmlRb
|
|
|
759
773
|
if node.respond_to?(:children)
|
|
760
774
|
node.children.map do |child|
|
|
761
775
|
if child.text?
|
|
762
|
-
child.content.to_s
|
|
776
|
+
serialize_text_content(child.content.to_s)
|
|
763
777
|
elsif child.comment?
|
|
764
778
|
"<!--#{child.content}-->"
|
|
765
779
|
else
|
|
@@ -771,15 +785,52 @@ module MjmlRb
|
|
|
771
785
|
end
|
|
772
786
|
end
|
|
773
787
|
|
|
788
|
+
def annotate_raw_html(content)
|
|
789
|
+
return content if content.nil? || content.empty? || !content.include?("<")
|
|
790
|
+
content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
|
|
791
|
+
tag_name = Regexp.last_match(1)
|
|
792
|
+
attrs = Regexp.last_match(2).to_s
|
|
793
|
+
closing = Regexp.last_match(3).to_s
|
|
794
|
+
"<#{tag_name}#{attrs} data-mjml-raw=\"true\"#{closing}>"
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def strip_internal_raw_markers(html)
|
|
799
|
+
html.gsub(/\sdata-mjml-raw=(['"])true\1/, "")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def preserve_raw_tag_spacing(html)
|
|
803
|
+
html.gsub(
|
|
804
|
+
/(<[^>]+data-mjml-raw=(['"])true\2[^>]*>)([ \t]+)(<[^\/!][^>]+data-mjml-raw=(['"])true\5[^>]*>)/
|
|
805
|
+
) do
|
|
806
|
+
"#{Regexp.last_match(1)}#{encode_whitespace_entities(Regexp.last_match(3))}#{Regexp.last_match(4)}"
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def encode_whitespace_entities(text)
|
|
811
|
+
text.to_s.gsub(" ", " ").gsub("\t", "	")
|
|
812
|
+
end
|
|
813
|
+
|
|
774
814
|
def serialize_node(node)
|
|
775
815
|
attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
|
|
776
816
|
return "<#{node.tag_name}#{attrs} />" if node.children.empty? && html_void_tag?(node.tag_name)
|
|
777
817
|
return "<#{node.tag_name}#{attrs}></#{node.tag_name}>" if node.children.empty?
|
|
778
818
|
|
|
779
|
-
inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
|
|
819
|
+
inner = node.children.map { |child| child.text? ? serialize_text_content(child.content.to_s) : serialize_node(child) }.join
|
|
780
820
|
"<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
|
|
781
821
|
end
|
|
782
822
|
|
|
823
|
+
def serialize_text_content(text)
|
|
824
|
+
value = text.to_s
|
|
825
|
+
return value unless significant_whitespace_text?(value)
|
|
826
|
+
|
|
827
|
+
value.gsub(" ", " ").gsub("\t", "	")
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def significant_whitespace_text?(text)
|
|
831
|
+
!text.empty? && text.strip.empty? && !text.match?(/[\r\n]/)
|
|
832
|
+
end
|
|
833
|
+
|
|
783
834
|
def html_void_tag?(tag_name)
|
|
784
835
|
HTML_VOID_TAGS.include?(tag_name.to_s.downcase)
|
|
785
836
|
end
|
data/lib/mjml-rb/version.rb
CHANGED