mjml-rb 0.2.32 → 0.2.34
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/ast_node.rb +3 -2
- data/lib/mjml-rb/components/text.rb +1 -1
- data/lib/mjml-rb/parser.rb +154 -18
- data/lib/mjml-rb/renderer.rb +78 -7
- data/lib/mjml-rb/validator.rb +14 -6
- 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: bae58d9f87e82dc3c39bd0c9fca4949bb2a953cf8e22a2dd56fa2d5ff057a566
|
|
4
|
+
data.tar.gz: b892f3ed57cf49cf3935a60cd8abef4f232544b0bb2abf2999ef5a557b49849f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 320fa67da655fcb902557e784e8f1411ab94316b0c1d9e18b2890a6e25fba015ce159fe8ae6e404fbfa60273c9db3a0fdb45320abc9d72eba19a7eff8867706a
|
|
7
|
+
data.tar.gz: e6e3176e0c1cf87791bac2625ea520c339a4059f9ef474b74e16cc2ffd6bd8c1664caa35bd429e2c963d4f39167051fbaf7e391669d44cec93e7788664e5d519
|
data/lib/mjml-rb/ast_node.rb
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
module MjmlRb
|
|
2
2
|
class AstNode
|
|
3
|
-
attr_reader :tag_name, :attributes, :children, :content, :line
|
|
3
|
+
attr_reader :tag_name, :attributes, :children, :content, :line, :file
|
|
4
4
|
|
|
5
|
-
def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil)
|
|
5
|
+
def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil, file: nil)
|
|
6
6
|
@tag_name = tag_name.to_s
|
|
7
7
|
@attributes = attributes.transform_keys(&:to_s)
|
|
8
8
|
@children = Array(children)
|
|
9
9
|
@content = content
|
|
10
10
|
@line = line
|
|
11
|
+
@file = file
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def text?
|
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,11 +29,12 @@ 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
|
|
|
27
|
-
|
|
36
|
+
xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
|
|
37
|
+
doc = Document.new(xml)
|
|
28
38
|
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
29
39
|
rescue ParseException => e
|
|
30
40
|
raise ParseError.new("XML parse error: #{e.message}")
|
|
@@ -48,45 +58,112 @@ module MjmlRb
|
|
|
48
58
|
end
|
|
49
59
|
end
|
|
50
60
|
|
|
51
|
-
def expand_includes(xml, options)
|
|
52
|
-
xml =
|
|
61
|
+
def expand_includes(xml, options, included_in = [])
|
|
62
|
+
xml = wrap_ending_tags_in_cdata(xml)
|
|
53
63
|
xml = normalize_html_void_tags(xml)
|
|
54
64
|
doc = Document.new(sanitize_bare_ampersands(xml))
|
|
55
65
|
includes = XPath.match(doc, "//mj-include")
|
|
56
66
|
return xml if includes.empty?
|
|
57
67
|
|
|
68
|
+
css_includes = []
|
|
69
|
+
|
|
58
70
|
includes.reverse_each do |include_node|
|
|
59
71
|
path_attr = include_node.attributes["path"]
|
|
60
72
|
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
61
73
|
|
|
62
74
|
include_type = include_node.attributes["type"].to_s
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
parent = include_node.parent
|
|
76
|
+
|
|
77
|
+
resolved_path = begin
|
|
78
|
+
resolve_include_path(path_attr, options[:actual_path], options[:file_path])
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
include_content = resolved_path ? File.read(resolved_path) : nil
|
|
84
|
+
|
|
85
|
+
if include_content.nil?
|
|
86
|
+
# Collect error as an mj-raw comment node instead of raising
|
|
87
|
+
display_path = resolved_path || File.expand_path(path_attr, options[:file_path].to_s)
|
|
88
|
+
error_comment = "<!-- mj-include fails to read file : #{path_attr} at #{display_path} -->"
|
|
89
|
+
error_node = Element.new("mj-raw")
|
|
90
|
+
error_node.add(CData.new(error_comment))
|
|
91
|
+
parent.insert_before(include_node, error_node)
|
|
92
|
+
parent.delete(include_node)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Circular include detection
|
|
97
|
+
if included_in.include?(resolved_path)
|
|
98
|
+
raise ParseError, "Circular inclusion detected on file : #{resolved_path}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if include_type == "css"
|
|
102
|
+
# CSS includes get collected and added to mj-head later
|
|
103
|
+
css_inline = include_node.attributes["css-inline"].to_s
|
|
104
|
+
css_includes << { content: include_content, inline: css_inline == "inline" }
|
|
105
|
+
parent.delete(include_node)
|
|
106
|
+
next
|
|
107
|
+
end
|
|
65
108
|
|
|
66
109
|
replacement = if include_type == "html"
|
|
67
110
|
%(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
|
|
68
111
|
else
|
|
69
|
-
|
|
112
|
+
prepared = wrap_ending_tags_in_cdata(normalize_html_void_tags(strip_xml_declaration(include_content)))
|
|
113
|
+
# Recursively expand includes in the included content
|
|
114
|
+
expand_includes(prepared, options.merge(
|
|
115
|
+
actual_path: resolved_path,
|
|
116
|
+
file_path: File.dirname(resolved_path)
|
|
117
|
+
), included_in + [resolved_path])
|
|
70
118
|
end
|
|
71
119
|
|
|
72
120
|
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
73
|
-
parent = include_node.parent
|
|
74
121
|
insert_before = include_node
|
|
75
122
|
fragment.root.children.each do |child|
|
|
123
|
+
annotate_include_source(child, resolved_path) if child.is_a?(Element)
|
|
76
124
|
parent.insert_before(insert_before, deep_clone(child))
|
|
77
125
|
end
|
|
78
126
|
parent.delete(include_node)
|
|
79
127
|
end
|
|
80
128
|
|
|
129
|
+
# Inject CSS includes into mj-head
|
|
130
|
+
unless css_includes.empty?
|
|
131
|
+
inject_css_includes(doc, css_includes)
|
|
132
|
+
end
|
|
133
|
+
|
|
81
134
|
output = +""
|
|
82
135
|
doc.write(output)
|
|
83
136
|
output
|
|
84
|
-
rescue Errno::ENOENT => e
|
|
85
|
-
raise ParseError, "Cannot read included file: #{e.message}"
|
|
86
137
|
rescue ParseException => e
|
|
87
138
|
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
88
139
|
end
|
|
89
140
|
|
|
141
|
+
def inject_css_includes(doc, css_includes)
|
|
142
|
+
mjml_root = doc.root
|
|
143
|
+
return unless mjml_root
|
|
144
|
+
|
|
145
|
+
# Find or create mj-head
|
|
146
|
+
head = XPath.first(mjml_root, "mj-head")
|
|
147
|
+
unless head
|
|
148
|
+
head = Element.new("mj-head")
|
|
149
|
+
# Insert mj-head before mj-body if possible
|
|
150
|
+
body = XPath.first(mjml_root, "mj-body")
|
|
151
|
+
if body
|
|
152
|
+
mjml_root.insert_before(body, head)
|
|
153
|
+
else
|
|
154
|
+
mjml_root.add(head)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Add each CSS include as an mj-style element
|
|
159
|
+
css_includes.each do |css_include|
|
|
160
|
+
style_node = Element.new("mj-style")
|
|
161
|
+
style_node.add_attribute("inline", "inline") if css_include[:inline]
|
|
162
|
+
style_node.add(CData.new(css_include[:content]))
|
|
163
|
+
head.add(style_node)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
90
167
|
def strip_xml_declaration(content)
|
|
91
168
|
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
92
169
|
end
|
|
@@ -107,14 +184,20 @@ module MjmlRb
|
|
|
107
184
|
end
|
|
108
185
|
end
|
|
109
186
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
187
|
+
def wrap_ending_tags_in_cdata(content)
|
|
188
|
+
tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
|
|
189
|
+
# Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
|
|
190
|
+
content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
|
|
191
|
+
tag = Regexp.last_match(1)
|
|
192
|
+
attrs = Regexp.last_match(2).to_s
|
|
193
|
+
inner = Regexp.last_match(3).to_s
|
|
114
194
|
if inner.include?("<![CDATA[")
|
|
115
|
-
"
|
|
195
|
+
"<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
116
196
|
else
|
|
117
|
-
|
|
197
|
+
# Pre-process content: normalize void tags and sanitize bare ampersands
|
|
198
|
+
# before wrapping in CDATA, so the raw HTML is well-formed for output.
|
|
199
|
+
prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
|
|
200
|
+
"<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
|
|
118
201
|
end
|
|
119
202
|
end
|
|
120
203
|
end
|
|
@@ -130,6 +213,36 @@ module MjmlRb
|
|
|
130
213
|
content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&")
|
|
131
214
|
end
|
|
132
215
|
|
|
216
|
+
# Adds data-mjml-line attributes to MJML tags so line numbers survive
|
|
217
|
+
# REXML parsing (which doesn't expose source positions).
|
|
218
|
+
# Skips content inside CDATA sections to avoid modifying raw HTML.
|
|
219
|
+
def annotate_line_numbers(xml)
|
|
220
|
+
line = 1
|
|
221
|
+
xml.gsub(/(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m) do
|
|
222
|
+
if Regexp.last_match(1) # newline
|
|
223
|
+
line += 1
|
|
224
|
+
"\n"
|
|
225
|
+
elsif Regexp.last_match(2) # CDATA section — count newlines, pass through
|
|
226
|
+
line += Regexp.last_match(2).count("\n")
|
|
227
|
+
Regexp.last_match(2)
|
|
228
|
+
else # opening MJML tag
|
|
229
|
+
"#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Recursively marks REXML elements from included files with data-mjml-file.
|
|
235
|
+
# Only sets the attribute on elements that don't already have it (preserving
|
|
236
|
+
# deeper include annotations from recursive expansion).
|
|
237
|
+
def annotate_include_source(element, file_path)
|
|
238
|
+
return unless element.is_a?(Element)
|
|
239
|
+
|
|
240
|
+
if (element.name.start_with?("mj-") || element.name == "mjml") && !element.attributes["data-mjml-file"]
|
|
241
|
+
element.add_attribute("data-mjml-file", file_path)
|
|
242
|
+
end
|
|
243
|
+
element.each_element { |child| annotate_include_source(child, file_path) }
|
|
244
|
+
end
|
|
245
|
+
|
|
133
246
|
def resolve_include_path(include_path, actual_path, file_path)
|
|
134
247
|
include_path = include_path.to_s
|
|
135
248
|
return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
|
|
@@ -164,6 +277,28 @@ module MjmlRb
|
|
|
164
277
|
def element_to_ast(element, keep_comments:)
|
|
165
278
|
raise ParseError, "Missing XML root element" unless element
|
|
166
279
|
|
|
280
|
+
# Extract metadata annotations (added by annotate_line_numbers / annotate_include_source)
|
|
281
|
+
# and strip them from the public attributes hash.
|
|
282
|
+
meta_line = element.attributes["data-mjml-line"]&.to_i
|
|
283
|
+
meta_file = element.attributes["data-mjml-file"]
|
|
284
|
+
attrs = element.attributes.each_with_object({}) do |(name, val), h|
|
|
285
|
+
h[name] = val unless name.start_with?("data-mjml-")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
289
|
+
# the raw HTML directly as content instead of parsing structurally.
|
|
290
|
+
if ENDING_TAGS_FOR_CDATA.include?(element.name)
|
|
291
|
+
raw_content = element.children.select { |c| c.is_a?(Text) }.map(&:value).join
|
|
292
|
+
return AstNode.new(
|
|
293
|
+
tag_name: element.name,
|
|
294
|
+
attributes: attrs,
|
|
295
|
+
children: [],
|
|
296
|
+
content: raw_content.empty? ? nil : raw_content,
|
|
297
|
+
line: meta_line,
|
|
298
|
+
file: meta_file
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
167
302
|
children = element.children.each_with_object([]) do |child, memo|
|
|
168
303
|
case child
|
|
169
304
|
when Element
|
|
@@ -178,9 +313,10 @@ module MjmlRb
|
|
|
178
313
|
|
|
179
314
|
AstNode.new(
|
|
180
315
|
tag_name: element.name,
|
|
181
|
-
attributes:
|
|
316
|
+
attributes: attrs,
|
|
182
317
|
children: children,
|
|
183
|
-
line:
|
|
318
|
+
line: meta_line,
|
|
319
|
+
file: meta_file
|
|
184
320
|
)
|
|
185
321
|
end
|
|
186
322
|
end
|
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/validator.rb
CHANGED
|
@@ -64,7 +64,7 @@ module MjmlRb
|
|
|
64
64
|
|
|
65
65
|
errors << error(
|
|
66
66
|
"Element <#{child.tag_name}> is not allowed inside <#{node.tag_name}>",
|
|
67
|
-
tag_name: child.tag_name
|
|
67
|
+
tag_name: child.tag_name, line: child.line, file: child.file
|
|
68
68
|
)
|
|
69
69
|
end
|
|
70
70
|
end
|
|
@@ -74,7 +74,8 @@ module MjmlRb
|
|
|
74
74
|
required.each do |attr|
|
|
75
75
|
next if node.attributes.key?(attr)
|
|
76
76
|
|
|
77
|
-
errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>",
|
|
77
|
+
errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>",
|
|
78
|
+
tag_name: node.tag_name, line: node.line, file: node.file)
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
@@ -86,7 +87,8 @@ module MjmlRb
|
|
|
86
87
|
next if allowed_attributes.key?(attribute_name)
|
|
87
88
|
next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
|
|
88
89
|
|
|
89
|
-
errors << error("Attribute `#{attribute_name}` is not allowed for <#{node.tag_name}>",
|
|
90
|
+
errors << error("Attribute `#{attribute_name}` is not allowed for <#{node.tag_name}>",
|
|
91
|
+
tag_name: node.tag_name, line: node.line, file: node.file)
|
|
90
92
|
end
|
|
91
93
|
end
|
|
92
94
|
|
|
@@ -103,7 +105,7 @@ module MjmlRb
|
|
|
103
105
|
|
|
104
106
|
errors << error(
|
|
105
107
|
"Attribute `#{attribute_name}` on <#{node.tag_name}> has invalid value `#{attribute_value}` for type `#{expected_type}`",
|
|
106
|
-
tag_name: node.tag_name
|
|
108
|
+
tag_name: node.tag_name, line: node.line, file: node.file
|
|
107
109
|
)
|
|
108
110
|
end
|
|
109
111
|
end
|
|
@@ -183,12 +185,18 @@ module MjmlRb
|
|
|
183
185
|
end
|
|
184
186
|
end
|
|
185
187
|
|
|
186
|
-
def error(message, line: nil, tag_name: nil)
|
|
188
|
+
def error(message, line: nil, tag_name: nil, file: nil)
|
|
189
|
+
location = [
|
|
190
|
+
("line #{line}" if line),
|
|
191
|
+
("in #{file}" if file)
|
|
192
|
+
].compact.join(", ")
|
|
193
|
+
|
|
187
194
|
{
|
|
188
195
|
line: line,
|
|
196
|
+
file: file,
|
|
189
197
|
message: message,
|
|
190
198
|
tag_name: tag_name,
|
|
191
|
-
formatted_message: message
|
|
199
|
+
formatted_message: location.empty? ? message : "#{message} (#{location})"
|
|
192
200
|
}
|
|
193
201
|
end
|
|
194
202
|
end
|
data/lib/mjml-rb/version.rb
CHANGED