mjml-rb 0.4.3 → 0.5.0
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/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/text.rb +1 -1
- data/lib/mjml-rb/dependencies.rb +1 -1
- data/lib/mjml-rb/parser.rb +198 -105
- data/lib/mjml-rb/renderer.rb +142 -158
- data/lib/mjml-rb/validator.rb +2 -2
- data/lib/mjml-rb/version.rb +1 -1
- data/mjml-rb.gemspec +2 -2
- metadata +8 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b2c747b6f5afbc288db3ddc0afec65b3f0ff5a208bb2958abf8c9869a76790c
|
|
4
|
+
data.tar.gz: edd5d4b07c99bfd971bafadf42b11a66c0ace207cdfe835fa2643c64fc4297f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8989b98cecdb8b63d843f87dd980c409581fe11220cbb439d183768259027a04536799e1d9bc43f5f8760aef216c208ff4e0d74d01e13008f65c749f190fc99c
|
|
7
|
+
data.tar.gz: 83d310d5e9f5f92bf97da53086c56bca1baed0e9bd7495f7c8595f888b6343621e76b6dc98292db801a28c169a85720be6e8afcd674928ec9a3a4a5947d727fd
|
|
@@ -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
|
|
data/lib/mjml-rb/dependencies.rb
CHANGED
|
@@ -4,7 +4,7 @@ module MjmlRb
|
|
|
4
4
|
module Dependencies
|
|
5
5
|
# Components whose content is treated as raw HTML in NPM (endingTag = true).
|
|
6
6
|
# The parser preserves their inner markup as-is; the validator skips child
|
|
7
|
-
# element checks because
|
|
7
|
+
# element checks because the XML parser structurally parses what NPM treats as text.
|
|
8
8
|
ENDING_TAGS = Set.new(%w[
|
|
9
9
|
mj-accordion-text
|
|
10
10
|
mj-accordion-title
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
require "
|
|
2
|
-
require "rexml/xpath"
|
|
1
|
+
require "nokogiri"
|
|
3
2
|
|
|
4
3
|
require_relative "ast_node"
|
|
5
4
|
|
|
6
5
|
module MjmlRb
|
|
7
6
|
class Parser
|
|
8
|
-
include REXML
|
|
9
7
|
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
10
8
|
|
|
11
9
|
# Ending-tag components whose inner HTML is preserved as raw content via CDATA
|
|
@@ -18,13 +16,21 @@ module MjmlRb
|
|
|
18
16
|
].freeze
|
|
19
17
|
|
|
20
18
|
# Pre-compiled regex patterns to avoid rebuilding on every call
|
|
21
|
-
|
|
19
|
+
ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
|
|
22
20
|
|
|
23
21
|
VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
|
|
24
22
|
VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
|
|
25
23
|
VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
|
|
26
|
-
LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
|
|
27
24
|
BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
|
|
25
|
+
ROOT_LEVEL_HEAD_TAGS = %w[
|
|
26
|
+
mj-attributes
|
|
27
|
+
mj-breakpoint
|
|
28
|
+
mj-html-attributes
|
|
29
|
+
mj-font
|
|
30
|
+
mj-preview
|
|
31
|
+
mj-style
|
|
32
|
+
mj-title
|
|
33
|
+
].freeze
|
|
28
34
|
|
|
29
35
|
class ParseError < StandardError
|
|
30
36
|
attr_reader :line
|
|
@@ -47,10 +53,12 @@ module MjmlRb
|
|
|
47
53
|
xml = normalize_html_void_tags(xml)
|
|
48
54
|
xml = expand_includes(xml, opts) unless opts[:ignore_includes]
|
|
49
55
|
|
|
50
|
-
xml =
|
|
51
|
-
|
|
56
|
+
xml = sanitize_bare_ampersands(xml)
|
|
57
|
+
xml = replace_html_entities(xml)
|
|
58
|
+
doc = Nokogiri::XML(xml) { |config| config.strict }
|
|
59
|
+
normalize_root_head_elements(doc)
|
|
52
60
|
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
53
|
-
rescue
|
|
61
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
54
62
|
raise ParseError.new("XML parse error: #{e.message}")
|
|
55
63
|
end
|
|
56
64
|
|
|
@@ -75,18 +83,18 @@ module MjmlRb
|
|
|
75
83
|
def expand_includes(xml, options, included_in = [])
|
|
76
84
|
xml = wrap_ending_tags_in_cdata(xml)
|
|
77
85
|
xml = normalize_html_void_tags(xml)
|
|
78
|
-
doc =
|
|
79
|
-
includes =
|
|
86
|
+
doc = parse_xml(sanitize_bare_ampersands(xml))
|
|
87
|
+
includes = doc.xpath("//mj-include")
|
|
80
88
|
return xml if includes.empty?
|
|
81
89
|
|
|
82
90
|
css_includes = []
|
|
83
91
|
head_includes = []
|
|
84
92
|
|
|
85
93
|
includes.reverse_each do |include_node|
|
|
86
|
-
path_attr = include_node
|
|
94
|
+
path_attr = include_node["path"]
|
|
87
95
|
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
88
96
|
|
|
89
|
-
include_type = include_node
|
|
97
|
+
include_type = include_node["type"].to_s
|
|
90
98
|
parent = include_node.parent
|
|
91
99
|
|
|
92
100
|
resolved_path = begin
|
|
@@ -104,7 +112,7 @@ module MjmlRb
|
|
|
104
112
|
tag_name: "mj-include",
|
|
105
113
|
file: display_path
|
|
106
114
|
}
|
|
107
|
-
|
|
115
|
+
include_node.remove
|
|
108
116
|
next
|
|
109
117
|
end
|
|
110
118
|
|
|
@@ -115,9 +123,9 @@ module MjmlRb
|
|
|
115
123
|
|
|
116
124
|
if include_type == "css"
|
|
117
125
|
# CSS includes get collected and added to mj-head later
|
|
118
|
-
css_inline = include_node
|
|
126
|
+
css_inline = include_node["css-inline"].to_s
|
|
119
127
|
css_includes << { content: include_content, inline: css_inline == "inline" }
|
|
120
|
-
|
|
128
|
+
include_node.remove
|
|
121
129
|
next
|
|
122
130
|
end
|
|
123
131
|
|
|
@@ -135,19 +143,20 @@ module MjmlRb
|
|
|
135
143
|
body_children
|
|
136
144
|
end
|
|
137
145
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
if replacement.is_a?(Array)
|
|
147
|
+
replacement.each do |child|
|
|
148
|
+
annotate_include_source(child, resolved_path) if child.element?
|
|
149
|
+
include_node.add_previous_sibling(child)
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
fragment = parse_xml("<include-root>#{sanitize_bare_ampersands(replacement)}</include-root>").root
|
|
153
|
+
fragment.children.each do |child|
|
|
154
|
+
cloned = child.dup(1)
|
|
155
|
+
annotate_include_source(cloned, resolved_path) if cloned.element?
|
|
156
|
+
include_node.add_previous_sibling(cloned)
|
|
157
|
+
end
|
|
149
158
|
end
|
|
150
|
-
|
|
159
|
+
include_node.remove
|
|
151
160
|
end
|
|
152
161
|
|
|
153
162
|
inject_head_includes(doc, head_includes) unless head_includes.empty?
|
|
@@ -157,11 +166,7 @@ module MjmlRb
|
|
|
157
166
|
inject_css_includes(doc, css_includes)
|
|
158
167
|
end
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
doc.write(output)
|
|
162
|
-
output
|
|
163
|
-
rescue ParseException => e
|
|
164
|
-
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
169
|
+
doc.root.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML | Nokogiri::XML::Node::SaveOptions::NO_DECLARATION)
|
|
165
170
|
end
|
|
166
171
|
|
|
167
172
|
def prepare_mjml_include_document(content)
|
|
@@ -172,22 +177,23 @@ module MjmlRb
|
|
|
172
177
|
end
|
|
173
178
|
|
|
174
179
|
def extract_mjml_include_children(xml)
|
|
175
|
-
include_doc =
|
|
180
|
+
include_doc = parse_xml(sanitize_bare_ampersands(xml))
|
|
181
|
+
normalize_root_head_elements(include_doc)
|
|
176
182
|
mjml_root = include_doc.root
|
|
177
183
|
return [[], []] unless mjml_root&.name == "mjml"
|
|
178
184
|
|
|
179
|
-
body =
|
|
180
|
-
head =
|
|
185
|
+
body = mjml_root.at_xpath("mj-body")
|
|
186
|
+
head = mjml_root.at_xpath("mj-head")
|
|
181
187
|
|
|
182
188
|
[
|
|
183
|
-
body ? body.children.map { |child|
|
|
184
|
-
head ? head.children.map { |child|
|
|
189
|
+
body ? body.children.map { |child| child.dup(1) } : [],
|
|
190
|
+
head ? head.children.map { |child| child.dup(1) } : []
|
|
185
191
|
]
|
|
186
192
|
end
|
|
187
193
|
|
|
188
194
|
def inject_head_includes(doc, head_includes)
|
|
189
195
|
head = ensure_head(doc)
|
|
190
|
-
head_includes.each { |child| head.
|
|
196
|
+
head_includes.each { |child| head.add_child(child) }
|
|
191
197
|
end
|
|
192
198
|
|
|
193
199
|
def inject_css_includes(doc, css_includes)
|
|
@@ -195,10 +201,10 @@ module MjmlRb
|
|
|
195
201
|
|
|
196
202
|
# Add each CSS include as an mj-style element
|
|
197
203
|
css_includes.each do |css_include|
|
|
198
|
-
style_node =
|
|
199
|
-
style_node
|
|
200
|
-
style_node.
|
|
201
|
-
head.
|
|
204
|
+
style_node = Nokogiri::XML::Node.new("mj-style", doc)
|
|
205
|
+
style_node["inline"] = "inline" if css_include[:inline]
|
|
206
|
+
style_node.add_child(Nokogiri::XML::CDATA.new(doc, css_include[:content]))
|
|
207
|
+
head.add_child(style_node)
|
|
202
208
|
end
|
|
203
209
|
end
|
|
204
210
|
|
|
@@ -206,19 +212,51 @@ module MjmlRb
|
|
|
206
212
|
mjml_root = doc.root
|
|
207
213
|
return unless mjml_root
|
|
208
214
|
|
|
209
|
-
head =
|
|
215
|
+
head = mjml_root.at_xpath("mj-head")
|
|
210
216
|
return head if head
|
|
211
217
|
|
|
212
|
-
head =
|
|
213
|
-
body =
|
|
218
|
+
head = Nokogiri::XML::Node.new("mj-head", doc)
|
|
219
|
+
body = mjml_root.at_xpath("mj-body")
|
|
214
220
|
if body
|
|
215
|
-
|
|
221
|
+
body.add_previous_sibling(head)
|
|
216
222
|
else
|
|
217
|
-
mjml_root.
|
|
223
|
+
mjml_root.add_child(head)
|
|
218
224
|
end
|
|
219
225
|
head
|
|
220
226
|
end
|
|
221
227
|
|
|
228
|
+
def normalize_root_head_elements(doc)
|
|
229
|
+
mjml_root = doc.root
|
|
230
|
+
return unless mjml_root&.name == "mjml"
|
|
231
|
+
|
|
232
|
+
head_nodes = []
|
|
233
|
+
normalized_head_children = []
|
|
234
|
+
root_head_elements = []
|
|
235
|
+
|
|
236
|
+
mjml_root.children.each do |child|
|
|
237
|
+
next unless child.element?
|
|
238
|
+
|
|
239
|
+
if child.name == "mj-head"
|
|
240
|
+
head_nodes << child
|
|
241
|
+
child.children.each { |head_child| normalized_head_children << head_child.dup(1) }
|
|
242
|
+
elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
|
|
243
|
+
root_head_elements << child
|
|
244
|
+
normalized_head_children << child.dup(1)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
return if root_head_elements.empty? && head_nodes.length <= 1
|
|
249
|
+
|
|
250
|
+
head = head_nodes.first || ensure_head(doc)
|
|
251
|
+
return unless head
|
|
252
|
+
|
|
253
|
+
head.children.each(&:remove)
|
|
254
|
+
normalized_head_children.each { |child| head.add_child(child) }
|
|
255
|
+
|
|
256
|
+
root_head_elements.each(&:remove)
|
|
257
|
+
head_nodes.drop(1).each(&:remove)
|
|
258
|
+
end
|
|
259
|
+
|
|
222
260
|
def strip_xml_declaration(content)
|
|
223
261
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
224
262
|
end
|
|
@@ -239,20 +277,74 @@ module MjmlRb
|
|
|
239
277
|
end
|
|
240
278
|
|
|
241
279
|
def wrap_ending_tags_in_cdata(content)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
280
|
+
wrapped = +""
|
|
281
|
+
cursor = 0
|
|
282
|
+
|
|
283
|
+
while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
|
|
284
|
+
tag = match[1]
|
|
285
|
+
attrs = match[2].to_s
|
|
286
|
+
wrapped << content[cursor...match.begin(0)]
|
|
287
|
+
|
|
288
|
+
closing_range = find_matching_ending_tag(content, tag, match.end(0))
|
|
289
|
+
unless closing_range
|
|
290
|
+
wrapped << match[0]
|
|
291
|
+
cursor = match.end(0)
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
inner = content[match.end(0)...closing_range.begin(0)]
|
|
247
296
|
if inner.include?("<![CDATA[")
|
|
248
|
-
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
297
|
+
wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
249
298
|
else
|
|
250
299
|
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
251
300
|
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
252
301
|
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
253
|
-
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
302
|
+
wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
254
303
|
end
|
|
304
|
+
|
|
305
|
+
cursor = closing_range.end(0)
|
|
255
306
|
end
|
|
307
|
+
|
|
308
|
+
wrapped << content[cursor..] if cursor < content.length
|
|
309
|
+
wrapped
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def find_matching_ending_tag(content, tag_name, cursor)
|
|
313
|
+
open_tag_re = /<#{Regexp.escape(tag_name)}(\s[^<>]*?)?(?<!\/)>/mi
|
|
314
|
+
close_tag_re = %r{</#{Regexp.escape(tag_name)}\s*>}i
|
|
315
|
+
depth = 1
|
|
316
|
+
|
|
317
|
+
while cursor < content.length
|
|
318
|
+
cdata_index = content.index("<![CDATA[", cursor)
|
|
319
|
+
open_match = open_tag_re.match(content, cursor)
|
|
320
|
+
close_match = close_tag_re.match(content, cursor)
|
|
321
|
+
|
|
322
|
+
candidates = []
|
|
323
|
+
candidates << [:cdata, cdata_index, nil] if cdata_index
|
|
324
|
+
candidates << [:open, open_match.begin(0), open_match] if open_match
|
|
325
|
+
candidates << [:close, close_match.begin(0), close_match] if close_match
|
|
326
|
+
return nil if candidates.empty?
|
|
327
|
+
|
|
328
|
+
kind, _, match = candidates.min_by { |candidate| candidate[1] }
|
|
329
|
+
|
|
330
|
+
case kind
|
|
331
|
+
when :cdata
|
|
332
|
+
cdata_end = content.index("]]>", cdata_index + 9)
|
|
333
|
+
return nil unless cdata_end
|
|
334
|
+
|
|
335
|
+
cursor = cdata_end + 3
|
|
336
|
+
when :open
|
|
337
|
+
depth += 1
|
|
338
|
+
cursor = match.end(0)
|
|
339
|
+
when :close
|
|
340
|
+
depth -= 1
|
|
341
|
+
return match if depth.zero?
|
|
342
|
+
|
|
343
|
+
cursor = match.end(0)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
nil
|
|
256
348
|
end
|
|
257
349
|
|
|
258
350
|
def escape_cdata(content)
|
|
@@ -260,40 +352,39 @@ module MjmlRb
|
|
|
260
352
|
end
|
|
261
353
|
|
|
262
354
|
# Escape bare "&" that are not part of a valid XML entity reference
|
|
263
|
-
# (e.g. & { ). This lets
|
|
264
|
-
# such as "Terms & Conditions" which is common in email templates.
|
|
355
|
+
# (e.g. & { ). This lets the XML parser handle HTML-ish
|
|
356
|
+
# content such as "Terms & Conditions" which is common in email templates.
|
|
265
357
|
def sanitize_bare_ampersands(content)
|
|
266
358
|
content.gsub(BARE_AMPERSAND_RE, "&")
|
|
267
359
|
end
|
|
268
360
|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
else # opening MJML tag
|
|
282
|
-
"#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
|
|
283
|
-
end
|
|
361
|
+
# Replace HTML named entities (e.g. , ©) with their numeric
|
|
362
|
+
# XML equivalents (e.g.  , ©). XML only defines five named
|
|
363
|
+
# entities (amp, lt, gt, quot, apos); all other named references from
|
|
364
|
+
# HTML must be converted to numeric form for the XML parser to accept them.
|
|
365
|
+
# Uses Nokogiri's built-in HTML entity lookup table.
|
|
366
|
+
def replace_html_entities(content)
|
|
367
|
+
content.gsub(/&([a-zA-Z][a-zA-Z0-9]*);/) do |match|
|
|
368
|
+
name = ::Regexp.last_match(1)
|
|
369
|
+
next match if XML_PREDEFINED_ENTITIES.include?(name)
|
|
370
|
+
|
|
371
|
+
codepoint = Nokogiri::HTML::NamedCharacters[name]
|
|
372
|
+
codepoint ? "&##{codepoint};" : match
|
|
284
373
|
end
|
|
285
374
|
end
|
|
286
375
|
|
|
287
|
-
|
|
376
|
+
XML_PREDEFINED_ENTITIES = %w[amp lt gt quot apos].freeze
|
|
377
|
+
|
|
378
|
+
# Recursively marks Nokogiri elements from included files with data-mjml-file.
|
|
288
379
|
# Only sets the attribute on elements that don't already have it (preserving
|
|
289
380
|
# deeper include annotations from recursive expansion).
|
|
290
381
|
def annotate_include_source(element, file_path)
|
|
291
|
-
return unless element.
|
|
382
|
+
return unless element.element?
|
|
292
383
|
|
|
293
|
-
if (element.name.start_with?("mj-") || element.name == "mjml") && !element
|
|
294
|
-
element
|
|
384
|
+
if (element.name.start_with?("mj-") || element.name == "mjml") && !element["data-mjml-file"]
|
|
385
|
+
element["data-mjml-file"] = file_path
|
|
295
386
|
end
|
|
296
|
-
element.
|
|
387
|
+
element.element_children.each { |child| annotate_include_source(child, file_path) }
|
|
297
388
|
end
|
|
298
389
|
|
|
299
390
|
def resolve_include_path(include_path, actual_path, file_path)
|
|
@@ -311,37 +402,24 @@ module MjmlRb
|
|
|
311
402
|
raise Errno::ENOENT, include_path
|
|
312
403
|
end
|
|
313
404
|
|
|
314
|
-
def deep_clone(node)
|
|
315
|
-
case node
|
|
316
|
-
when Element
|
|
317
|
-
clone = Element.new(node.name)
|
|
318
|
-
node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
|
|
319
|
-
node.children.each { |child| clone.add(deep_clone(child)) }
|
|
320
|
-
clone
|
|
321
|
-
when Text
|
|
322
|
-
Text.new(node.value)
|
|
323
|
-
when Comment
|
|
324
|
-
Comment.new(node.string)
|
|
325
|
-
else
|
|
326
|
-
node
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
405
|
def element_to_ast(element, keep_comments:)
|
|
331
406
|
raise ParseError, "Missing XML root element" unless element
|
|
332
407
|
|
|
333
|
-
# Extract metadata annotations (added by
|
|
408
|
+
# Extract metadata annotations (added by annotate_include_source)
|
|
334
409
|
# and strip them from the public attributes hash.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
410
|
+
# Line numbers come from Nokogiri's native node.line.
|
|
411
|
+
meta_line = element.line
|
|
412
|
+
meta_file = element["data-mjml-file"]
|
|
413
|
+
attrs = {}
|
|
414
|
+
element.attributes.each do |name, attr|
|
|
415
|
+
attrs[name] = attr.value unless name.start_with?("data-mjml-")
|
|
339
416
|
end
|
|
417
|
+
attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
|
|
340
418
|
|
|
341
419
|
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
342
420
|
# the raw HTML directly as content instead of parsing structurally.
|
|
343
421
|
if ENDING_TAGS_FOR_CDATA.include?(element.name)
|
|
344
|
-
raw_content = element.children.select { |c| c.
|
|
422
|
+
raw_content = element.children.select { |c| c.cdata? || c.text? }.map(&:content).join
|
|
345
423
|
return AstNode.new(
|
|
346
424
|
tag_name: element.name,
|
|
347
425
|
attributes: attrs,
|
|
@@ -353,14 +431,16 @@ module MjmlRb
|
|
|
353
431
|
end
|
|
354
432
|
|
|
355
433
|
children = element.children.each_with_object([]) do |child, memo|
|
|
356
|
-
|
|
357
|
-
when Element
|
|
434
|
+
if child.element?
|
|
358
435
|
memo << element_to_ast(child, keep_comments: keep_comments)
|
|
359
|
-
|
|
360
|
-
text = child.
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
436
|
+
elsif child.text? || child.cdata?
|
|
437
|
+
text = child.content
|
|
438
|
+
next if text.empty?
|
|
439
|
+
next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
|
|
440
|
+
|
|
441
|
+
memo << AstNode.new(tag_name: "#text", content: text)
|
|
442
|
+
elsif child.comment?
|
|
443
|
+
memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
|
|
364
444
|
end
|
|
365
445
|
end
|
|
366
446
|
|
|
@@ -372,5 +452,18 @@ module MjmlRb
|
|
|
372
452
|
file: meta_file
|
|
373
453
|
)
|
|
374
454
|
end
|
|
455
|
+
|
|
456
|
+
# Lenient XML parse used during include expansion and intermediate steps.
|
|
457
|
+
# Errors are collected but do not raise; the final strict parse in #parse
|
|
458
|
+
# will surface any real issues.
|
|
459
|
+
def parse_xml(xml)
|
|
460
|
+
Nokogiri::XML(replace_html_entities(xml))
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def ignorable_whitespace_text?(text, parent_element_name:)
|
|
464
|
+
return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
|
|
465
|
+
|
|
466
|
+
text.match?(/[\r\n]/)
|
|
467
|
+
end
|
|
375
468
|
end
|
|
376
469
|
end
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "cgi"
|
|
2
|
+
require "css_parser"
|
|
2
3
|
require "nokogiri"
|
|
3
4
|
require "set"
|
|
4
5
|
require_relative "components/accordion"
|
|
@@ -168,6 +169,8 @@ module MjmlRb
|
|
|
168
169
|
HTML
|
|
169
170
|
|
|
170
171
|
html = apply_inline_styles(html, context)
|
|
172
|
+
html = preserve_raw_tag_spacing(html)
|
|
173
|
+
html = strip_internal_raw_markers(html)
|
|
171
174
|
html = merge_outlook_conditionals(html)
|
|
172
175
|
before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
|
|
173
176
|
end
|
|
@@ -342,20 +345,27 @@ module MjmlRb
|
|
|
342
345
|
return html if css_blocks.empty?
|
|
343
346
|
|
|
344
347
|
document = parse_html_document(html)
|
|
345
|
-
rules,
|
|
348
|
+
rules, = parse_inline_css_rules(css_blocks.join("\n"))
|
|
349
|
+
merged_declarations_by_node = {}
|
|
350
|
+
touched_properties_by_node = Hash.new { |hash, node| hash[node] = Set.new }
|
|
346
351
|
|
|
347
352
|
rules.each do |selector, declarations|
|
|
348
353
|
next if selector.empty? || declarations.empty?
|
|
349
354
|
|
|
350
355
|
select_nodes(document, selector).each do |node|
|
|
351
|
-
|
|
356
|
+
existing = merged_declarations_by_node[node] ||= begin
|
|
357
|
+
source = node["data-mjml-raw"] == "true" ? :inline : :css
|
|
358
|
+
parsed = parse_css_declarations(node["style"].to_s, source: source)
|
|
359
|
+
touched_properties_by_node[node].merge(parsed.keys & HTML_ATTRIBUTE_SYNC_PROPERTIES.to_a) if source == :inline
|
|
360
|
+
parsed
|
|
361
|
+
end
|
|
362
|
+
merge_inline_declarations!(existing, declarations, touched_properties_by_node[node])
|
|
352
363
|
end
|
|
353
364
|
end
|
|
354
365
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
inject_preserved_at_rules(document, at_rules_css)
|
|
366
|
+
merged_declarations_by_node.each do |node, declarations|
|
|
367
|
+
finalize_inline_style!(node, declarations, touched_properties_by_node[node])
|
|
368
|
+
end
|
|
359
369
|
|
|
360
370
|
document.to_html
|
|
361
371
|
end
|
|
@@ -368,18 +378,6 @@ module MjmlRb
|
|
|
368
378
|
end
|
|
369
379
|
end
|
|
370
380
|
|
|
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
381
|
def select_nodes(document, selector)
|
|
384
382
|
document.css(selector)
|
|
385
383
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
@@ -420,146 +418,69 @@ module MjmlRb
|
|
|
420
418
|
end
|
|
421
419
|
|
|
422
420
|
def parse_inline_css_rules(css)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
rules =
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
421
|
+
parser = CssParser::Parser.new
|
|
422
|
+
parser.add_block!(css.to_s)
|
|
423
|
+
|
|
424
|
+
rules = []
|
|
425
|
+
parser.each_rule_set do |rule_set, media_types|
|
|
426
|
+
# Only inline rules not inside @media blocks; css_parser stores
|
|
427
|
+
# top-level rules under the :all media type.
|
|
428
|
+
next unless media_types == [:all]
|
|
429
|
+
|
|
430
|
+
rule_set.each_selector do |selector, declarations_str, specificity|
|
|
431
|
+
selector = selector.strip
|
|
432
|
+
# Skip @-rules like @font-face that css_parser yields as selectors
|
|
433
|
+
next if selector.start_with?("@")
|
|
434
|
+
|
|
435
|
+
declaration_map = parse_css_declarations(declarations_str)
|
|
436
|
+
rules << [selector, declaration_map, specificity]
|
|
437
|
+
end
|
|
430
438
|
end
|
|
431
439
|
|
|
432
440
|
# Sort rules by specificity (ascending). With the "last wins" merge
|
|
433
441
|
# strategy, higher-specificity rules applied later correctly override
|
|
434
442
|
# lower-specificity ones — matching CSS cascade behavior.
|
|
435
|
-
sorted = rules.
|
|
436
|
-
.
|
|
437
|
-
.map(&:first)
|
|
443
|
+
sorted = rules.sort_by.with_index { |(_, _, spec), idx| [spec, idx] }
|
|
444
|
+
.map { |sel, decl, _| [sel, decl] }
|
|
438
445
|
|
|
439
|
-
[sorted,
|
|
446
|
+
[sorted, ""]
|
|
440
447
|
end
|
|
441
448
|
|
|
442
|
-
def
|
|
443
|
-
|
|
449
|
+
def css_specificity(selector)
|
|
450
|
+
CssParser.calculate_specificity(selector.to_s)
|
|
444
451
|
end
|
|
445
452
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
# <style> block since @-rules cannot be inlined into style attributes.
|
|
449
|
-
def extract_css_at_rules(css)
|
|
450
|
-
plain = +""
|
|
451
|
-
at_rules = +""
|
|
452
|
-
index = 0
|
|
453
|
-
|
|
454
|
-
while index < css.length
|
|
455
|
-
if css[index] == "@"
|
|
456
|
-
brace_index = css.index("{", index)
|
|
457
|
-
semicolon_index = css.index(";", index)
|
|
458
|
-
|
|
459
|
-
# Simple @-rules like @import or @charset end with semicolon
|
|
460
|
-
if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
|
|
461
|
-
at_rules << css[index..semicolon_index] << "\n"
|
|
462
|
-
index = semicolon_index + 1
|
|
463
|
-
next
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
# Block @-rules like @media, @font-face have nested braces
|
|
467
|
-
if brace_index
|
|
468
|
-
depth = 1
|
|
469
|
-
cursor = brace_index + 1
|
|
470
|
-
while cursor < css.length && depth.positive?
|
|
471
|
-
depth += 1 if css[cursor] == "{"
|
|
472
|
-
depth -= 1 if css[cursor] == "}"
|
|
473
|
-
cursor += 1
|
|
474
|
-
end
|
|
475
|
-
at_rules << css[index...cursor] << "\n"
|
|
476
|
-
index = cursor
|
|
477
|
-
next
|
|
478
|
-
end
|
|
479
|
-
end
|
|
480
|
-
|
|
481
|
-
plain << css[index]
|
|
482
|
-
index += 1
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
[plain, at_rules]
|
|
486
|
-
end
|
|
453
|
+
def parse_css_declarations(declarations, source: :css)
|
|
454
|
+
return {} if declarations.nil? || declarations.to_s.strip.empty?
|
|
487
455
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
# and pseudo-classes (:hover, :lang())
|
|
492
|
-
# c = number of type selectors (div, p) and pseudo-elements (::before)
|
|
493
|
-
def css_specificity(selector)
|
|
494
|
-
s = selector.to_s
|
|
495
|
-
|
|
496
|
-
# a: ID selectors
|
|
497
|
-
a = s.scan(/#[\w-]+/).length
|
|
498
|
-
|
|
499
|
-
# b: class selectors + attribute selectors + pseudo-classes
|
|
500
|
-
b = s.scan(/\.[\w-]+/).length +
|
|
501
|
-
s.scan(/\[[^\]]*\]/).length +
|
|
502
|
-
s.scan(/:(?!:)[\w-]+/).length
|
|
503
|
-
|
|
504
|
-
# c: type selectors + pseudo-elements
|
|
505
|
-
# Strip everything except element names and combinators
|
|
506
|
-
cleaned = s
|
|
507
|
-
.gsub(/#[\w-]+/, "") # remove IDs
|
|
508
|
-
.gsub(/\.[\w-]+/, "") # remove classes
|
|
509
|
-
.gsub(/\[[^\]]*\]/, "") # remove attribute selectors
|
|
510
|
-
.gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
|
|
511
|
-
.gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
|
|
512
|
-
.gsub(/[>+~]/, " ") # combinators → spaces
|
|
513
|
-
.gsub(/\*/, "") # universal selector has no specificity
|
|
514
|
-
c = cleaned.split.reject(&:empty?).length +
|
|
515
|
-
s.scan(/::[\w-]+/).length
|
|
516
|
-
|
|
517
|
-
[a, b, c]
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
def parse_css_declarations(declarations)
|
|
521
|
-
declarations.split(";").each_with_object({}) do |entry, memo|
|
|
522
|
-
property, value = entry.split(":", 2).map { |part| part&.strip }
|
|
456
|
+
rule_set = CssParser::RuleSet.new(block: declarations.to_s)
|
|
457
|
+
result = {}
|
|
458
|
+
rule_set.each_declaration do |property, value, important|
|
|
523
459
|
next if property.nil? || property.empty? || value.nil? || value.empty?
|
|
524
460
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
461
|
+
result[property] = {
|
|
462
|
+
value: value,
|
|
463
|
+
important: important,
|
|
464
|
+
source: source
|
|
529
465
|
}
|
|
530
466
|
end
|
|
467
|
+
result
|
|
531
468
|
end
|
|
532
469
|
|
|
533
|
-
def
|
|
534
|
-
existing = parse_css_declarations(node["style"].to_s)
|
|
470
|
+
def merge_inline_declarations!(existing, declarations, touched_properties)
|
|
535
471
|
declarations.each do |property, value|
|
|
536
472
|
merged = merge_css_declaration(existing[property], value)
|
|
537
473
|
next if merged.equal?(existing[property])
|
|
538
474
|
|
|
539
475
|
existing.delete(property)
|
|
540
476
|
existing[property] = merged
|
|
477
|
+
touched_properties << property
|
|
541
478
|
end
|
|
542
|
-
normalize_background_fallbacks!(node, existing)
|
|
543
|
-
sync_html_attributes!(node, existing)
|
|
544
|
-
node["style"] = serialize_css_declarations(existing)
|
|
545
479
|
end
|
|
546
480
|
|
|
547
|
-
def
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
declarations.delete("background") if syncable_background?(declaration_value(declarations["background"]))
|
|
551
|
-
return
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
background_color = declaration_value(declarations["background-color"])
|
|
555
|
-
return if background_color.nil? || background_color.empty?
|
|
556
|
-
|
|
557
|
-
if syncable_background?(declaration_value(declarations["background"]))
|
|
558
|
-
declarations["background"] = {
|
|
559
|
-
value: background_color,
|
|
560
|
-
important: declarations.fetch("background-color", {}).fetch(:important, false)
|
|
561
|
-
}
|
|
562
|
-
end
|
|
481
|
+
def finalize_inline_style!(node, declarations, touched_properties)
|
|
482
|
+
sync_html_attributes!(node, declarations, touched_properties)
|
|
483
|
+
node["style"] = serialize_css_declarations(declarations)
|
|
563
484
|
end
|
|
564
485
|
|
|
565
486
|
# Sync HTML attributes from inlined CSS declarations.
|
|
@@ -575,15 +496,19 @@ module MjmlRb
|
|
|
575
496
|
"text-align" => "align",
|
|
576
497
|
"vertical-align" => "valign"
|
|
577
498
|
}.freeze
|
|
499
|
+
HTML_ATTRIBUTE_SYNC_PROPERTIES = Set.new((%w[width height] + STYLE_TO_ATTRIBUTE.keys)).freeze
|
|
578
500
|
|
|
579
|
-
def sync_html_attributes!(node, declarations)
|
|
501
|
+
def sync_html_attributes!(node, declarations, touched_properties = nil)
|
|
580
502
|
tag = node.name.downcase
|
|
581
503
|
|
|
582
504
|
# Sync width/height on TABLE, TD, TH, IMG
|
|
583
505
|
if WIDTH_HEIGHT_ELEMENTS.include?(tag)
|
|
584
506
|
%w[width height].each do |prop|
|
|
507
|
+
next if touched_properties && !touched_properties.include?(prop)
|
|
508
|
+
|
|
585
509
|
css_value = declaration_value(declarations[prop])
|
|
586
510
|
next if css_value.nil? || css_value.empty?
|
|
511
|
+
next if tag == "img" && prop == "width" && css_value.include?("%")
|
|
587
512
|
|
|
588
513
|
# Convert CSS px values to plain numbers for HTML attributes;
|
|
589
514
|
# keep other values (auto, %) as-is.
|
|
@@ -595,6 +520,8 @@ module MjmlRb
|
|
|
595
520
|
# Sync style-to-attribute mappings on table elements
|
|
596
521
|
if TABLE_ELEMENTS.include?(tag)
|
|
597
522
|
STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
|
|
523
|
+
next if touched_properties && !touched_properties.include?(css_prop)
|
|
524
|
+
|
|
598
525
|
css_value = declaration_value(declarations[css_prop])
|
|
599
526
|
next if css_value.nil? || css_value.empty?
|
|
600
527
|
|
|
@@ -615,26 +542,10 @@ module MjmlRb
|
|
|
615
542
|
end
|
|
616
543
|
end
|
|
617
544
|
|
|
618
|
-
def syncable_background?(value)
|
|
619
|
-
return true if value.nil? || value.empty?
|
|
620
|
-
|
|
621
|
-
normalized = value.downcase
|
|
622
|
-
!normalized.include?("url(") &&
|
|
623
|
-
!normalized.include?("gradient(") &&
|
|
624
|
-
!normalized.include?("/") &&
|
|
625
|
-
!normalized.include?(" no-repeat") &&
|
|
626
|
-
!normalized.include?(" repeat") &&
|
|
627
|
-
!normalized.include?(" fixed") &&
|
|
628
|
-
!normalized.include?(" scroll") &&
|
|
629
|
-
!normalized.include?(" center") &&
|
|
630
|
-
!normalized.include?(" top") &&
|
|
631
|
-
!normalized.include?(" bottom") &&
|
|
632
|
-
!normalized.include?(" left") &&
|
|
633
|
-
!normalized.include?(" right")
|
|
634
|
-
end
|
|
635
|
-
|
|
636
545
|
def merge_css_declaration(existing, incoming)
|
|
637
546
|
return incoming if existing.nil?
|
|
547
|
+
return incoming if incoming[:important] && !existing[:important]
|
|
548
|
+
return existing if existing[:source] == :inline
|
|
638
549
|
return existing if existing[:important] && !incoming[:important]
|
|
639
550
|
|
|
640
551
|
incoming
|
|
@@ -645,13 +556,49 @@ module MjmlRb
|
|
|
645
556
|
end
|
|
646
557
|
|
|
647
558
|
def serialize_css_declarations(declarations)
|
|
648
|
-
declarations.map do |property, declaration|
|
|
649
|
-
|
|
650
|
-
value = "#{value} !important" if declaration[:important]
|
|
651
|
-
"#{property}: #{value}"
|
|
559
|
+
ordered_css_declarations(declarations).map do |property, declaration|
|
|
560
|
+
"#{property}: #{declaration[:value]}"
|
|
652
561
|
end.join("; ")
|
|
653
562
|
end
|
|
654
563
|
|
|
564
|
+
SHORTHAND_LONGHAND_FAMILIES = {
|
|
565
|
+
"background" => /\Abackground-/,
|
|
566
|
+
"border" => /\Aborder(?:-(?!collapse|spacing)[a-z-]+)?\z/,
|
|
567
|
+
"border-radius" => /\Aborder-(?:top|bottom)-(?:left|right)-radius\z/,
|
|
568
|
+
"font" => /\Afont-/,
|
|
569
|
+
"list-style" => /\Alist-style-/,
|
|
570
|
+
"margin" => /\Amargin-(?:top|right|bottom|left)\z/,
|
|
571
|
+
"padding" => /\Apadding-(?:top|right|bottom|left)\z/
|
|
572
|
+
}.freeze
|
|
573
|
+
|
|
574
|
+
def ordered_css_declarations(declarations)
|
|
575
|
+
ordered = declarations.to_a
|
|
576
|
+
|
|
577
|
+
SHORTHAND_LONGHAND_FAMILIES.each do |shorthand, longhand_pattern|
|
|
578
|
+
family_indexes = ordered.each_index.select do |index|
|
|
579
|
+
property = ordered[index][0]
|
|
580
|
+
property == shorthand || property.match?(longhand_pattern)
|
|
581
|
+
end
|
|
582
|
+
next if family_indexes.length < 2
|
|
583
|
+
|
|
584
|
+
family_entries = family_indexes.map.with_index do |declaration_index, original_family_index|
|
|
585
|
+
[ordered[declaration_index], original_family_index]
|
|
586
|
+
end
|
|
587
|
+
next unless family_entries.any? { |((_, declaration), _)| declaration[:important] }
|
|
588
|
+
next unless family_entries.any? { |((_, declaration), _)| !declaration[:important] }
|
|
589
|
+
|
|
590
|
+
reordered_entries = family_entries.sort_by do |((_, declaration), original_family_index)|
|
|
591
|
+
[declaration[:important] ? 1 : 0, original_family_index]
|
|
592
|
+
end.map(&:first)
|
|
593
|
+
|
|
594
|
+
family_indexes.each_with_index do |declaration_index, reordered_index|
|
|
595
|
+
ordered[declaration_index] = reordered_entries[reordered_index]
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
ordered
|
|
600
|
+
end
|
|
601
|
+
|
|
655
602
|
def append_component_head_styles(document, context)
|
|
656
603
|
all_tags = collect_tag_names(document)
|
|
657
604
|
|
|
@@ -746,7 +693,7 @@ module MjmlRb
|
|
|
746
693
|
if node.respond_to?(:children)
|
|
747
694
|
node.children.map do |child|
|
|
748
695
|
if child.text?
|
|
749
|
-
escape_html(child.content.to_s)
|
|
696
|
+
serialize_text_content(escape_html(child.content.to_s))
|
|
750
697
|
elsif child.comment?
|
|
751
698
|
"<!--#{child.content}-->"
|
|
752
699
|
else
|
|
@@ -765,7 +712,7 @@ module MjmlRb
|
|
|
765
712
|
if node.respond_to?(:children)
|
|
766
713
|
node.children.map do |child|
|
|
767
714
|
if child.text?
|
|
768
|
-
child.content.to_s
|
|
715
|
+
serialize_text_content(child.content.to_s)
|
|
769
716
|
elsif child.comment?
|
|
770
717
|
"<!--#{child.content}-->"
|
|
771
718
|
else
|
|
@@ -777,15 +724,52 @@ module MjmlRb
|
|
|
777
724
|
end
|
|
778
725
|
end
|
|
779
726
|
|
|
727
|
+
def annotate_raw_html(content)
|
|
728
|
+
return content if content.nil? || content.empty? || !content.include?("<")
|
|
729
|
+
content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
|
|
730
|
+
tag_name = Regexp.last_match(1)
|
|
731
|
+
attrs = Regexp.last_match(2).to_s
|
|
732
|
+
closing = Regexp.last_match(3).to_s
|
|
733
|
+
"<#{tag_name}#{attrs} data-mjml-raw=\"true\"#{closing}>"
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def strip_internal_raw_markers(html)
|
|
738
|
+
html.gsub(/\sdata-mjml-raw=(['"])true\1/, "")
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def preserve_raw_tag_spacing(html)
|
|
742
|
+
html.gsub(
|
|
743
|
+
/(<[^>]+data-mjml-raw=(['"])true\2[^>]*>)([ \t]+)(<[^\/!][^>]+data-mjml-raw=(['"])true\5[^>]*>)/
|
|
744
|
+
) do
|
|
745
|
+
"#{Regexp.last_match(1)}#{encode_whitespace_entities(Regexp.last_match(3))}#{Regexp.last_match(4)}"
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def encode_whitespace_entities(text)
|
|
750
|
+
text.to_s.gsub(" ", " ").gsub("\t", "	")
|
|
751
|
+
end
|
|
752
|
+
|
|
780
753
|
def serialize_node(node)
|
|
781
754
|
attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
|
|
782
755
|
return "<#{node.tag_name}#{attrs} />" if node.children.empty? && html_void_tag?(node.tag_name)
|
|
783
756
|
return "<#{node.tag_name}#{attrs}></#{node.tag_name}>" if node.children.empty?
|
|
784
757
|
|
|
785
|
-
inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
|
|
758
|
+
inner = node.children.map { |child| child.text? ? serialize_text_content(child.content.to_s) : serialize_node(child) }.join
|
|
786
759
|
"<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
|
|
787
760
|
end
|
|
788
761
|
|
|
762
|
+
def serialize_text_content(text)
|
|
763
|
+
value = text.to_s
|
|
764
|
+
return value unless significant_whitespace_text?(value)
|
|
765
|
+
|
|
766
|
+
value.gsub(" ", " ").gsub("\t", "	")
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def significant_whitespace_text?(text)
|
|
770
|
+
!text.empty? && text.strip.empty? && !text.match?(/[\r\n]/)
|
|
771
|
+
end
|
|
772
|
+
|
|
789
773
|
def html_void_tag?(tag_name)
|
|
790
774
|
HTML_VOID_TAGS.include?(tag_name.to_s.downcase)
|
|
791
775
|
end
|
data/lib/mjml-rb/validator.rb
CHANGED
|
@@ -77,8 +77,8 @@ module MjmlRb
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def validate_allowed_children(node, errors)
|
|
80
|
-
# Ending-tag components treat content as raw HTML;
|
|
81
|
-
# children structurally, so skip child validation for those tags.
|
|
80
|
+
# Ending-tag components treat content as raw HTML; the XML parser still
|
|
81
|
+
# parses children structurally, so skip child validation for those tags.
|
|
82
82
|
return if MjmlRb.component_registry.ending_tags.include?(node.tag_name)
|
|
83
83
|
|
|
84
84
|
allowed = MjmlRb.component_registry.dependency_rules[node.tag_name]
|
data/lib/mjml-rb/version.rb
CHANGED
data/mjml-rb.gemspec
CHANGED
|
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.summary = "Ruby implementation of the MJML toolchain"
|
|
10
10
|
spec.description = "Ruby-first MJML compiler API and CLI with compatibility-focused behavior."
|
|
11
11
|
spec.license = "MIT"
|
|
12
|
-
spec.required_ruby_version = ">= 3.
|
|
12
|
+
spec.required_ruby_version = ">= 3.3"
|
|
13
13
|
|
|
14
14
|
spec.homepage = "https://github.com/faraquet/mjml-rb"
|
|
15
15
|
spec.files = Dir.chdir(__dir__) do
|
|
@@ -18,6 +18,6 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.bindir = "bin"
|
|
19
19
|
spec.executables = ["mjml"]
|
|
20
20
|
spec.require_paths = ["lib"]
|
|
21
|
+
spec.add_dependency "css_parser", ">= 1.17"
|
|
21
22
|
spec.add_dependency "nokogiri", ">= 1.13"
|
|
22
|
-
spec.add_dependency "rexml", ">= 3.2.5"
|
|
23
23
|
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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Andriichuk
|
|
@@ -10,33 +10,33 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: css_parser
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '1.
|
|
18
|
+
version: '1.17'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '1.
|
|
25
|
+
version: '1.17'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: nokogiri
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
32
|
+
version: '1.13'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
39
|
+
version: '1.13'
|
|
40
40
|
description: Ruby-first MJML compiler API and CLI with compatibility-focused behavior.
|
|
41
41
|
email:
|
|
42
42
|
- andreiandriichuk@gmail.com
|
|
@@ -99,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
99
99
|
requirements:
|
|
100
100
|
- - ">="
|
|
101
101
|
- !ruby/object:Gem::Version
|
|
102
|
-
version: '3.
|
|
102
|
+
version: '3.3'
|
|
103
103
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
104
|
requirements:
|
|
105
105
|
- - ">="
|