mjml-rb 0.4.4 → 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/dependencies.rb +1 -1
- data/lib/mjml-rb/parser.rb +89 -103
- data/lib/mjml-rb/renderer.rb +30 -91
- 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
|
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
|
|
@@ -23,7 +21,6 @@ module MjmlRb
|
|
|
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
|
|
28
25
|
ROOT_LEVEL_HEAD_TAGS = %w[
|
|
29
26
|
mj-attributes
|
|
@@ -56,11 +53,12 @@ module MjmlRb
|
|
|
56
53
|
xml = normalize_html_void_tags(xml)
|
|
57
54
|
xml = expand_includes(xml, opts) unless opts[:ignore_includes]
|
|
58
55
|
|
|
59
|
-
xml =
|
|
60
|
-
|
|
56
|
+
xml = sanitize_bare_ampersands(xml)
|
|
57
|
+
xml = replace_html_entities(xml)
|
|
58
|
+
doc = Nokogiri::XML(xml) { |config| config.strict }
|
|
61
59
|
normalize_root_head_elements(doc)
|
|
62
60
|
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
63
|
-
rescue
|
|
61
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
64
62
|
raise ParseError.new("XML parse error: #{e.message}")
|
|
65
63
|
end
|
|
66
64
|
|
|
@@ -85,18 +83,18 @@ module MjmlRb
|
|
|
85
83
|
def expand_includes(xml, options, included_in = [])
|
|
86
84
|
xml = wrap_ending_tags_in_cdata(xml)
|
|
87
85
|
xml = normalize_html_void_tags(xml)
|
|
88
|
-
doc =
|
|
89
|
-
includes =
|
|
86
|
+
doc = parse_xml(sanitize_bare_ampersands(xml))
|
|
87
|
+
includes = doc.xpath("//mj-include")
|
|
90
88
|
return xml if includes.empty?
|
|
91
89
|
|
|
92
90
|
css_includes = []
|
|
93
91
|
head_includes = []
|
|
94
92
|
|
|
95
93
|
includes.reverse_each do |include_node|
|
|
96
|
-
path_attr = include_node
|
|
94
|
+
path_attr = include_node["path"]
|
|
97
95
|
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
98
96
|
|
|
99
|
-
include_type = include_node
|
|
97
|
+
include_type = include_node["type"].to_s
|
|
100
98
|
parent = include_node.parent
|
|
101
99
|
|
|
102
100
|
resolved_path = begin
|
|
@@ -114,7 +112,7 @@ module MjmlRb
|
|
|
114
112
|
tag_name: "mj-include",
|
|
115
113
|
file: display_path
|
|
116
114
|
}
|
|
117
|
-
|
|
115
|
+
include_node.remove
|
|
118
116
|
next
|
|
119
117
|
end
|
|
120
118
|
|
|
@@ -125,9 +123,9 @@ module MjmlRb
|
|
|
125
123
|
|
|
126
124
|
if include_type == "css"
|
|
127
125
|
# CSS includes get collected and added to mj-head later
|
|
128
|
-
css_inline = include_node
|
|
126
|
+
css_inline = include_node["css-inline"].to_s
|
|
129
127
|
css_includes << { content: include_content, inline: css_inline == "inline" }
|
|
130
|
-
|
|
128
|
+
include_node.remove
|
|
131
129
|
next
|
|
132
130
|
end
|
|
133
131
|
|
|
@@ -145,19 +143,20 @@ module MjmlRb
|
|
|
145
143
|
body_children
|
|
146
144
|
end
|
|
147
145
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
159
158
|
end
|
|
160
|
-
|
|
159
|
+
include_node.remove
|
|
161
160
|
end
|
|
162
161
|
|
|
163
162
|
inject_head_includes(doc, head_includes) unless head_includes.empty?
|
|
@@ -167,11 +166,7 @@ module MjmlRb
|
|
|
167
166
|
inject_css_includes(doc, css_includes)
|
|
168
167
|
end
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
doc.write(output)
|
|
172
|
-
output
|
|
173
|
-
rescue ParseException => e
|
|
174
|
-
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)
|
|
175
170
|
end
|
|
176
171
|
|
|
177
172
|
def prepare_mjml_include_document(content)
|
|
@@ -182,23 +177,23 @@ module MjmlRb
|
|
|
182
177
|
end
|
|
183
178
|
|
|
184
179
|
def extract_mjml_include_children(xml)
|
|
185
|
-
include_doc =
|
|
180
|
+
include_doc = parse_xml(sanitize_bare_ampersands(xml))
|
|
186
181
|
normalize_root_head_elements(include_doc)
|
|
187
182
|
mjml_root = include_doc.root
|
|
188
183
|
return [[], []] unless mjml_root&.name == "mjml"
|
|
189
184
|
|
|
190
|
-
body =
|
|
191
|
-
head =
|
|
185
|
+
body = mjml_root.at_xpath("mj-body")
|
|
186
|
+
head = mjml_root.at_xpath("mj-head")
|
|
192
187
|
|
|
193
188
|
[
|
|
194
|
-
body ? body.children.map { |child|
|
|
195
|
-
head ? head.children.map { |child|
|
|
189
|
+
body ? body.children.map { |child| child.dup(1) } : [],
|
|
190
|
+
head ? head.children.map { |child| child.dup(1) } : []
|
|
196
191
|
]
|
|
197
192
|
end
|
|
198
193
|
|
|
199
194
|
def inject_head_includes(doc, head_includes)
|
|
200
195
|
head = ensure_head(doc)
|
|
201
|
-
head_includes.each { |child| head.
|
|
196
|
+
head_includes.each { |child| head.add_child(child) }
|
|
202
197
|
end
|
|
203
198
|
|
|
204
199
|
def inject_css_includes(doc, css_includes)
|
|
@@ -206,10 +201,10 @@ module MjmlRb
|
|
|
206
201
|
|
|
207
202
|
# Add each CSS include as an mj-style element
|
|
208
203
|
css_includes.each do |css_include|
|
|
209
|
-
style_node =
|
|
210
|
-
style_node
|
|
211
|
-
style_node.
|
|
212
|
-
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)
|
|
213
208
|
end
|
|
214
209
|
end
|
|
215
210
|
|
|
@@ -217,15 +212,15 @@ module MjmlRb
|
|
|
217
212
|
mjml_root = doc.root
|
|
218
213
|
return unless mjml_root
|
|
219
214
|
|
|
220
|
-
head =
|
|
215
|
+
head = mjml_root.at_xpath("mj-head")
|
|
221
216
|
return head if head
|
|
222
217
|
|
|
223
|
-
head =
|
|
224
|
-
body =
|
|
218
|
+
head = Nokogiri::XML::Node.new("mj-head", doc)
|
|
219
|
+
body = mjml_root.at_xpath("mj-body")
|
|
225
220
|
if body
|
|
226
|
-
|
|
221
|
+
body.add_previous_sibling(head)
|
|
227
222
|
else
|
|
228
|
-
mjml_root.
|
|
223
|
+
mjml_root.add_child(head)
|
|
229
224
|
end
|
|
230
225
|
head
|
|
231
226
|
end
|
|
@@ -239,14 +234,14 @@ module MjmlRb
|
|
|
239
234
|
root_head_elements = []
|
|
240
235
|
|
|
241
236
|
mjml_root.children.each do |child|
|
|
242
|
-
next unless child.
|
|
237
|
+
next unless child.element?
|
|
243
238
|
|
|
244
239
|
if child.name == "mj-head"
|
|
245
240
|
head_nodes << child
|
|
246
|
-
child.children.each { |head_child| normalized_head_children <<
|
|
241
|
+
child.children.each { |head_child| normalized_head_children << head_child.dup(1) }
|
|
247
242
|
elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
|
|
248
243
|
root_head_elements << child
|
|
249
|
-
normalized_head_children <<
|
|
244
|
+
normalized_head_children << child.dup(1)
|
|
250
245
|
end
|
|
251
246
|
end
|
|
252
247
|
|
|
@@ -255,11 +250,11 @@ module MjmlRb
|
|
|
255
250
|
head = head_nodes.first || ensure_head(doc)
|
|
256
251
|
return unless head
|
|
257
252
|
|
|
258
|
-
head.children.
|
|
259
|
-
normalized_head_children.each { |child| head.
|
|
253
|
+
head.children.each(&:remove)
|
|
254
|
+
normalized_head_children.each { |child| head.add_child(child) }
|
|
260
255
|
|
|
261
|
-
root_head_elements.each
|
|
262
|
-
head_nodes.drop(1).each
|
|
256
|
+
root_head_elements.each(&:remove)
|
|
257
|
+
head_nodes.drop(1).each(&:remove)
|
|
263
258
|
end
|
|
264
259
|
|
|
265
260
|
def strip_xml_declaration(content)
|
|
@@ -357,40 +352,39 @@ module MjmlRb
|
|
|
357
352
|
end
|
|
358
353
|
|
|
359
354
|
# Escape bare "&" that are not part of a valid XML entity reference
|
|
360
|
-
# (e.g. & { ). This lets
|
|
361
|
-
# 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.
|
|
362
357
|
def sanitize_bare_ampersands(content)
|
|
363
358
|
content.gsub(BARE_AMPERSAND_RE, "&")
|
|
364
359
|
end
|
|
365
360
|
|
|
366
|
-
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
else # opening MJML tag
|
|
379
|
-
"#{Regexp.last_match(3)} data-mjml-line=\"#{line}\""
|
|
380
|
-
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
|
|
381
373
|
end
|
|
382
374
|
end
|
|
383
375
|
|
|
384
|
-
|
|
376
|
+
XML_PREDEFINED_ENTITIES = %w[amp lt gt quot apos].freeze
|
|
377
|
+
|
|
378
|
+
# Recursively marks Nokogiri elements from included files with data-mjml-file.
|
|
385
379
|
# Only sets the attribute on elements that don't already have it (preserving
|
|
386
380
|
# deeper include annotations from recursive expansion).
|
|
387
381
|
def annotate_include_source(element, file_path)
|
|
388
|
-
return unless element.
|
|
382
|
+
return unless element.element?
|
|
389
383
|
|
|
390
|
-
if (element.name.start_with?("mj-") || element.name == "mjml") && !element
|
|
391
|
-
element
|
|
384
|
+
if (element.name.start_with?("mj-") || element.name == "mjml") && !element["data-mjml-file"]
|
|
385
|
+
element["data-mjml-file"] = file_path
|
|
392
386
|
end
|
|
393
|
-
element.
|
|
387
|
+
element.element_children.each { |child| annotate_include_source(child, file_path) }
|
|
394
388
|
end
|
|
395
389
|
|
|
396
390
|
def resolve_include_path(include_path, actual_path, file_path)
|
|
@@ -408,38 +402,24 @@ module MjmlRb
|
|
|
408
402
|
raise Errno::ENOENT, include_path
|
|
409
403
|
end
|
|
410
404
|
|
|
411
|
-
def deep_clone(node)
|
|
412
|
-
case node
|
|
413
|
-
when Element
|
|
414
|
-
clone = Element.new(node.name)
|
|
415
|
-
node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
|
|
416
|
-
node.children.each { |child| clone.add(deep_clone(child)) }
|
|
417
|
-
clone
|
|
418
|
-
when Text
|
|
419
|
-
Text.new(node.value)
|
|
420
|
-
when Comment
|
|
421
|
-
Comment.new(node.string)
|
|
422
|
-
else
|
|
423
|
-
node
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
|
|
427
405
|
def element_to_ast(element, keep_comments:)
|
|
428
406
|
raise ParseError, "Missing XML root element" unless element
|
|
429
407
|
|
|
430
|
-
# Extract metadata annotations (added by
|
|
408
|
+
# Extract metadata annotations (added by annotate_include_source)
|
|
431
409
|
# and strip them from the public attributes hash.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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-")
|
|
436
416
|
end
|
|
437
417
|
attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
|
|
438
418
|
|
|
439
419
|
# For ending-tag elements whose content was wrapped in CDATA, store
|
|
440
420
|
# the raw HTML directly as content instead of parsing structurally.
|
|
441
421
|
if ENDING_TAGS_FOR_CDATA.include?(element.name)
|
|
442
|
-
raw_content = element.children.select { |c| c.
|
|
422
|
+
raw_content = element.children.select { |c| c.cdata? || c.text? }.map(&:content).join
|
|
443
423
|
return AstNode.new(
|
|
444
424
|
tag_name: element.name,
|
|
445
425
|
attributes: attrs,
|
|
@@ -451,17 +431,16 @@ module MjmlRb
|
|
|
451
431
|
end
|
|
452
432
|
|
|
453
433
|
children = element.children.each_with_object([]) do |child, memo|
|
|
454
|
-
|
|
455
|
-
when Element
|
|
434
|
+
if child.element?
|
|
456
435
|
memo << element_to_ast(child, keep_comments: keep_comments)
|
|
457
|
-
|
|
458
|
-
text = child.
|
|
436
|
+
elsif child.text? || child.cdata?
|
|
437
|
+
text = child.content
|
|
459
438
|
next if text.empty?
|
|
460
439
|
next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
|
|
461
440
|
|
|
462
441
|
memo << AstNode.new(tag_name: "#text", content: text)
|
|
463
|
-
|
|
464
|
-
memo << AstNode.new(tag_name: "#comment", content: child.
|
|
442
|
+
elsif child.comment?
|
|
443
|
+
memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
|
|
465
444
|
end
|
|
466
445
|
end
|
|
467
446
|
|
|
@@ -474,6 +453,13 @@ module MjmlRb
|
|
|
474
453
|
)
|
|
475
454
|
end
|
|
476
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
|
+
|
|
477
463
|
def ignorable_whitespace_text?(text, parent_element_name:)
|
|
478
464
|
return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
|
|
479
465
|
|
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"
|
|
@@ -417,115 +418,53 @@ module MjmlRb
|
|
|
417
418
|
end
|
|
418
419
|
|
|
419
420
|
def parse_inline_css_rules(css)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
rules =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
427
438
|
end
|
|
428
439
|
|
|
429
440
|
# Sort rules by specificity (ascending). With the "last wins" merge
|
|
430
441
|
# strategy, higher-specificity rules applied later correctly override
|
|
431
442
|
# lower-specificity ones — matching CSS cascade behavior.
|
|
432
|
-
sorted = rules.
|
|
433
|
-
.
|
|
434
|
-
.map(&:first)
|
|
435
|
-
|
|
436
|
-
[sorted, at_rules_css]
|
|
437
|
-
end
|
|
443
|
+
sorted = rules.sort_by.with_index { |(_, _, spec), idx| [spec, idx] }
|
|
444
|
+
.map { |sel, decl, _| [sel, decl] }
|
|
438
445
|
|
|
439
|
-
|
|
440
|
-
css.gsub(%r{/\*.*?\*/}m, "")
|
|
446
|
+
[sorted, ""]
|
|
441
447
|
end
|
|
442
448
|
|
|
443
|
-
# Separates @-rules (@media, @font-face, etc.) from plain CSS selectors.
|
|
444
|
-
# Returns [plain_css, at_rules_css]. The at_rules_css can be injected as a
|
|
445
|
-
# <style> block since @-rules cannot be inlined into style attributes.
|
|
446
|
-
def extract_css_at_rules(css)
|
|
447
|
-
plain = +""
|
|
448
|
-
at_rules = +""
|
|
449
|
-
index = 0
|
|
450
|
-
|
|
451
|
-
while index < css.length
|
|
452
|
-
if css[index] == "@"
|
|
453
|
-
brace_index = css.index("{", index)
|
|
454
|
-
semicolon_index = css.index(";", index)
|
|
455
|
-
|
|
456
|
-
# Simple @-rules like @import or @charset end with semicolon
|
|
457
|
-
if semicolon_index && (brace_index.nil? || semicolon_index < brace_index)
|
|
458
|
-
at_rules << css[index..semicolon_index] << "\n"
|
|
459
|
-
index = semicolon_index + 1
|
|
460
|
-
next
|
|
461
|
-
end
|
|
462
|
-
|
|
463
|
-
# Block @-rules like @media, @font-face have nested braces
|
|
464
|
-
if brace_index
|
|
465
|
-
depth = 1
|
|
466
|
-
cursor = brace_index + 1
|
|
467
|
-
while cursor < css.length && depth.positive?
|
|
468
|
-
depth += 1 if css[cursor] == "{"
|
|
469
|
-
depth -= 1 if css[cursor] == "}"
|
|
470
|
-
cursor += 1
|
|
471
|
-
end
|
|
472
|
-
at_rules << css[index...cursor] << "\n"
|
|
473
|
-
index = cursor
|
|
474
|
-
next
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
plain << css[index]
|
|
479
|
-
index += 1
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
[plain, at_rules]
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# Calculates CSS specificity as a comparable [a, b, c] tuple:
|
|
486
|
-
# a = number of ID selectors (#id)
|
|
487
|
-
# b = number of class selectors (.class), attribute selectors ([attr]),
|
|
488
|
-
# and pseudo-classes (:hover, :lang())
|
|
489
|
-
# c = number of type selectors (div, p) and pseudo-elements (::before)
|
|
490
449
|
def css_specificity(selector)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
# a: ID selectors
|
|
494
|
-
a = s.scan(/#[\w-]+/).length
|
|
495
|
-
|
|
496
|
-
# b: class selectors + attribute selectors + pseudo-classes
|
|
497
|
-
b = s.scan(/\.[\w-]+/).length +
|
|
498
|
-
s.scan(/\[[^\]]*\]/).length +
|
|
499
|
-
s.scan(/:(?!:)[\w-]+/).length
|
|
500
|
-
|
|
501
|
-
# c: type selectors + pseudo-elements
|
|
502
|
-
# Strip everything except element names and combinators
|
|
503
|
-
cleaned = s
|
|
504
|
-
.gsub(/#[\w-]+/, "") # remove IDs
|
|
505
|
-
.gsub(/\.[\w-]+/, "") # remove classes
|
|
506
|
-
.gsub(/\[[^\]]*\]/, "") # remove attribute selectors
|
|
507
|
-
.gsub(/:[\w-]+(?:\([^)]*\))?/, "") # remove pseudo-classes
|
|
508
|
-
.gsub(/::[\w-]+/, "") # remove pseudo-elements (counted separately)
|
|
509
|
-
.gsub(/[>+~]/, " ") # combinators → spaces
|
|
510
|
-
.gsub(/\*/, "") # universal selector has no specificity
|
|
511
|
-
c = cleaned.split.reject(&:empty?).length +
|
|
512
|
-
s.scan(/::[\w-]+/).length
|
|
513
|
-
|
|
514
|
-
[a, b, c]
|
|
450
|
+
CssParser.calculate_specificity(selector.to_s)
|
|
515
451
|
end
|
|
516
452
|
|
|
517
453
|
def parse_css_declarations(declarations, source: :css)
|
|
518
|
-
|
|
519
|
-
|
|
454
|
+
return {} if declarations.nil? || declarations.to_s.strip.empty?
|
|
455
|
+
|
|
456
|
+
rule_set = CssParser::RuleSet.new(block: declarations.to_s)
|
|
457
|
+
result = {}
|
|
458
|
+
rule_set.each_declaration do |property, value, important|
|
|
520
459
|
next if property.nil? || property.empty? || value.nil? || value.empty?
|
|
521
460
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
value: value.sub(/\s*!important\s*\z/, "").strip,
|
|
461
|
+
result[property] = {
|
|
462
|
+
value: value,
|
|
525
463
|
important: important,
|
|
526
464
|
source: source
|
|
527
465
|
}
|
|
528
466
|
end
|
|
467
|
+
result
|
|
529
468
|
end
|
|
530
469
|
|
|
531
470
|
def merge_inline_declarations!(existing, declarations, touched_properties)
|
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
|
- - ">="
|