mjml-rb 0.2.32 → 0.2.33
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/text.rb +1 -1
- data/lib/mjml-rb/parser.rb +109 -15
- data/lib/mjml-rb/renderer.rb +78 -7
- 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: 6147ba24e9e7ede9406ebbaa48655e885f72e7f61d14c0da37ecde7ecdc66a68
|
|
4
|
+
data.tar.gz: 6809e1ba8ee7927d565831840a5938dea5bc37abfaff3691d4b60dd4eacd67d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 961a93196418591ce8d2f485697ac7cdf7bd4ef2cbf2f53eac02b0811190b324e9d035c442d6d53b48e3faa94db47c44a3b32098354e36f5348ae2b4d1351372
|
|
7
|
+
data.tar.gz: 98a28a390dda4f04d50ab4170c8e277cb7546023bcb5da0aefd242e2524a00d9beb5a91d5c28c18dc308644072911630da43437a5f2b06d96972aa3b13454175
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -8,6 +8,15 @@ module MjmlRb
|
|
|
8
8
|
include REXML
|
|
9
9
|
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
10
10
|
|
|
11
|
+
# Ending-tag components whose inner HTML is preserved as raw content via CDATA
|
|
12
|
+
# wrapping, matching upstream npm's endingTag behavior. mj-table is excluded
|
|
13
|
+
# because its component needs structural AST access for attribute normalization.
|
|
14
|
+
# mj-carousel-image is excluded because it has no meaningful inner content.
|
|
15
|
+
ENDING_TAGS_FOR_CDATA = %w[
|
|
16
|
+
mj-accordion-text mj-accordion-title mj-button
|
|
17
|
+
mj-navbar-link mj-raw mj-text
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
11
20
|
class ParseError < StandardError
|
|
12
21
|
attr_reader :line
|
|
13
22
|
|
|
@@ -20,7 +29,7 @@ module MjmlRb
|
|
|
20
29
|
def parse(mjml, options = {})
|
|
21
30
|
opts = normalize_options(options)
|
|
22
31
|
xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
|
|
23
|
-
xml =
|
|
32
|
+
xml = wrap_ending_tags_in_cdata(xml)
|
|
24
33
|
xml = normalize_html_void_tags(xml)
|
|
25
34
|
xml = expand_includes(xml, opts) unless opts[:ignore_includes]
|
|
26
35
|
|
|
@@ -48,29 +57,66 @@ module MjmlRb
|
|
|
48
57
|
end
|
|
49
58
|
end
|
|
50
59
|
|
|
51
|
-
def expand_includes(xml, options)
|
|
52
|
-
xml =
|
|
60
|
+
def expand_includes(xml, options, included_in = [])
|
|
61
|
+
xml = wrap_ending_tags_in_cdata(xml)
|
|
53
62
|
xml = normalize_html_void_tags(xml)
|
|
54
63
|
doc = Document.new(sanitize_bare_ampersands(xml))
|
|
55
64
|
includes = XPath.match(doc, "//mj-include")
|
|
56
65
|
return xml if includes.empty?
|
|
57
66
|
|
|
67
|
+
css_includes = []
|
|
68
|
+
|
|
58
69
|
includes.reverse_each do |include_node|
|
|
59
70
|
path_attr = include_node.attributes["path"]
|
|
60
71
|
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
61
72
|
|
|
62
73
|
include_type = include_node.attributes["type"].to_s
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
parent = include_node.parent
|
|
75
|
+
|
|
76
|
+
resolved_path = begin
|
|
77
|
+
resolve_include_path(path_attr, options[:actual_path], options[:file_path])
|
|
78
|
+
rescue Errno::ENOENT
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
include_content = resolved_path ? File.read(resolved_path) : nil
|
|
83
|
+
|
|
84
|
+
if include_content.nil?
|
|
85
|
+
# Collect error as an mj-raw comment node instead of raising
|
|
86
|
+
display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
|
|
87
|
+
error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
|
|
88
|
+
error_node = Element.new("mj-raw")
|
|
89
|
+
error_node.add(CData.new(error_comment))
|
|
90
|
+
parent.insert_before(include_node, error_node)
|
|
91
|
+
parent.delete(include_node)
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Circular include detection
|
|
96
|
+
if included_in.include?(resolved_path)
|
|
97
|
+
raise ParseError, "Circular inclusion detected on file : #{resolved_path}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if include_type == "css"
|
|
101
|
+
# CSS includes get collected and added to mj-head later
|
|
102
|
+
css_inline = include_node.attributes["css-inline"].to_s
|
|
103
|
+
css_includes << { content: include_content, inline: css_inline == "inline" }
|
|
104
|
+
parent.delete(include_node)
|
|
105
|
+
next
|
|
106
|
+
end
|
|
65
107
|
|
|
66
108
|
replacement = if include_type == "html"
|
|
67
109
|
%(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
|
|
68
110
|
else
|
|
69
|
-
|
|
111
|
+
prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
|
|
112
|
+
# Recursively expand includes in the included content
|
|
113
|
+
expand_includes(prepared, options.merge(
|
|
114
|
+
actual_path: resolved_path,
|
|
115
|
+
file_path: File.dirname(resolved_path)
|
|
116
|
+
), included_in + [resolved_path])
|
|
70
117
|
end
|
|
71
118
|
|
|
72
119
|
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
73
|
-
parent = include_node.parent
|
|
74
120
|
insert_before = include_node
|
|
75
121
|
fragment.root.children.each do |child|
|
|
76
122
|
parent.insert_before(insert_before, deep_clone(child))
|
|
@@ -78,15 +124,44 @@ module MjmlRb
|
|
|
78
124
|
parent.delete(include_node)
|
|
79
125
|
end
|
|
80
126
|
|
|
127
|
+
# Inject CSS includes into mj-head
|
|
128
|
+
unless css_includes.empty?
|
|
129
|
+
inject_css_includes(doc, css_includes)
|
|
130
|
+
end
|
|
131
|
+
|
|
81
132
|
output = +""
|
|
82
133
|
doc.write(output)
|
|
83
134
|
output
|
|
84
|
-
rescue Errno::ENOENT => e
|
|
85
|
-
raise ParseError, "Cannot read included file: #{e.message}"
|
|
86
135
|
rescue ParseException => e
|
|
87
136
|
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
88
137
|
end
|
|
89
138
|
|
|
139
|
+
def inject_css_includes(doc, css_includes)
|
|
140
|
+
mjml_root = doc.root
|
|
141
|
+
return unless mjml_root
|
|
142
|
+
|
|
143
|
+
# Find or create mj-head
|
|
144
|
+
head = XPath.first(mjml_root, "mj-head")
|
|
145
|
+
unless head
|
|
146
|
+
head = Element.new("mj-head")
|
|
147
|
+
# Insert mj-head before mj-body if possible
|
|
148
|
+
body = XPath.first(mjml_root, "mj-body")
|
|
149
|
+
if body
|
|
150
|
+
mjml_root.insert_before(body, head)
|
|
151
|
+
else
|
|
152
|
+
mjml_root.add(head)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Add each CSS include as an mj-style element
|
|
157
|
+
css_includes.each do |css_include|
|
|
158
|
+
style_node = Element.new("mj-style")
|
|
159
|
+
style_node.add_attribute("inline", "inline") if css_include[:inline]
|
|
160
|
+
style_node.add(CData.new(css_include[:content]))
|
|
161
|
+
head.add(style_node)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
90
165
|
def strip_xml_declaration(content)
|
|
91
166
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
92
167
|
end
|
|
@@ -107,14 +182,20 @@ module MjmlRb
|
|
|
107
182
|
end
|
|
108
183
|
end
|
|
109
184
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
185
|
+
def wrap_ending_tags_in_cdata(content)
|
|
186
|
+
tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
|
|
187
|
+
# Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
|
|
188
|
+
content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
|
|
189
|
+
tag = Regexp.last_match(1)
|
|
190
|
+
attrs = Regexp.last_match(2).to_s
|
|
191
|
+
inner = Regexp.last_match(3).to_s
|
|
114
192
|
if inner.include?("<![CDATA[")
|
|
115
|
-
"
|
|
193
|
+
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
116
194
|
else
|
|
117
|
-
|
|
195
|
+
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
196
|
+
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
197
|
+
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
198
|
+
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
118
199
|
end
|
|
119
200
|
end
|
|
120
201
|
end
|
|
@@ -164,6 +245,19 @@ module MjmlRb
|
|
|
164
245
|
def element_to_ast(element, keep_comments:)
|
|
165
246
|
raise ParseError, "Missing XML root element" unless element
|
|
166
247
|
|
|
248
|
+
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
249
|
+
# the raw HTML directly as content instead of parsing structurally.
|
|
250
|
+
if ENDING_TAGS_FOR_CDATA.include?(element.name)
|
|
251
|
+
raw_content = element.children.select { |c| c.is_a?(Text) }.map(&:value).join
|
|
252
|
+
return AstNode.new(
|
|
253
|
+
tag_name: element.name,
|
|
254
|
+
attributes: element.attributes.each_with_object({}) { |(name, val), h| h[name] = val },
|
|
255
|
+
children: [],
|
|
256
|
+
content: raw_content.empty? ? nil : raw_content,
|
|
257
|
+
line: nil
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
167
261
|
children = element.children.each_with_object([]) do |child, memo|
|
|
168
262
|
case child
|
|
169
263
|
when Element
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -296,7 +296,9 @@ module MjmlRb
|
|
|
296
296
|
return html if css_blocks.empty?
|
|
297
297
|
|
|
298
298
|
document = parse_html_document(html)
|
|
299
|
-
parse_inline_css_rules(css_blocks.join("\n"))
|
|
299
|
+
rules, at_rules_css = parse_inline_css_rules(css_blocks.join("\n"))
|
|
300
|
+
|
|
301
|
+
rules.each do |selector, declarations|
|
|
300
302
|
next if selector.empty? || declarations.empty?
|
|
301
303
|
|
|
302
304
|
select_nodes(document, selector).each do |node|
|
|
@@ -304,6 +306,11 @@ module MjmlRb
|
|
|
304
306
|
end
|
|
305
307
|
end
|
|
306
308
|
|
|
309
|
+
# Inject preserved @-rules (@media, @font-face, etc.) as a <style> block.
|
|
310
|
+
# These rules cannot be inlined into style attributes but should be kept
|
|
311
|
+
# in the document for runtime application by email clients.
|
|
312
|
+
inject_preserved_at_rules(document, at_rules_css)
|
|
313
|
+
|
|
307
314
|
document.to_html
|
|
308
315
|
end
|
|
309
316
|
|
|
@@ -315,6 +322,18 @@ module MjmlRb
|
|
|
315
322
|
end
|
|
316
323
|
end
|
|
317
324
|
|
|
325
|
+
def inject_preserved_at_rules(document, at_rules_css)
|
|
326
|
+
return if at_rules_css.nil? || at_rules_css.strip.empty?
|
|
327
|
+
|
|
328
|
+
head = document.at_css("head")
|
|
329
|
+
return unless head
|
|
330
|
+
|
|
331
|
+
style = Nokogiri::XML::Node.new("style", document)
|
|
332
|
+
style["type"] = "text/css"
|
|
333
|
+
style.content = at_rules_css.strip
|
|
334
|
+
head.add_child(style)
|
|
335
|
+
end
|
|
336
|
+
|
|
318
337
|
def select_nodes(document, selector)
|
|
319
338
|
document.css(selector)
|
|
320
339
|
rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
|
|
@@ -356,21 +375,34 @@ module MjmlRb
|
|
|
356
375
|
|
|
357
376
|
def parse_inline_css_rules(css)
|
|
358
377
|
stripped_css = strip_css_comments(css.to_s)
|
|
359
|
-
plain_css =
|
|
378
|
+
plain_css, at_rules_css = extract_css_at_rules(stripped_css)
|
|
360
379
|
|
|
361
|
-
plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
|
|
380
|
+
rules = plain_css.scan(/([^{}]+)\{([^{}]+)\}/m).flat_map do |selector_group, declarations|
|
|
362
381
|
selectors = selector_group.split(",").map(&:strip).reject(&:empty?)
|
|
363
382
|
declaration_map = parse_css_declarations(declarations)
|
|
364
383
|
selectors.map { |selector| [selector, declaration_map] }
|
|
365
384
|
end
|
|
385
|
+
|
|
386
|
+
# Sort rules by specificity (ascending). With the "last wins" merge
|
|
387
|
+
# strategy, higher-specificity rules applied later correctly override
|
|
388
|
+
# lower-specificity ones — matching CSS cascade behavior.
|
|
389
|
+
sorted = rules.each_with_index
|
|
390
|
+
.sort_by { |(selector, _), idx| [css_specificity(selector), idx] }
|
|
391
|
+
.map(&:first)
|
|
392
|
+
|
|
393
|
+
[sorted, at_rules_css]
|
|
366
394
|
end
|
|
367
395
|
|
|
368
396
|
def strip_css_comments(css)
|
|
369
397
|
css.gsub(%r{/\*.*?\*/}m, "")
|
|
370
398
|
end
|
|
371
399
|
|
|
372
|
-
|
|
373
|
-
|
|
400
|
+
# Separates @-rules (@media, @font-face, etc.) from plain CSS selectors.
|
|
401
|
+
# Returns [plain_css, at_rules_css]. The at_rules_css can be injected as a
|
|
402
|
+
# <style> block since @-rules cannot be inlined into style attributes.
|
|
403
|
+
def extract_css_at_rules(css)
|
|
404
|
+
plain = +""
|
|
405
|
+
at_rules = +""
|
|
374
406
|
index = 0
|
|
375
407
|
|
|
376
408
|
while index < css.length
|
|
@@ -378,11 +410,14 @@ module MjmlRb
|
|
|
378
410
|
brace_index = css.index("{", index)
|
|
379
411
|
semicolon_index = css.index(";", index)
|
|
380
412
|
|
|
413
|
+
# Simple @-rules like @import or @charset end with semicolon
|
|
381
414
|
if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
|
|
415
|
+
at_rules << css[index..semicolon_index] << "\n"
|
|
382
416
|
index = semicolon_index + 1
|
|
383
417
|
next
|
|
384
418
|
end
|
|
385
419
|
|
|
420
|
+
# Block @-rules like @media, @font-face have nested braces
|
|
386
421
|
if brace_index
|
|
387
422
|
depth = 1
|
|
388
423
|
cursor = brace_index + 1
|
|
@@ -391,16 +426,49 @@ module MjmlRb
|
|
|
391
426
|
depth -= 1 if css[cursor] == "}"
|
|
392
427
|
cursor += 1
|
|
393
428
|
end
|
|
429
|
+
at_rules << css[index...cursor] << "\n"
|
|
394
430
|
index = cursor
|
|
395
431
|
next
|
|
396
432
|
end
|
|
397
433
|
end
|
|
398
434
|
|
|
399
|
-
|
|
435
|
+
plain << css[index]
|
|
400
436
|
index += 1
|
|
401
437
|
end
|
|
402
438
|
|
|
403
|
-
|
|
439
|
+
[plain, at_rules]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Calculates CSS specificity as a comparable [a, b, c] tuple:
|
|
443
|
+
# a = number of ID selectors (#id)
|
|
444
|
+
# b = number of class selectors (.class), attribute selectors ([attr]),
|
|
445
|
+
# and pseudo-classes (:hover, :lang())
|
|
446
|
+
# c = number of type selectors (div, p) and pseudo-elements (::before)
|
|
447
|
+
def css_specificity(selector)
|
|
448
|
+
s = selector.to_s
|
|
449
|
+
|
|
450
|
+
# a: ID selectors
|
|
451
|
+
a = s.scan(/#[\w-]+/).length
|
|
452
|
+
|
|
453
|
+
# b: class selectors + attribute selectors + pseudo-classes
|
|
454
|
+
b = s.scan(/\.[\w-]+/).length +
|
|
455
|
+
s.scan(/\[[^\]]*\]/).length +
|
|
456
|
+
s.scan(/:(?!:)[\w-]+/).length
|
|
457
|
+
|
|
458
|
+
# c: type selectors + pseudo-elements
|
|
459
|
+
# Strip everything except element names and combinators
|
|
460
|
+
cleaned = s
|
|
461
|
+
.gsub(/#[\w-]+/, "") # remove IDs
|
|
462
|
+
.gsub(/\.[\w-]+/, "") # remove classes
|
|
463
|
+
.gsub(/\[[^\]]*\]/, "") # remove attribute selectors
|
|
464
|
+
.gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
|
|
465
|
+
.gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
|
|
466
|
+
.gsub(/[>+~]/, " ") # combinators → spaces
|
|
467
|
+
.gsub(/\*/, "") # universal selector has no specificity
|
|
468
|
+
c = cleaned.split.reject(&:empty?).length +
|
|
469
|
+
s.scan(/::[\w-]+/).length
|
|
470
|
+
|
|
471
|
+
[a, b, c]
|
|
404
472
|
end
|
|
405
473
|
|
|
406
474
|
def parse_css_declarations(declarations)
|
|
@@ -580,6 +648,9 @@ module MjmlRb
|
|
|
580
648
|
end
|
|
581
649
|
|
|
582
650
|
def raw_inner(node)
|
|
651
|
+
# For ending-tag nodes whose content was preserved as raw HTML by the parser
|
|
652
|
+
return node.content if node.element? && node.content
|
|
653
|
+
|
|
583
654
|
if node.respond_to?(:children)
|
|
584
655
|
node.children.map do |child|
|
|
585
656
|
if child.text?
|
data/lib/mjml-rb/version.rb
CHANGED