mjml-rb 0.3.6 → 0.4.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/README.md +8 -11
- data/lib/mjml-rb/ast_node.rb +4 -2
- data/lib/mjml-rb/component_registry.rb +23 -7
- data/lib/mjml-rb/components/carousel.rb +37 -33
- data/lib/mjml-rb/parser.rb +15 -8
- data/lib/mjml-rb/renderer.rb +11 -7
- data/lib/mjml-rb/version.rb +1 -1
- data/mjml-rb.gemspec +2 -2
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9cdaa07e2f84c880f1c3a4ae1594a32ec0d49436d6bf2fe83ee7d1738628fdf
|
|
4
|
+
data.tar.gz: 4b8576742cd05906ad3bf2bf8ef184dcf0eb6e4a7a7a19ca701e7a84c5ad3939
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10f9d9c51871108519184113747abbee99c6c9b58e4f9a95161756296c4c97b64bf136cf969980ad99b00eb12ba505871a6efaa6b5ec1524f1ec14375e9030a1
|
|
7
|
+
data.tar.gz: f2af62cc6cb8f43f6fbd4e7aa6feb20ec7326b1069c52ee46d807ce7d4e134b3f1b931a03dc78c03108328621c322764ab70b14bf3d6e84c52940661ad6d2c09
|
data/README.md
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# MJML Ruby Implementation
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
9
|
-
>
|
|
10
|
-
>
|
|
11
|
-
> This is a **fully open source project**, and help is welcome:
|
|
12
|
-
> feedback, bug reports, test cases, optimizations, proposals, and pull requests.
|
|
13
|
-
> No warranty of any kind is provided.
|
|
3
|
+
> **Note:** This is an unofficial Ruby port of the MJML email framework,
|
|
4
|
+
> not affiliated with or endorsed by the MJML team.
|
|
5
|
+
> All 22 MJML v4 components are implemented and tested against npm MJML v4.18.0.
|
|
6
|
+
> Output may differ from the npm renderer in cosmetic ways (whitespace, attribute ordering)
|
|
7
|
+
> but renders identically across email clients.
|
|
8
|
+
> We recommend testing your templates when migrating from the npm renderer.
|
|
9
|
+
> This is a **fully open source project** — feedback, bug reports, test cases,
|
|
10
|
+
> and pull requests are welcome!
|
|
14
11
|
|
|
15
12
|
This gem provides a Ruby-first implementation of the main MJML tooling:
|
|
16
13
|
|
data/lib/mjml-rb/ast_node.rb
CHANGED
|
@@ -25,11 +25,13 @@ module MjmlRb
|
|
|
25
25
|
|
|
26
26
|
def text_content
|
|
27
27
|
return @content.to_s if text?
|
|
28
|
-
|
|
28
|
+
result = +""
|
|
29
|
+
@children.each { |child| result << child.text_content }
|
|
30
|
+
result
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
def element_children
|
|
32
|
-
@children.select(&:element?)
|
|
34
|
+
@element_children ||= @children.select(&:element?)
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
end
|
|
@@ -17,33 +17,49 @@ module MjmlRb
|
|
|
17
17
|
@custom_dependencies[parent] = ((@custom_dependencies[parent] || []) + Array(children)).uniq
|
|
18
18
|
end
|
|
19
19
|
@custom_ending_tags.merge(Array(ending_tags))
|
|
20
|
+
invalidate_caches!
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def component_class_for_tag(tag_name)
|
|
23
|
-
|
|
24
|
+
tag_class_cache[tag_name]
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def dependency_rules
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
@dependency_rules_cache ||= begin
|
|
29
|
+
merged = {}
|
|
30
|
+
Dependencies::RULES.each { |k, v| merged[k] = v.dup }
|
|
31
|
+
@custom_dependencies.each do |parent, children|
|
|
32
|
+
merged[parent] = ((merged[parent] || []) + Array(children)).uniq
|
|
33
|
+
end
|
|
34
|
+
merged
|
|
31
35
|
end
|
|
32
|
-
merged
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def ending_tags
|
|
36
|
-
Dependencies::ENDING_TAGS | @custom_ending_tags
|
|
39
|
+
@ending_tags_cache ||= (Dependencies::ENDING_TAGS | @custom_ending_tags)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def reset!
|
|
40
43
|
@custom_components.clear
|
|
41
44
|
@custom_dependencies.clear
|
|
42
45
|
@custom_ending_tags.clear
|
|
46
|
+
invalidate_caches!
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
private
|
|
46
50
|
|
|
51
|
+
def invalidate_caches!
|
|
52
|
+
@tag_class_cache = nil
|
|
53
|
+
@dependency_rules_cache = nil
|
|
54
|
+
@ending_tags_cache = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tag_class_cache
|
|
58
|
+
@tag_class_cache ||= all_component_classes.each_with_object({}) do |klass, h|
|
|
59
|
+
klass.tags.each { |tag| h[tag] ||= klass }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
def all_component_classes
|
|
48
64
|
builtin = MjmlRb::Components.constants.filter_map do |name|
|
|
49
65
|
value = MjmlRb::Components.const_get(name)
|
|
@@ -305,39 +305,43 @@ module MjmlRb
|
|
|
305
305
|
def component_head_style(carousel_id, length, attrs)
|
|
306
306
|
return "" if length.zero?
|
|
307
307
|
|
|
308
|
-
hide_non_selected =
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
".mj-carousel-#{carousel_id}-radio
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
".mj-carousel-#{carousel_id}-radio-#{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
308
|
+
hide_non_selected = []
|
|
309
|
+
show_selected = []
|
|
310
|
+
next_icons = []
|
|
311
|
+
previous_icons = []
|
|
312
|
+
selected_thumbnail = []
|
|
313
|
+
show_thumbnails = []
|
|
314
|
+
hide_on_hover = []
|
|
315
|
+
show_on_hover = []
|
|
316
|
+
|
|
317
|
+
# Pre-compute adjacent sibling strings to avoid repeated "+" * count" allocations
|
|
318
|
+
sibling_cache = Array.new(length) { |i| adjacent_siblings(i) }
|
|
319
|
+
|
|
320
|
+
length.times do |index|
|
|
321
|
+
siblings_index = sibling_cache[index]
|
|
322
|
+
siblings_reverse = sibling_cache[length - index - 1]
|
|
323
|
+
idx1 = index + 1
|
|
324
|
+
next_target = ((index + 1) % length) + 1
|
|
325
|
+
prev_target = ((index - 1) % length) + 1
|
|
326
|
+
|
|
327
|
+
hide_non_selected << ".mj-carousel-#{carousel_id}-radio:checked #{siblings_index}+ .mj-carousel-content .mj-carousel-image"
|
|
328
|
+
show_selected << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-image-#{idx1}"
|
|
329
|
+
next_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-next-#{next_target}"
|
|
330
|
+
previous_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-previous-#{prev_target}"
|
|
331
|
+
selected_thumbnail << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{idx1}"
|
|
332
|
+
show_thumbnails << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
|
|
333
|
+
hide_on_hover << ".mj-carousel-#{carousel_id}-thumbnail:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image"
|
|
334
|
+
show_on_hover << ".mj-carousel-#{carousel_id}-thumbnail-#{idx1}:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image-#{idx1}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
hide_non_selected = hide_non_selected.join(",\n")
|
|
338
|
+
show_selected = show_selected.join(",\n")
|
|
339
|
+
next_icons = next_icons.join(",\n")
|
|
340
|
+
previous_icons = previous_icons.join(",\n")
|
|
341
|
+
selected_thumbnail = selected_thumbnail.join(",\n")
|
|
342
|
+
show_thumbnails = show_thumbnails.join(",\n")
|
|
343
|
+
hide_on_hover = hide_on_hover.join(",\n")
|
|
344
|
+
show_on_hover = show_on_hover.join(",\n")
|
|
341
345
|
|
|
342
346
|
<<~CSS
|
|
343
347
|
.mj-carousel {
|
data/lib/mjml-rb/parser.rb
CHANGED
|
@@ -17,6 +17,15 @@ module MjmlRb
|
|
|
17
17
|
mj-navbar-link mj-raw mj-text
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
|
+
# Pre-compiled regex patterns to avoid rebuilding on every call
|
|
21
|
+
ENDING_TAGS_CDATA_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi.freeze
|
|
22
|
+
|
|
23
|
+
VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
|
|
24
|
+
VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
|
|
25
|
+
VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
|
|
26
|
+
LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
|
|
27
|
+
BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
|
|
28
|
+
|
|
20
29
|
class ParseError < StandardError
|
|
21
30
|
attr_reader :line
|
|
22
31
|
|
|
@@ -212,23 +221,21 @@ module MjmlRb
|
|
|
212
221
|
def normalize_html_void_tags(content)
|
|
213
222
|
# Legacy mail templates sometimes emit invalid closing </br> tags.
|
|
214
223
|
# Browser-style recovery treats them as actual line breaks, so preserve that.
|
|
215
|
-
content = content.gsub(
|
|
224
|
+
content = content.gsub(VOID_TAG_CLOSING_BR_RE, "<br />")
|
|
216
225
|
|
|
217
226
|
# Remove other closing tags for void elements (e.g. </hr>, </img>).
|
|
218
227
|
# These are invalid in both HTML and XML and HTML5 recovery drops them.
|
|
219
|
-
content = content.gsub(
|
|
228
|
+
content = content.gsub(VOID_TAG_CLOSING_OTHER_RE, "")
|
|
220
229
|
|
|
221
230
|
# Self-close opening void tags that aren't already self-closed.
|
|
222
|
-
|
|
223
|
-
content.gsub(pattern) do |tag|
|
|
231
|
+
content.gsub(VOID_TAG_OPEN_RE) do |tag|
|
|
224
232
|
tag.end_with?("/>") ? tag : tag.sub(/>$/, " />")
|
|
225
233
|
end
|
|
226
234
|
end
|
|
227
235
|
|
|
228
236
|
def wrap_ending_tags_in_cdata(content)
|
|
229
|
-
tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
|
|
230
237
|
# Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
|
|
231
|
-
content.gsub(
|
|
238
|
+
content.gsub(ENDING_TAGS_CDATA_RE) do
|
|
232
239
|
tag = Regexp.last_match(1)
|
|
233
240
|
attrs = Regexp.last_match(2).to_s
|
|
234
241
|
inner = Regexp.last_match(3).to_s
|
|
@@ -251,7 +258,7 @@ module MjmlRb
|
|
|
251
258
|
# (e.g. & { ). This lets REXML parse HTML-ish content
|
|
252
259
|
# such as "Terms & Conditions" which is common in email templates.
|
|
253
260
|
def sanitize_bare_ampersands(content)
|
|
254
|
-
content.gsub(
|
|
261
|
+
content.gsub(BARE_AMPERSAND_RE, "&")
|
|
255
262
|
end
|
|
256
263
|
|
|
257
264
|
# Adds data-mjml-line attributes to MJML tags so line numbers survive
|
|
@@ -259,7 +266,7 @@ module MjmlRb
|
|
|
259
266
|
# Skips content inside CDATA sections to avoid modifying raw HTML.
|
|
260
267
|
def annotate_line_numbers(xml)
|
|
261
268
|
line = 1
|
|
262
|
-
xml.gsub(
|
|
269
|
+
xml.gsub(LINE_ANNOTATION_RE) do
|
|
263
270
|
if Regexp.last_match(1) # newline
|
|
264
271
|
line += 1
|
|
265
272
|
"\n"
|
data/lib/mjml-rb/renderer.rb
CHANGED
|
@@ -25,7 +25,7 @@ require_relative "components/spacer"
|
|
|
25
25
|
|
|
26
26
|
module MjmlRb
|
|
27
27
|
class Renderer
|
|
28
|
-
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
28
|
+
HTML_VOID_TAGS = Set.new(%w[area base br col embed hr img input link meta param source track wbr]).freeze
|
|
29
29
|
|
|
30
30
|
DEFAULT_FONTS = {
|
|
31
31
|
"Open Sans" => "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
|
|
@@ -647,6 +647,8 @@ module MjmlRb
|
|
|
647
647
|
end
|
|
648
648
|
|
|
649
649
|
def append_component_head_styles(document, context)
|
|
650
|
+
all_tags = collect_tag_names(document)
|
|
651
|
+
|
|
650
652
|
component_registry.each_value.uniq.each do |component|
|
|
651
653
|
next unless component.respond_to?(:head_style)
|
|
652
654
|
|
|
@@ -662,7 +664,7 @@ module MjmlRb
|
|
|
662
664
|
else
|
|
663
665
|
component.tags
|
|
664
666
|
end
|
|
665
|
-
next unless Array(tags).any? { |tag|
|
|
667
|
+
next unless Array(tags).any? { |tag| all_tags.include?(tag) }
|
|
666
668
|
|
|
667
669
|
context[:component_head_styles] << style
|
|
668
670
|
end
|
|
@@ -815,11 +817,12 @@ module MjmlRb
|
|
|
815
817
|
end.join("\n")
|
|
816
818
|
end
|
|
817
819
|
|
|
818
|
-
def
|
|
819
|
-
return
|
|
820
|
-
return true if node.tag_name == tag_name
|
|
820
|
+
def collect_tag_names(node, result = Set.new)
|
|
821
|
+
return result unless node.respond_to?(:tag_name)
|
|
821
822
|
|
|
822
|
-
|
|
823
|
+
result << node.tag_name
|
|
824
|
+
node.children.each { |child| collect_tag_names(child, result) if child.respond_to?(:children) }
|
|
825
|
+
result
|
|
823
826
|
end
|
|
824
827
|
|
|
825
828
|
def escape_html(value)
|
|
@@ -845,9 +848,10 @@ module MjmlRb
|
|
|
845
848
|
end
|
|
846
849
|
|
|
847
850
|
def unique_strings(values)
|
|
851
|
+
seen = Set.new
|
|
848
852
|
Array(values).each_with_object([]) do |value, memo|
|
|
849
853
|
next if value.nil? || value.empty?
|
|
850
|
-
next
|
|
854
|
+
next unless seen.add?(value)
|
|
851
855
|
|
|
852
856
|
memo << value
|
|
853
857
|
end
|
data/lib/mjml-rb/version.rb
CHANGED
data/mjml-rb.gemspec
CHANGED
|
@@ -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 "nokogiri"
|
|
22
|
-
spec.add_dependency "rexml"
|
|
21
|
+
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Andriichuk
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '1.13'
|
|
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: '
|
|
25
|
+
version: '1.13'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rexml
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
32
|
+
version: 3.2.5
|
|
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: 3.2.5
|
|
40
40
|
description: Ruby-first MJML compiler API and CLI with compatibility-focused behavior.
|
|
41
41
|
email:
|
|
42
42
|
- andreiandriichuk@gmail.com
|