mjml-rb 0.2.1
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 +7 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/bin/mjml +6 -0
- data/lib/mjml-rb/ast_node.rb +34 -0
- data/lib/mjml-rb/cli.rb +305 -0
- data/lib/mjml-rb/compiler.rb +109 -0
- data/lib/mjml-rb/components/accordion.rb +210 -0
- data/lib/mjml-rb/components/base.rb +76 -0
- data/lib/mjml-rb/components/body.rb +46 -0
- data/lib/mjml-rb/components/breakpoint.rb +25 -0
- data/lib/mjml-rb/components/button.rb +157 -0
- data/lib/mjml-rb/components/column.rb +241 -0
- data/lib/mjml-rb/components/divider.rb +120 -0
- data/lib/mjml-rb/components/hero.rb +285 -0
- data/lib/mjml-rb/components/html_attributes.rb +32 -0
- data/lib/mjml-rb/components/image.rb +183 -0
- data/lib/mjml-rb/components/navbar.rb +279 -0
- data/lib/mjml-rb/components/section.rb +331 -0
- data/lib/mjml-rb/components/social.rb +303 -0
- data/lib/mjml-rb/components/spacer.rb +63 -0
- data/lib/mjml-rb/components/table.rb +162 -0
- data/lib/mjml-rb/components/text.rb +71 -0
- data/lib/mjml-rb/dependencies.rb +67 -0
- data/lib/mjml-rb/migrator.rb +18 -0
- data/lib/mjml-rb/parser.rb +169 -0
- data/lib/mjml-rb/renderer.rb +513 -0
- data/lib/mjml-rb/result.rb +23 -0
- data/lib/mjml-rb/validator.rb +187 -0
- data/lib/mjml-rb/version.rb +3 -0
- data/lib/mjml-rb.rb +30 -0
- data/mjml-rb.gemspec +23 -0
- metadata +101 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module MjmlRb
|
|
4
|
+
module Components
|
|
5
|
+
class Spacer < Base
|
|
6
|
+
TAGS = ["mj-spacer"].freeze
|
|
7
|
+
|
|
8
|
+
ALLOWED_ATTRIBUTES = {
|
|
9
|
+
"border" => "string",
|
|
10
|
+
"border-bottom" => "string",
|
|
11
|
+
"border-left" => "string",
|
|
12
|
+
"border-right" => "string",
|
|
13
|
+
"border-top" => "string",
|
|
14
|
+
"container-background-color" => "color",
|
|
15
|
+
"padding-bottom" => "unit(px,%)",
|
|
16
|
+
"padding-left" => "unit(px,%)",
|
|
17
|
+
"padding-right" => "unit(px,%)",
|
|
18
|
+
"padding-top" => "unit(px,%)",
|
|
19
|
+
"padding" => "unit(px,%){1,4}",
|
|
20
|
+
"height" => "unit(px,%)"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_ATTRIBUTES = {
|
|
24
|
+
"height" => "20px"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def tags
|
|
28
|
+
TAGS
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render(tag_name:, node:, context:, attrs:, parent:)
|
|
32
|
+
a = self.class.default_attributes.merge(attrs)
|
|
33
|
+
height = a["height"]
|
|
34
|
+
outer_td_attrs = {
|
|
35
|
+
"class" => a["css-class"],
|
|
36
|
+
"style" => style_join(
|
|
37
|
+
"background" => a["container-background-color"],
|
|
38
|
+
"border" => a["border"],
|
|
39
|
+
"border-bottom" => a["border-bottom"],
|
|
40
|
+
"border-left" => a["border-left"],
|
|
41
|
+
"border-right" => a["border-right"],
|
|
42
|
+
"border-top" => a["border-top"],
|
|
43
|
+
"padding" => a["padding"],
|
|
44
|
+
"padding-top" => a["padding-top"],
|
|
45
|
+
"padding-right" => a["padding-right"],
|
|
46
|
+
"padding-bottom" => a["padding-bottom"],
|
|
47
|
+
"padding-left" => a["padding-left"],
|
|
48
|
+
"word-break" => "break-word"
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
div_attrs = {
|
|
52
|
+
"style" => style_join(
|
|
53
|
+
"height" => height,
|
|
54
|
+
"line-height" => height,
|
|
55
|
+
"font-size" => "0"
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
%(<tr><td#{html_attrs(outer_td_attrs)}><div#{html_attrs(div_attrs)}> </div></td></tr>)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module MjmlRb
|
|
4
|
+
module Components
|
|
5
|
+
class Table < Base
|
|
6
|
+
TAGS = ["mj-table"].freeze
|
|
7
|
+
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"align" => "left",
|
|
10
|
+
"border" => "none",
|
|
11
|
+
"cellpadding" => "0",
|
|
12
|
+
"cellspacing" => "0",
|
|
13
|
+
"color" => "#000000",
|
|
14
|
+
"font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
|
|
15
|
+
"font-size" => "13px",
|
|
16
|
+
"line-height" => "22px",
|
|
17
|
+
"padding" => "10px 25px",
|
|
18
|
+
"table-layout" => "auto",
|
|
19
|
+
"width" => "100%"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def tags
|
|
23
|
+
TAGS
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render(tag_name:, node:, context:, attrs:, parent:)
|
|
27
|
+
a = DEFAULTS.merge(attrs)
|
|
28
|
+
|
|
29
|
+
outer_td_style = style_join(
|
|
30
|
+
"background" => a["container-background-color"],
|
|
31
|
+
"font-size" => "0px",
|
|
32
|
+
"font-family" => "inherit",
|
|
33
|
+
"padding" => a["padding"],
|
|
34
|
+
"padding-top" => a["padding-top"],
|
|
35
|
+
"padding-right" => a["padding-right"],
|
|
36
|
+
"padding-bottom" => a["padding-bottom"],
|
|
37
|
+
"padding-left" => a["padding-left"],
|
|
38
|
+
"word-break" => "break-word"
|
|
39
|
+
)
|
|
40
|
+
outer_td_attrs = {
|
|
41
|
+
"align" => a["align"],
|
|
42
|
+
"vertical-align" => a["vertical-align"],
|
|
43
|
+
"class" => a["css-class"],
|
|
44
|
+
"style" => outer_td_style
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cellspacing_has_value = has_cellspacing?(a["cellspacing"])
|
|
48
|
+
table_style = style_join(
|
|
49
|
+
"color" => a["color"],
|
|
50
|
+
"font-family" => a["font-family"],
|
|
51
|
+
"font-size" => a["font-size"],
|
|
52
|
+
"line-height" => a["line-height"],
|
|
53
|
+
"table-layout" => a["table-layout"],
|
|
54
|
+
"width" => a["width"],
|
|
55
|
+
"border" => a["border"],
|
|
56
|
+
"border-collapse" => (cellspacing_has_value ? "separate" : nil)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Attribute order matches official mjml output: cellpadding, cellspacing, role, width, border, style
|
|
60
|
+
table_attrs = {
|
|
61
|
+
"cellpadding" => a["cellpadding"],
|
|
62
|
+
"cellspacing" => a["cellspacing"],
|
|
63
|
+
"role" => a["role"],
|
|
64
|
+
"width" => get_width(a["width"]),
|
|
65
|
+
"border" => "0",
|
|
66
|
+
"style" => table_style
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
content = serialize_table_children(node)
|
|
70
|
+
table_html = %(<table#{html_attrs(table_attrs)}>#{content}</table>)
|
|
71
|
+
|
|
72
|
+
%(<tr><td#{html_attrs(outer_td_attrs)}>#{table_html}</td></tr>)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def get_width(width)
|
|
78
|
+
return width if width == "auto"
|
|
79
|
+
|
|
80
|
+
if width =~ /^(\d+(?:\.\d+)?)\s*%$/
|
|
81
|
+
width # keep as "100%"
|
|
82
|
+
elsif width =~ /^(\d+(?:\.\d+)?)\s*px$/
|
|
83
|
+
::Regexp.last_match(1) # return just the number e.g. "300"
|
|
84
|
+
else
|
|
85
|
+
width
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def has_cellspacing?(cellspacing)
|
|
90
|
+
return false if cellspacing.nil? || cellspacing.to_s.strip.empty?
|
|
91
|
+
|
|
92
|
+
num = cellspacing.to_s.gsub(/[^\d.]/, "").to_f
|
|
93
|
+
num > 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def serialize_table_children(node)
|
|
97
|
+
node.children.map { |child| serialize_table_node(child) }.join
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def serialize_table_node(node)
|
|
101
|
+
return node.content.to_s if node.text?
|
|
102
|
+
return "" if node.comment?
|
|
103
|
+
|
|
104
|
+
attrs = normalize_table_node_attributes(node)
|
|
105
|
+
attr_html = attrs.map { |key, value| %( #{key}="#{escape_attr(value)}") }.join
|
|
106
|
+
|
|
107
|
+
return "<#{node.tag_name}#{attr_html} />" if node.children.empty?
|
|
108
|
+
|
|
109
|
+
inner = node.children.map { |child| serialize_table_node(child) }.join
|
|
110
|
+
"<#{node.tag_name}#{attr_html}>#{inner}</#{node.tag_name}>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize_table_node_attributes(node)
|
|
114
|
+
attrs = node.attributes.dup
|
|
115
|
+
style_map = parse_style_map(attrs["style"])
|
|
116
|
+
|
|
117
|
+
if %w[table td th a].include?(node.tag_name)
|
|
118
|
+
style_map["font-family"] ||= "inherit"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if node.tag_name == "table"
|
|
122
|
+
unless attrs.key?("width") || style_map.key?("width")
|
|
123
|
+
attrs["width"] = "100%"
|
|
124
|
+
style_map["width"] = "100%"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if %w[td th].include?(node.tag_name)
|
|
129
|
+
attrs["width"] ||= style_to_html_width(style_map["width"])
|
|
130
|
+
attrs["align"] ||= style_map["text-align"] if style_map["text-align"]
|
|
131
|
+
attrs["valign"] ||= style_map["vertical-align"] if style_map["vertical-align"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
attrs["style"] = serialize_style_map(style_map) unless style_map.empty?
|
|
135
|
+
attrs.delete("style") if style_map.empty?
|
|
136
|
+
attrs.compact
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_style_map(style)
|
|
140
|
+
return {} if style.nil? || style.strip.empty?
|
|
141
|
+
|
|
142
|
+
style.split(";").each_with_object({}) do |declaration, memo|
|
|
143
|
+
key, value = declaration.split(":", 2).map { |part| part&.strip }
|
|
144
|
+
next if key.nil? || key.empty? || value.nil? || value.empty?
|
|
145
|
+
|
|
146
|
+
memo[key] = value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def serialize_style_map(style_map)
|
|
151
|
+
style_map.map { |key, value| "#{key}: #{value}" }.join("; ")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def style_to_html_width(value)
|
|
155
|
+
return if value.nil?
|
|
156
|
+
|
|
157
|
+
match = value.match(/\A(\d+(?:\.\d+)?)px\z/)
|
|
158
|
+
match ? match[1] : value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module MjmlRb
|
|
4
|
+
module Components
|
|
5
|
+
class Text < Base
|
|
6
|
+
TAGS = ["mj-text"].freeze
|
|
7
|
+
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"align" => "left",
|
|
10
|
+
"color" => "#000000",
|
|
11
|
+
"font-family" => "Ubuntu, Helvetica, Arial, sans-serif",
|
|
12
|
+
"font-size" => "13px",
|
|
13
|
+
"line-height" => "1",
|
|
14
|
+
"padding" => "10px 25px"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def tags
|
|
18
|
+
TAGS
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render(tag_name:, node:, context:, attrs:, parent:)
|
|
22
|
+
a = DEFAULTS.merge(attrs)
|
|
23
|
+
height = a["height"]
|
|
24
|
+
|
|
25
|
+
outer_td_style = style_join(
|
|
26
|
+
"background" => a["container-background-color"],
|
|
27
|
+
"font-size" => "0px",
|
|
28
|
+
"padding" => a["padding"],
|
|
29
|
+
"padding-top" => a["padding-top"],
|
|
30
|
+
"padding-right" => a["padding-right"],
|
|
31
|
+
"padding-bottom" => a["padding-bottom"],
|
|
32
|
+
"padding-left" => a["padding-left"],
|
|
33
|
+
"word-break" => "break-word"
|
|
34
|
+
)
|
|
35
|
+
outer_td_attrs = {
|
|
36
|
+
"align" => a["align"],
|
|
37
|
+
"vertical-align" => a["vertical-align"],
|
|
38
|
+
"class" => a["css-class"],
|
|
39
|
+
"style" => outer_td_style
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
div_style = style_join(
|
|
43
|
+
"font-family" => a["font-family"],
|
|
44
|
+
"font-size" => a["font-size"],
|
|
45
|
+
"font-style" => a["font-style"],
|
|
46
|
+
"font-weight" => a["font-weight"],
|
|
47
|
+
"letter-spacing" => a["letter-spacing"],
|
|
48
|
+
"line-height" => a["line-height"],
|
|
49
|
+
"text-align" => a["align"],
|
|
50
|
+
"text-decoration" => a["text-decoration"],
|
|
51
|
+
"text-transform" => a["text-transform"],
|
|
52
|
+
"color" => a["color"],
|
|
53
|
+
"height" => height
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
content = html_inner(node)
|
|
57
|
+
inner_div = %(<div style="#{div_style}">#{content}</div>)
|
|
58
|
+
|
|
59
|
+
body = if height
|
|
60
|
+
outlook_open = %(<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td height="#{escape_attr(height)}" style="vertical-align:top;height:#{escape_attr(height)};"><![endif]-->)
|
|
61
|
+
outlook_close = %(<!--[if mso | IE]></td></tr></table><![endif]-->)
|
|
62
|
+
"#{outlook_open}#{inner_div}#{outlook_close}"
|
|
63
|
+
else
|
|
64
|
+
inner_div
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
%(<tr><td#{html_attrs(outer_td_attrs)}>#{body}</td></tr>)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module MjmlRb
|
|
2
|
+
module Dependencies
|
|
3
|
+
RULES = {
|
|
4
|
+
"mjml" => ["mj-body", "mj-head", "mj-raw"],
|
|
5
|
+
"mj-accordion" => ["mj-accordion-element", "mj-raw"],
|
|
6
|
+
"mj-accordion-element" => ["mj-accordion-title", "mj-accordion-text", "mj-raw"],
|
|
7
|
+
"mj-accordion-title" => [],
|
|
8
|
+
"mj-accordion-text" => [],
|
|
9
|
+
"mj-attributes" => [/.*/],
|
|
10
|
+
"mj-body" => ["mj-raw", "mj-section", "mj-wrapper", "mj-hero"],
|
|
11
|
+
"mj-button" => [],
|
|
12
|
+
"mj-carousel" => ["mj-carousel-image"],
|
|
13
|
+
"mj-carousel-image" => [],
|
|
14
|
+
"mj-column" => [
|
|
15
|
+
"mj-accordion",
|
|
16
|
+
"mj-button",
|
|
17
|
+
"mj-carousel",
|
|
18
|
+
"mj-divider",
|
|
19
|
+
"mj-image",
|
|
20
|
+
"mj-raw",
|
|
21
|
+
"mj-social",
|
|
22
|
+
"mj-spacer",
|
|
23
|
+
"mj-table",
|
|
24
|
+
"mj-text",
|
|
25
|
+
"mj-navbar"
|
|
26
|
+
],
|
|
27
|
+
"mj-html-attribute" => [],
|
|
28
|
+
"mj-html-attributes" => ["mj-selector"],
|
|
29
|
+
"mj-divider" => [],
|
|
30
|
+
"mj-group" => ["mj-column", "mj-raw"],
|
|
31
|
+
"mj-head" => [
|
|
32
|
+
"mj-attributes",
|
|
33
|
+
"mj-breakpoint",
|
|
34
|
+
"mj-html-attributes",
|
|
35
|
+
"mj-font",
|
|
36
|
+
"mj-preview",
|
|
37
|
+
"mj-style",
|
|
38
|
+
"mj-title",
|
|
39
|
+
"mj-raw"
|
|
40
|
+
],
|
|
41
|
+
"mj-hero" => [
|
|
42
|
+
"mj-accordion",
|
|
43
|
+
"mj-button",
|
|
44
|
+
"mj-carousel",
|
|
45
|
+
"mj-divider",
|
|
46
|
+
"mj-image",
|
|
47
|
+
"mj-social",
|
|
48
|
+
"mj-spacer",
|
|
49
|
+
"mj-table",
|
|
50
|
+
"mj-text",
|
|
51
|
+
"mj-navbar",
|
|
52
|
+
"mj-raw"
|
|
53
|
+
],
|
|
54
|
+
"mj-image" => [],
|
|
55
|
+
"mj-navbar" => ["mj-navbar-link", "mj-raw"],
|
|
56
|
+
"mj-raw" => [/^(?!mj-).+/],
|
|
57
|
+
"mj-section" => ["mj-column", "mj-group", "mj-raw"],
|
|
58
|
+
"mj-selector" => ["mj-html-attribute"],
|
|
59
|
+
"mj-social" => ["mj-social-element", "mj-raw"],
|
|
60
|
+
"mj-social-element" => [],
|
|
61
|
+
"mj-spacer" => [],
|
|
62
|
+
"mj-table" => [/^(?!mj-).+/],
|
|
63
|
+
"mj-text" => [/^(?!mj-).+/],
|
|
64
|
+
"mj-wrapper" => ["mj-hero", "mj-raw", "mj-section"]
|
|
65
|
+
}.freeze
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module MjmlRb
|
|
2
|
+
class Migrator
|
|
3
|
+
TAG_RENAMES = {
|
|
4
|
+
"mj-container" => "mj-body"
|
|
5
|
+
}.freeze
|
|
6
|
+
|
|
7
|
+
def migrate(mjml)
|
|
8
|
+
output = mjml.to_s.dup
|
|
9
|
+
|
|
10
|
+
TAG_RENAMES.each do |from, to|
|
|
11
|
+
output.gsub!("<#{from}", "<#{to}")
|
|
12
|
+
output.gsub!("</#{from}>", "</#{to}>")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
output
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
require "rexml/document"
|
|
2
|
+
require "rexml/xpath"
|
|
3
|
+
|
|
4
|
+
require_relative "ast_node"
|
|
5
|
+
|
|
6
|
+
module MjmlRb
|
|
7
|
+
class Parser
|
|
8
|
+
include REXML
|
|
9
|
+
HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
|
|
10
|
+
|
|
11
|
+
class ParseError < StandardError
|
|
12
|
+
attr_reader :line
|
|
13
|
+
|
|
14
|
+
def initialize(message, line: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@line = line
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse(mjml, options = {})
|
|
21
|
+
opts = normalize_options(options)
|
|
22
|
+
xml = apply_preprocessors(mjml.to_s, opts[:preprocessors])
|
|
23
|
+
xml = normalize_html_void_tags(xml)
|
|
24
|
+
xml = expand_includes(xml, opts) unless opts[:ignore_includes]
|
|
25
|
+
|
|
26
|
+
doc = Document.new(sanitize_bare_ampersands(xml))
|
|
27
|
+
element_to_ast(doc.root, keep_comments: opts[:keep_comments])
|
|
28
|
+
rescue ParseException => e
|
|
29
|
+
raise ParseError.new("XML parse error: #{e.message}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def normalize_options(options)
|
|
35
|
+
{
|
|
36
|
+
keep_comments: options[:keep_comments],
|
|
37
|
+
preprocessors: Array(options[:preprocessors]),
|
|
38
|
+
ignore_includes: !!options[:ignore_includes],
|
|
39
|
+
file_path: options[:file_path] || ".",
|
|
40
|
+
actual_path: options[:actual_path] || "."
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def apply_preprocessors(xml, preprocessors)
|
|
45
|
+
preprocessors.reduce(xml) do |current, preprocessor|
|
|
46
|
+
preprocessor.respond_to?(:call) ? preprocessor.call(current).to_s : current
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def expand_includes(xml, options)
|
|
51
|
+
xml = normalize_html_void_tags(xml)
|
|
52
|
+
doc = Document.new(sanitize_bare_ampersands(xml))
|
|
53
|
+
includes = XPath.match(doc, "//mj-include")
|
|
54
|
+
return xml if includes.empty?
|
|
55
|
+
|
|
56
|
+
includes.reverse_each do |include_node|
|
|
57
|
+
path_attr = include_node.attributes["path"]
|
|
58
|
+
raise ParseError, "mj-include path is required" if path_attr.to_s.empty?
|
|
59
|
+
|
|
60
|
+
include_type = include_node.attributes["type"].to_s
|
|
61
|
+
resolved_path = resolve_include_path(path_attr, options[:actual_path], options[:file_path])
|
|
62
|
+
include_content = File.read(resolved_path)
|
|
63
|
+
|
|
64
|
+
replacement = if include_type == "html"
|
|
65
|
+
%(<mj-raw><![CDATA[#{escape_cdata(include_content)}]]></mj-raw>)
|
|
66
|
+
else
|
|
67
|
+
normalize_html_void_tags(strip_xml_declaration(include_content))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
fragment = Document.new(sanitize_bare_ampersands("<include-root>#{replacement}</include-root>"))
|
|
71
|
+
parent = include_node.parent
|
|
72
|
+
insert_before = include_node
|
|
73
|
+
fragment.root.children.each do |child|
|
|
74
|
+
parent.insert_before(insert_before, deep_clone(child))
|
|
75
|
+
end
|
|
76
|
+
parent.delete(include_node)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
output = +""
|
|
80
|
+
doc.write(output)
|
|
81
|
+
output
|
|
82
|
+
rescue Errno::ENOENT => e
|
|
83
|
+
raise ParseError, "Cannot read included file: #{e.message}"
|
|
84
|
+
rescue ParseException => e
|
|
85
|
+
raise ParseError, "Failed to parse included content: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def strip_xml_declaration(content)
|
|
89
|
+
content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_html_void_tags(content)
|
|
93
|
+
# Remove closing tags for void elements (e.g. </br>, </hr>).
|
|
94
|
+
# These are invalid in both HTML and XML but appear in legacy content.
|
|
95
|
+
content = content.gsub(/<\/(#{HTML_VOID_TAGS.join("|")})\s*>/i, "")
|
|
96
|
+
|
|
97
|
+
# Self-close opening void tags that aren't already self-closed.
|
|
98
|
+
pattern = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i
|
|
99
|
+
content.gsub(pattern) do |tag|
|
|
100
|
+
tag.end_with?("/>") ? tag : tag.sub(/>$/, " />")
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def escape_cdata(content)
|
|
105
|
+
content.to_s.gsub("]]>", "]]]]><![CDATA[>")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Escape bare "&" that are not part of a valid XML entity reference
|
|
109
|
+
# (e.g. & { ). This lets REXML parse HTML-ish content
|
|
110
|
+
# such as "Terms & Conditions" which is common in email templates.
|
|
111
|
+
def sanitize_bare_ampersands(content)
|
|
112
|
+
content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_include_path(include_path, actual_path, file_path)
|
|
116
|
+
include_path = include_path.to_s
|
|
117
|
+
return include_path if File.absolute_path(include_path) == include_path && File.file?(include_path)
|
|
118
|
+
|
|
119
|
+
candidates = []
|
|
120
|
+
candidates << File.expand_path(include_path, File.dirname(actual_path.to_s))
|
|
121
|
+
candidates << File.expand_path(include_path, file_path.to_s)
|
|
122
|
+
candidates << File.expand_path(include_path, Dir.pwd)
|
|
123
|
+
|
|
124
|
+
existing = candidates.find { |candidate| File.file?(candidate) }
|
|
125
|
+
return existing if existing
|
|
126
|
+
|
|
127
|
+
raise Errno::ENOENT, include_path
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def deep_clone(node)
|
|
131
|
+
case node
|
|
132
|
+
when Element
|
|
133
|
+
clone = Element.new(node.name)
|
|
134
|
+
node.attributes.each_attribute { |attr| clone.add_attribute(attr.expanded_name, attr.value) }
|
|
135
|
+
node.children.each { |child| clone.add(deep_clone(child)) }
|
|
136
|
+
clone
|
|
137
|
+
when Text
|
|
138
|
+
Text.new(node.value)
|
|
139
|
+
when Comment
|
|
140
|
+
Comment.new(node.string)
|
|
141
|
+
else
|
|
142
|
+
node
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def element_to_ast(element, keep_comments:)
|
|
147
|
+
raise ParseError, "Missing XML root element" unless element
|
|
148
|
+
|
|
149
|
+
children = element.children.each_with_object([]) do |child, memo|
|
|
150
|
+
case child
|
|
151
|
+
when Element
|
|
152
|
+
memo << element_to_ast(child, keep_comments: keep_comments)
|
|
153
|
+
when Text
|
|
154
|
+
text = child.value
|
|
155
|
+
memo << AstNode.new(tag_name: "#text", content: text) unless text.strip.empty?
|
|
156
|
+
when Comment
|
|
157
|
+
memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
AstNode.new(
|
|
162
|
+
tag_name: element.name,
|
|
163
|
+
attributes: element.attributes.each_with_object({}) { |(name, val), h| h[name] = val },
|
|
164
|
+
children: children,
|
|
165
|
+
line: nil
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|