inkcite 1.0.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 +7 -0
- data/LICENSE +20 -0
- data/README.md +110 -0
- data/Rakefile +8 -0
- data/assets/facebook-like.css +62 -0
- data/assets/facebook-like.js +59 -0
- data/assets/init/config.yml +97 -0
- data/assets/init/helpers.tsv +31 -0
- data/assets/init/source.html +60 -0
- data/assets/init/source.txt +6 -0
- data/bin/inkcite +6 -0
- data/inkcite.gemspec +42 -0
- data/lib/inkcite.rb +32 -0
- data/lib/inkcite/cli/base.rb +128 -0
- data/lib/inkcite/cli/build.rb +130 -0
- data/lib/inkcite/cli/init.rb +58 -0
- data/lib/inkcite/cli/preview.rb +30 -0
- data/lib/inkcite/cli/server.rb +123 -0
- data/lib/inkcite/cli/test.rb +61 -0
- data/lib/inkcite/email.rb +219 -0
- data/lib/inkcite/mailer.rb +140 -0
- data/lib/inkcite/minifier.rb +151 -0
- data/lib/inkcite/parser.rb +111 -0
- data/lib/inkcite/renderer.rb +177 -0
- data/lib/inkcite/renderer/base.rb +186 -0
- data/lib/inkcite/renderer/button.rb +168 -0
- data/lib/inkcite/renderer/div.rb +29 -0
- data/lib/inkcite/renderer/element.rb +82 -0
- data/lib/inkcite/renderer/footnote.rb +132 -0
- data/lib/inkcite/renderer/google_analytics.rb +35 -0
- data/lib/inkcite/renderer/image.rb +95 -0
- data/lib/inkcite/renderer/image_base.rb +82 -0
- data/lib/inkcite/renderer/in_browser.rb +38 -0
- data/lib/inkcite/renderer/like.rb +73 -0
- data/lib/inkcite/renderer/link.rb +243 -0
- data/lib/inkcite/renderer/litmus.rb +33 -0
- data/lib/inkcite/renderer/lorem.rb +39 -0
- data/lib/inkcite/renderer/mobile_image.rb +67 -0
- data/lib/inkcite/renderer/mobile_style.rb +40 -0
- data/lib/inkcite/renderer/mobile_toggle.rb +27 -0
- data/lib/inkcite/renderer/outlook_background.rb +48 -0
- data/lib/inkcite/renderer/partial.rb +31 -0
- data/lib/inkcite/renderer/preheader.rb +22 -0
- data/lib/inkcite/renderer/property.rb +39 -0
- data/lib/inkcite/renderer/responsive.rb +334 -0
- data/lib/inkcite/renderer/span.rb +21 -0
- data/lib/inkcite/renderer/table.rb +67 -0
- data/lib/inkcite/renderer/table_base.rb +149 -0
- data/lib/inkcite/renderer/td.rb +92 -0
- data/lib/inkcite/uploader.rb +173 -0
- data/lib/inkcite/util.rb +85 -0
- data/lib/inkcite/version.rb +3 -0
- data/lib/inkcite/view.rb +745 -0
- data/lib/inkcite/view/context.rb +38 -0
- data/lib/inkcite/view/media_query.rb +60 -0
- data/lib/inkcite/view/tag_stack.rb +38 -0
- data/test/email_spec.rb +16 -0
- data/test/parser_spec.rb +72 -0
- data/test/project/config.yml +98 -0
- data/test/project/helpers.tsv +56 -0
- data/test/project/images/inkcite.jpg +0 -0
- data/test/project/source.html +58 -0
- data/test/project/source.txt +6 -0
- data/test/renderer/button_spec.rb +45 -0
- data/test/renderer/div_spec.rb +101 -0
- data/test/renderer/element_spec.rb +31 -0
- data/test/renderer/footnote_spec.rb +57 -0
- data/test/renderer/image_spec.rb +82 -0
- data/test/renderer/link_spec.rb +84 -0
- data/test/renderer/mobile_image_spec.rb +27 -0
- data/test/renderer/mobile_style_spec.rb +37 -0
- data/test/renderer/td_spec.rb +126 -0
- data/test/renderer_spec.rb +28 -0
- data/test/view_spec.rb +15 -0
- metadata +333 -0
data/lib/inkcite/util.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
# Includes hex color manipulation from
|
2
|
+
# http://www.redguava.com.au/2011/10/lighten-or-darken-a-hexadecimal-color-in-ruby-on-rails/
|
3
|
+
module Inkcite
|
4
|
+
module Util
|
5
|
+
|
6
|
+
def self.brightness_value color
|
7
|
+
color.nil? ? 0 : (color.gsub('#', '').scan(/../).map { |c| c.hex }).inject { |sum, c| sum + c }
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.darken color, amount=0.4
|
11
|
+
return BLACK if color.nil?
|
12
|
+
rgb = color.gsub('#', '').scan(/../).map { |color| color.hex }
|
13
|
+
rgb[0] = (rgb[0].to_i * amount).round
|
14
|
+
rgb[1] = (rgb[1].to_i * amount).round
|
15
|
+
rgb[2] = (rgb[2].to_i * amount).round
|
16
|
+
"#%02x%02x%02x" % rgb
|
17
|
+
end
|
18
|
+
|
19
|
+
# Iterates through the list of possible options and returns the
|
20
|
+
# first non-blank value.
|
21
|
+
def self.detect *opts
|
22
|
+
opts.detect { |o| !o.blank? }
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.lighten color, amount=0.6
|
26
|
+
return WHITE if color.nil?
|
27
|
+
rgb = color.gsub('#', '').scan(/../).map { |color| color.hex }
|
28
|
+
rgb[0] = [(rgb[0].to_i + 255 * amount).round, 255].min
|
29
|
+
rgb[1] = [(rgb[1].to_i + 255 * amount).round, 255].min
|
30
|
+
rgb[2] = [(rgb[2].to_i + 255 * amount).round, 255].min
|
31
|
+
"#%02x%02x%02x" % rgb
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.contrasting_text_color color
|
35
|
+
brightness_value(color) > 382.5 ? darken(color) : lighten(color)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.each_line path, fail_if_not_exists, &block
|
39
|
+
|
40
|
+
if File.exists?(path)
|
41
|
+
File.open(path).each { |line| yield line.strip }
|
42
|
+
elsif fail_if_not_exists
|
43
|
+
raise "File not found: #{path}"
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.read *argv
|
49
|
+
path = File.join(File.expand_path('../..', File.dirname(__FILE__)), argv)
|
50
|
+
if File.exists?(path)
|
51
|
+
line = File.open(path).read
|
52
|
+
line.gsub!(/[\r\f\n]+/, "\n")
|
53
|
+
line.gsub!(/ {2,}/, ' ')
|
54
|
+
line
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.read_yml file, fail_if_not_exists=false
|
59
|
+
if File.exist?(file)
|
60
|
+
symbolize_keys(YAML.load_file(file))
|
61
|
+
elsif fail_if_not_exists
|
62
|
+
raise "File not found: #{file}" if fail_if_not_exists
|
63
|
+
else
|
64
|
+
{}
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
BLACK = '#000000'
|
71
|
+
WHITE = '#111111'
|
72
|
+
|
73
|
+
# Recursive key symbolization for the provided Hash.
|
74
|
+
def self.symbolize_keys hash
|
75
|
+
unless hash.nil?
|
76
|
+
hash.symbolize_keys!
|
77
|
+
hash.each do |k, v|
|
78
|
+
symbolize_keys(v) if v.is_a?(Hash)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
hash
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
data/lib/inkcite/view.rb
ADDED
@@ -0,0 +1,745 @@
|
|
1
|
+
require_relative 'view/context'
|
2
|
+
require_relative 'view/media_query'
|
3
|
+
require_relative 'view/tag_stack'
|
4
|
+
|
5
|
+
module Inkcite
|
6
|
+
class View
|
7
|
+
|
8
|
+
# The base Email object this is a view of
|
9
|
+
attr_reader :email
|
10
|
+
|
11
|
+
# The rendered html or content available after render! has been called.
|
12
|
+
attr_reader :content
|
13
|
+
|
14
|
+
# One of :development, :preview or :production
|
15
|
+
attr_reader :environment
|
16
|
+
|
17
|
+
# The version of the email (e.g. :default)
|
18
|
+
attr_reader :version
|
19
|
+
|
20
|
+
# The format of the email (e.g. :email or :text)
|
21
|
+
attr_reader :format
|
22
|
+
|
23
|
+
# Manages the Responsive::Rules applied to this email view.
|
24
|
+
attr_reader :media_query
|
25
|
+
|
26
|
+
# Line number of the email file being processed
|
27
|
+
attr_accessor :line
|
28
|
+
|
29
|
+
# The configuration hash for the view
|
30
|
+
attr_accessor :config
|
31
|
+
|
32
|
+
# The array of error messages collected during rendering
|
33
|
+
attr_accessor :errors
|
34
|
+
|
35
|
+
# Will be populated with the css and js compressor objects
|
36
|
+
# after first use. Ensures we can reset the compressors
|
37
|
+
# after a rendering is complete.
|
38
|
+
attr_accessor :css_compressor
|
39
|
+
attr_accessor :js_compressor
|
40
|
+
|
41
|
+
def initialize email, environment, format, version, config
|
42
|
+
@email = email
|
43
|
+
@environment = environment
|
44
|
+
@format = format
|
45
|
+
@version = version
|
46
|
+
|
47
|
+
# TODO Read this ourselves and run it through Erubis for better
|
48
|
+
# a/b testing capabilities
|
49
|
+
@config = config
|
50
|
+
|
51
|
+
# Expose the version, format as a properties so that it can be resolved when
|
52
|
+
# processing pathnames and such. These need to be strings because they are
|
53
|
+
# cloned during rendering.
|
54
|
+
@config[:version] = version.to_s
|
55
|
+
@config[:format] = format.to_s
|
56
|
+
@config[FILE_NAME] = file_name
|
57
|
+
|
58
|
+
# The MediaQuery object manages the responsive styles that are applied to
|
59
|
+
# the email during rendering.
|
60
|
+
@media_query = MediaQuery.new(self, 480)
|
61
|
+
|
62
|
+
# Set the version index based on the position of this
|
63
|
+
# version in the list of those defined.
|
64
|
+
@config[:'version-index'] = (email.versions.index(version) + 1).to_s
|
65
|
+
|
66
|
+
# Tracks the line number and is recorded when errors are encountered
|
67
|
+
# while rendering said line.
|
68
|
+
@line = 0
|
69
|
+
|
70
|
+
# True if VML is used during the preparation of this email.
|
71
|
+
@vml_used = false
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
def [] key
|
76
|
+
key = key.to_sym
|
77
|
+
|
78
|
+
# Look for configuration specific to the environment and then format.
|
79
|
+
env_cfg = config[@environment] || EMPTY_HASH
|
80
|
+
ver_cfg = env_cfg[@version] || config[@version] || EMPTY_HASH
|
81
|
+
fmt_cfg = env_cfg[@format] || EMPTY_HASH
|
82
|
+
|
83
|
+
# Not using || operator because the value can be legitimately false (e.g. minify
|
84
|
+
# is disabled) so only a nil should trigger moving on to the next level up the
|
85
|
+
# hierarchy.
|
86
|
+
val = ver_cfg[key]
|
87
|
+
val = fmt_cfg[key] if val.nil?
|
88
|
+
val = env_cfg[key] if val.nil?
|
89
|
+
val = config[key] if val.nil?
|
90
|
+
|
91
|
+
val
|
92
|
+
end
|
93
|
+
|
94
|
+
# Verifies that the provided image file (e.g. "banner.jpg") exists in the
|
95
|
+
# project's image subdirectory. If not, reports the missing image to the
|
96
|
+
# developer (unless that is explicitly disabled).
|
97
|
+
def assert_image_exists src
|
98
|
+
|
99
|
+
# This is the full path to the image on the dev's harddrive.
|
100
|
+
path = @email.image_path(src)
|
101
|
+
exists = File.exists?(path)
|
102
|
+
|
103
|
+
error('Missing image', { :src => src }) if !exists
|
104
|
+
|
105
|
+
exists
|
106
|
+
end
|
107
|
+
|
108
|
+
def browser?
|
109
|
+
@format == :browser
|
110
|
+
end
|
111
|
+
|
112
|
+
def default?
|
113
|
+
@version == :default
|
114
|
+
end
|
115
|
+
|
116
|
+
def development?
|
117
|
+
@environment == :development
|
118
|
+
end
|
119
|
+
|
120
|
+
def email?
|
121
|
+
@format == :email
|
122
|
+
end
|
123
|
+
|
124
|
+
def eval_erb source, file_name
|
125
|
+
Erubis::Eruby.new(source, :filename => file_name, :trim => false, :numbering => true).evaluate(Context.new(self))
|
126
|
+
end
|
127
|
+
|
128
|
+
# Records an error message on the currently processing line of the source.
|
129
|
+
def error message, obj=nil
|
130
|
+
|
131
|
+
message << " (line #{self.line.to_i})"
|
132
|
+
unless obj.blank?
|
133
|
+
message << ' ['
|
134
|
+
message << obj.collect { |k, v| "#{k}=#{v}" }.join(', ')
|
135
|
+
message << ']'
|
136
|
+
end
|
137
|
+
|
138
|
+
@errors ||= []
|
139
|
+
@errors << message
|
140
|
+
|
141
|
+
true
|
142
|
+
end
|
143
|
+
|
144
|
+
def footer
|
145
|
+
@footer ||= []
|
146
|
+
end
|
147
|
+
|
148
|
+
def footnotes
|
149
|
+
@footnotes ||= []
|
150
|
+
end
|
151
|
+
|
152
|
+
def file_name ext=nil
|
153
|
+
|
154
|
+
# Check to see if the file name has been configured.
|
155
|
+
fn = self[FILE_NAME]
|
156
|
+
if fn.blank?
|
157
|
+
|
158
|
+
# Default naming based on the number of versions - only the format if there is
|
159
|
+
# a single version or version and format when there are multiple versions.
|
160
|
+
fn = if email.versions.length > 1
|
161
|
+
'{version}-{format}'
|
162
|
+
elsif text?
|
163
|
+
'email'
|
164
|
+
else
|
165
|
+
'{format}'
|
166
|
+
end
|
167
|
+
|
168
|
+
end
|
169
|
+
|
170
|
+
|
171
|
+
# Need to render the name to convert embedded tags to actual values.
|
172
|
+
fn = Renderer.render(fn, self)
|
173
|
+
|
174
|
+
# Sanity check to ensure there is an appropriate extension on the
|
175
|
+
# file name.
|
176
|
+
ext ||= (text?? TXT_EXTENSION : HTML_EXTENSION)
|
177
|
+
fn << ext unless File.extname(fn) == ext
|
178
|
+
|
179
|
+
fn
|
180
|
+
end
|
181
|
+
|
182
|
+
def image_url src
|
183
|
+
|
184
|
+
src_url = ''
|
185
|
+
|
186
|
+
# Prepend the image host onto the src if one is specified in the properties.
|
187
|
+
# During local development, images are always expected in an images/ subdirectory.
|
188
|
+
image_host = development?? "#{Email::IMAGES}/" : self[Email::IMAGE_HOST]
|
189
|
+
src_url << image_host unless image_host.blank?
|
190
|
+
|
191
|
+
# Add the source of the image.
|
192
|
+
src_url << src
|
193
|
+
|
194
|
+
# Cache-bust the image if the caller is expecting it to be there.
|
195
|
+
src_url << "?#{Time.now.to_i}" if is_enabled?(Email::CACHE_BUST)
|
196
|
+
|
197
|
+
# Transpose any embedded tags into actual values.
|
198
|
+
Renderer.render(src_url, self)
|
199
|
+
end
|
200
|
+
|
201
|
+
# Tests if a configuration value has been enabled. This assumes
|
202
|
+
# it is disabled by default but that a value of true, 'true' or 1
|
203
|
+
# for the value indicates it is enabled.
|
204
|
+
def is_enabled? key
|
205
|
+
val = self[key]
|
206
|
+
!val.blank? && val != false && (val == true || val == true.to_s || val.to_i == 1)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Tests if a configuration value has been disabled. This assumes
|
210
|
+
# it is enabled by default but that a value of false, 'false' or 0
|
211
|
+
# will indicate it is disabled.
|
212
|
+
def is_disabled? key
|
213
|
+
val = self[key]
|
214
|
+
!val.nil? && (val == false || val == false.to_s)
|
215
|
+
end
|
216
|
+
|
217
|
+
|
218
|
+
def links_file_name
|
219
|
+
|
220
|
+
# There is nothing to return if trackable links aren't enabled.
|
221
|
+
return nil unless track_links?
|
222
|
+
|
223
|
+
fn = ''
|
224
|
+
fn << "#{@version}-" if email.versions.length > 1
|
225
|
+
fn << 'links.csv'
|
226
|
+
|
227
|
+
# Need to render the name to convert embedded tags to actual values.
|
228
|
+
Renderer.render(fn, self)
|
229
|
+
|
230
|
+
end
|
231
|
+
|
232
|
+
# Map of hrefs by their unique ID
|
233
|
+
def links
|
234
|
+
@links ||= {}
|
235
|
+
end
|
236
|
+
|
237
|
+
def meta key
|
238
|
+
md = meta_data
|
239
|
+
md.nil?? nil : md[key]
|
240
|
+
end
|
241
|
+
|
242
|
+
# Returns the opts for the parent matching the designated
|
243
|
+
# tag, if any are presently open.
|
244
|
+
def parent_opts tag
|
245
|
+
tag_stack(tag).opts
|
246
|
+
end
|
247
|
+
|
248
|
+
def preview?
|
249
|
+
@environment == :preview
|
250
|
+
end
|
251
|
+
|
252
|
+
def production?
|
253
|
+
@environment == :production
|
254
|
+
end
|
255
|
+
|
256
|
+
def render!
|
257
|
+
raise "Already rendered" unless @content.blank?
|
258
|
+
|
259
|
+
source_file = 'source'
|
260
|
+
source_file << (text?? TXT_EXTENSION : HTML_EXTENSION)
|
261
|
+
|
262
|
+
# Will be used to assemble the parameters passed to File.open.
|
263
|
+
# First, always open the file in read mode.
|
264
|
+
mode = [ 'r' ]
|
265
|
+
|
266
|
+
# Detect abnormal file encoding and construct the string to
|
267
|
+
# convert such encoding to UTF-8 if specified.
|
268
|
+
encoding = self[SOURCE_ENCODING]
|
269
|
+
if !encoding.blank? && encoding != UTF_8
|
270
|
+
mode << encoding
|
271
|
+
mode << UTF_8
|
272
|
+
end
|
273
|
+
|
274
|
+
# Read the original source which may include embedded Ruby.
|
275
|
+
source = File.open(@email.project_file(source_file), mode.join(':')).read
|
276
|
+
|
277
|
+
# Run the content through Erubis
|
278
|
+
filtered = self.eval_erb(source, source_file)
|
279
|
+
|
280
|
+
# Protect against unsupported characters
|
281
|
+
Renderer.fix_illegal_characters filtered, self
|
282
|
+
|
283
|
+
# Filter each of the lines of text and push them onto the stack of lines
|
284
|
+
# that we be written into the text or html file.
|
285
|
+
lines = render_each(filtered)
|
286
|
+
|
287
|
+
@content = if text?
|
288
|
+
lines.join(NEW_LINE)
|
289
|
+
|
290
|
+
else
|
291
|
+
|
292
|
+
# Minify the content of the email.
|
293
|
+
minified = Minifier.html(lines, self)
|
294
|
+
|
295
|
+
# Some last-minute fixes before we assemble the wrapping content.
|
296
|
+
prevent_ios_date_detection minified
|
297
|
+
|
298
|
+
# Prepare a copy of the HTML for saving as the file.
|
299
|
+
html = []
|
300
|
+
html << '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
|
301
|
+
|
302
|
+
# Resolve the HTML declaration for this email based on whether or not VML was used.
|
303
|
+
html_declaration = '<html xmlns="http://www.w3.org/1999/xhtml"'
|
304
|
+
html_declaration << ' xmlns:v="urn:schemas-microsoft-com:vml" lang="en" xml:lang="en"' if vml_used?
|
305
|
+
html_declaration << '>'
|
306
|
+
html << html_declaration
|
307
|
+
|
308
|
+
html << '<head>'
|
309
|
+
html << '<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>'
|
310
|
+
html << '<meta name="viewport" content="width=device-width"/>'
|
311
|
+
html << "<meta name=\"generator\" content=\"Inkcite #{Inkcite::VERSION}\"/>"
|
312
|
+
|
313
|
+
html << "<title>#{self.title}</title>"
|
314
|
+
|
315
|
+
# Add external script sources.
|
316
|
+
html += external_scripts
|
317
|
+
|
318
|
+
# Add external styles
|
319
|
+
html += external_styles
|
320
|
+
|
321
|
+
html << '<style type="text/css">'
|
322
|
+
html << inline_styles
|
323
|
+
html << '</style>'
|
324
|
+
html << '</head>'
|
325
|
+
|
326
|
+
# Render the body statement and apply the email's background color to it.
|
327
|
+
bgcolor = Renderer.hex(self[BACKGROUND])
|
328
|
+
|
329
|
+
# Intentially not setting the link colors because those should be entirely
|
330
|
+
# controlled by the styles and attributes of the links themselves. By not
|
331
|
+
# setting it, links created sans-helper should be visually distinct.
|
332
|
+
html << Renderer.render("<body bgcolor=\"#{bgcolor}\" style=\"background-color: #{bgcolor}; width: 100% !important; min-width: 100% !important; margin: 0; padding: 0; -webkit-text-size-adjust: none; -ms-text-size-adjust: none;\">", self)
|
333
|
+
|
334
|
+
html << minified
|
335
|
+
|
336
|
+
# Append any arbitrary footer content
|
337
|
+
html << inline_footer
|
338
|
+
|
339
|
+
# Add inline scripts
|
340
|
+
html << inline_scripts
|
341
|
+
|
342
|
+
html << '</body></html>'
|
343
|
+
|
344
|
+
# Remove all blank lines and assemble the wrapped content into a
|
345
|
+
# a single string.
|
346
|
+
html.select { |l| !l.blank? }.join(NEW_LINE)
|
347
|
+
|
348
|
+
end
|
349
|
+
|
350
|
+
# Ensure that all failsafes pass
|
351
|
+
assert_failsafes
|
352
|
+
|
353
|
+
# Verify that the tag stack is open which indicates all opened tags were
|
354
|
+
# properly closed - e.g. all {table}s have matching {/table}s.
|
355
|
+
#open_stack = @tag_stack && @tag_stack.select { |k, v| !v.empty? }
|
356
|
+
#raise open_stack.inspect
|
357
|
+
#error 'One or more {tags} may have been left open', { :open_stack => open_stack.collect(&:tag) } if open_stack
|
358
|
+
|
359
|
+
@content
|
360
|
+
end
|
361
|
+
|
362
|
+
def rendered?
|
363
|
+
!@content.blank?
|
364
|
+
end
|
365
|
+
|
366
|
+
def scripts
|
367
|
+
@scripts ||= []
|
368
|
+
end
|
369
|
+
|
370
|
+
def set_meta key, value
|
371
|
+
md = meta_data || {}
|
372
|
+
md[key.to_sym] = value
|
373
|
+
|
374
|
+
# Write the hash back to the email's meta data.
|
375
|
+
@email.set_meta version, md
|
376
|
+
|
377
|
+
value
|
378
|
+
end
|
379
|
+
|
380
|
+
def styles
|
381
|
+
@styles ||= []
|
382
|
+
end
|
383
|
+
|
384
|
+
def subject
|
385
|
+
@subject ||= Renderer.render((self[:subject] || self[:title] || UNTITLED_EMAIL), self)
|
386
|
+
end
|
387
|
+
|
388
|
+
def tag_stack tag
|
389
|
+
@tag_stack ||= Hash.new()
|
390
|
+
@tag_stack[tag] ||= TagStack.new(tag, self)
|
391
|
+
end
|
392
|
+
|
393
|
+
def title
|
394
|
+
@title ||= Renderer.render((self[:title] || UNTITLED_EMAIL), self)
|
395
|
+
end
|
396
|
+
|
397
|
+
# Sends this version of the email to Litmus for testing.
|
398
|
+
def test!
|
399
|
+
EmailTest.test! self
|
400
|
+
end
|
401
|
+
|
402
|
+
def text?
|
403
|
+
@format == :text
|
404
|
+
end
|
405
|
+
|
406
|
+
def track_links?
|
407
|
+
!self[Email::TRACK_LINKS].blank?
|
408
|
+
end
|
409
|
+
|
410
|
+
# Generates an incremental ID for the designated key. The first time a
|
411
|
+
# key is used, it will return a 1. Subsequent requests for said key will
|
412
|
+
# return 2, 3, etc.
|
413
|
+
def unique_id key
|
414
|
+
@unique_ids ||= Hash.new(0)
|
415
|
+
@unique_ids[key] += 1
|
416
|
+
end
|
417
|
+
|
418
|
+
# Returns true if vml is enabled in this context. This requires that the
|
419
|
+
# context is for an email and that the VML property is enabled.
|
420
|
+
def vml_enabled?
|
421
|
+
email? && is_enabled?(:vml)
|
422
|
+
end
|
423
|
+
|
424
|
+
# Signifies that VML was used during the rendering and that
|
425
|
+
def vml_used!
|
426
|
+
raise 'VML was used but is not enabled' unless vml_enabled?
|
427
|
+
@vml_used = true
|
428
|
+
end
|
429
|
+
|
430
|
+
def vml_used?
|
431
|
+
@vml_used == true
|
432
|
+
end
|
433
|
+
|
434
|
+
def write out
|
435
|
+
|
436
|
+
# Ensure that the version has been rendered fully
|
437
|
+
render!
|
438
|
+
|
439
|
+
# Fully-qualify the filename - e.g. public/project/issue/file_name and then write the
|
440
|
+
# contents of the HTML to said file.
|
441
|
+
out.write(@content)
|
442
|
+
|
443
|
+
true
|
444
|
+
end
|
445
|
+
|
446
|
+
def write_links_csv out
|
447
|
+
|
448
|
+
unless @links.blank?
|
449
|
+
|
450
|
+
require 'csv'
|
451
|
+
csv = CSV.new(out, :force_quotes => true)
|
452
|
+
|
453
|
+
# Write each link to the CSV file.
|
454
|
+
@links.keys.sort.each { |k| csv << [k, @links[k]] }
|
455
|
+
end
|
456
|
+
|
457
|
+
true
|
458
|
+
end
|
459
|
+
|
460
|
+
private
|
461
|
+
|
462
|
+
ASSETS = 'assets'
|
463
|
+
BACKGROUND = :'#background'
|
464
|
+
FILE_SCHEME = 'file'
|
465
|
+
FILE_NAME = :'file-name'
|
466
|
+
HTML_EXTENSION = '.html'
|
467
|
+
LINKS_EXTENSION = '-links.csv'
|
468
|
+
NEW_LINE = "\n"
|
469
|
+
REGEX_SLASH = '/'
|
470
|
+
SOURCE_ENCODING = :'source-encoding'
|
471
|
+
TXT_EXTENSION = '.txt'
|
472
|
+
UTF_8 = 'utf-8'
|
473
|
+
|
474
|
+
# Empty hash used when there is no environment or format-specific configuration
|
475
|
+
EMPTY_HASH = {}
|
476
|
+
|
477
|
+
# Name of the property holding the email field used to ensure that an unsubscribe has
|
478
|
+
# been placed into emails.
|
479
|
+
EMAIL_MERGE_TAG = :'email-merge-tag'
|
480
|
+
|
481
|
+
# Used when there is no subject or title for this email.
|
482
|
+
UNTITLED_EMAIL = 'Untitled Email'
|
483
|
+
|
484
|
+
def assert_in_browser msg
|
485
|
+
raise msg if email? && !development?
|
486
|
+
end
|
487
|
+
|
488
|
+
def assert_failsafes
|
489
|
+
|
490
|
+
passes = true
|
491
|
+
|
492
|
+
failsafes = self[:failsafe] || self[:failsafes]
|
493
|
+
unless failsafes.blank?
|
494
|
+
|
495
|
+
_includes = failsafes[:includes]
|
496
|
+
[*_includes].each do |rule|
|
497
|
+
if !content_matches?(rule)
|
498
|
+
error "Failsafe! Email does not include \"#{rule}\""
|
499
|
+
passes = false
|
500
|
+
end
|
501
|
+
end
|
502
|
+
|
503
|
+
_excludes = failsafes[:excludes]
|
504
|
+
[*_excludes].each do |rule|
|
505
|
+
if content_matches?(rule)
|
506
|
+
error("Failsafe! Email must not include \"#{rule}\"")
|
507
|
+
passes = false
|
508
|
+
end
|
509
|
+
end
|
510
|
+
|
511
|
+
end
|
512
|
+
|
513
|
+
passes
|
514
|
+
end
|
515
|
+
|
516
|
+
# Returns true if the content in this email either matches the
|
517
|
+
# regular expression provided or if it includes the exact string
|
518
|
+
# that is provided.
|
519
|
+
def content_matches? rule
|
520
|
+
# Check to see if the failsafe rule is a regular expression.
|
521
|
+
if rule[0, 1] == REGEX_SLASH && rule[-1, 1] == REGEX_SLASH
|
522
|
+
@content.match(Regexp.new(rule[1..-2]))
|
523
|
+
else
|
524
|
+
@content.include?(rule)
|
525
|
+
end
|
526
|
+
end
|
527
|
+
|
528
|
+
def external_scripts
|
529
|
+
html = []
|
530
|
+
|
531
|
+
self.scripts.each do |js|
|
532
|
+
if js.is_a?(URI::HTTP)
|
533
|
+
assert_in_browser 'External scripts prohibited in emails'
|
534
|
+
html << "<script src=\"#{js.to_s}\"></script>"
|
535
|
+
end
|
536
|
+
end
|
537
|
+
|
538
|
+
html
|
539
|
+
end
|
540
|
+
|
541
|
+
def external_styles
|
542
|
+
html = []
|
543
|
+
|
544
|
+
self.styles.each do |css|
|
545
|
+
if css.is_a?(URI::HTTP)
|
546
|
+
assert_in_browser 'External stylesheets prohibited in emails'
|
547
|
+
html << "<link href=\"#{css.to_s}\" rel=\"stylesheet\">"
|
548
|
+
end
|
549
|
+
end
|
550
|
+
|
551
|
+
html
|
552
|
+
end
|
553
|
+
|
554
|
+
def from_uri uri
|
555
|
+
if uri.is_a?(URI)
|
556
|
+
if uri.scheme == FILE_SCHEME # e.g. file://facebook-like.js
|
557
|
+
return Util.read(ASSETS, uri.host)
|
558
|
+
else
|
559
|
+
raise "Unsupported URI scheme: #{uri.to_s}"
|
560
|
+
end
|
561
|
+
end
|
562
|
+
|
563
|
+
# Otherwise, return the string which is assumed to be already
|
564
|
+
uri
|
565
|
+
end
|
566
|
+
|
567
|
+
def inline_footer
|
568
|
+
html = ''
|
569
|
+
self.footer.each { |f| html << Minifier.html(f.split("\n"), self) }
|
570
|
+
html
|
571
|
+
end
|
572
|
+
|
573
|
+
def inline_scripts
|
574
|
+
|
575
|
+
code = ''
|
576
|
+
|
577
|
+
self.scripts.each do |js|
|
578
|
+
next if js.is_a?(URI::HTTP)
|
579
|
+
|
580
|
+
# Check to see if we've received a URI to a local asset file or if it's just javascript
|
581
|
+
# to be included in the file.
|
582
|
+
code << from_uri(js)
|
583
|
+
|
584
|
+
end
|
585
|
+
|
586
|
+
unless code.blank?
|
587
|
+
assert_in_browser 'Scripts prohibited in emails'
|
588
|
+
code = Minifier.js(code, self)
|
589
|
+
code = "<script>\n#{code}\n</script>"
|
590
|
+
end
|
591
|
+
|
592
|
+
code
|
593
|
+
end
|
594
|
+
|
595
|
+
def inline_styles
|
596
|
+
|
597
|
+
# This is the default font family for the email.
|
598
|
+
font_family = self[Renderer::Base::FONT_FAMILY]
|
599
|
+
|
600
|
+
reset = []
|
601
|
+
|
602
|
+
if email?
|
603
|
+
|
604
|
+
# Forces Hotmail to display emails at full width
|
605
|
+
reset << '.ExternalClass, .ReadMsgBody { width:100%; }'
|
606
|
+
|
607
|
+
# Forces Hotmail to display normal line spacing, here is more on that:
|
608
|
+
# http://www.emailonacid.com/forum/viewthread/43/
|
609
|
+
reset << '.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; }'
|
610
|
+
|
611
|
+
# Not sure where I got this fix from.
|
612
|
+
reset << '#outlook a { padding: 0; }'
|
613
|
+
|
614
|
+
# Body text color for the New Yahoo.
|
615
|
+
reset << '.yshortcuts, .yshortcuts a, .yshortcuts a:link,.yshortcuts a:visited, .yshortcuts a:hover, .yshortcuts a span { color: black; text-decoration: none !important; border-bottom: none !important; background: none !important; }'
|
616
|
+
|
617
|
+
# Hides 'Today' ads in Yahoo!
|
618
|
+
# https://litmus.com/blog/hiding-today-ads-yahoo?utm_source=newsletter&utm_medium=email&utm_campaign=april2012news */
|
619
|
+
reset << 'XHTML-STRIPONREPLY { display:none; }'
|
620
|
+
|
621
|
+
# This resolves the Outlook 07, 10, and Gmail td padding issue. Here's more info:
|
622
|
+
# http://www.ianhoar.com/2008/04/29/outlook-2007-borders-and-1px-padding-on-table-cells
|
623
|
+
# http://www.campaignmonitor.com/blog/post/3392/1px-borders-padding-on-table-cells-in-outlook-07
|
624
|
+
reset << 'table { border-spacing: 0; }'
|
625
|
+
reset << 'table, td { border-collapse: collapse; }'
|
626
|
+
|
627
|
+
# Ensure that telephone numbers are displayed using the same style as links.
|
628
|
+
reset << "a[href^=tel] { color: #{self[Renderer::Base::LINK_COLOR]}; text-decoration:none;}"
|
629
|
+
|
630
|
+
end
|
631
|
+
|
632
|
+
# Reset the font on every cell to the default family.
|
633
|
+
reset << "td { font-family: #{self[Renderer::Base::FONT_FAMILY]}; }"
|
634
|
+
|
635
|
+
# Obviously VML-specific CSS needed only if VML was used in the issue.
|
636
|
+
if vml_used?
|
637
|
+
reset << 'v\:* { behavior: url(#default#VML); display: inline-block; }'
|
638
|
+
reset << 'o\:* { behavior: url(#default#VML); display: inline-block; }'
|
639
|
+
end
|
640
|
+
|
641
|
+
# Google Web Fonts support courtesy of
|
642
|
+
# http://www.emaildesignreview.com/html-email-coding/web-fonts-in-email-1482/
|
643
|
+
font_urls = self[:fonts]
|
644
|
+
unless font_urls.blank?
|
645
|
+
require 'open-uri'
|
646
|
+
|
647
|
+
# If you use @font-face in HTML email, Outlook 07/10/13 will default all
|
648
|
+
# text back to Times New Roman.
|
649
|
+
# http://www.emaildesignreview.com/html-email-coding/web-fonts-in-email-1482/
|
650
|
+
reset << "@media screen {"
|
651
|
+
|
652
|
+
# Iterate through the configured fonts and
|
653
|
+
font_urls.each do |url|
|
654
|
+
begin
|
655
|
+
reset << open(url).read
|
656
|
+
rescue
|
657
|
+
error "Unable to load Google Web Font", { :url => url }
|
658
|
+
end
|
659
|
+
|
660
|
+
end
|
661
|
+
reset << "}"
|
662
|
+
|
663
|
+
end
|
664
|
+
|
665
|
+
# Responsive styles.
|
666
|
+
reset += @media_query.to_a unless @media_query.blank?
|
667
|
+
|
668
|
+
html = []
|
669
|
+
|
670
|
+
# Append the minified CSS
|
671
|
+
html << Minifier.css(reset.join(NEW_LINE), self)
|
672
|
+
|
673
|
+
# Iterate through the list of files or in-line CSS and embed them into the HTML.
|
674
|
+
self.styles.each do |css|
|
675
|
+
next if css.is_a?(URI::HTTP)
|
676
|
+
html << Minifier.css(from_uri(css), self)
|
677
|
+
end
|
678
|
+
|
679
|
+
html.join(NEW_LINE)
|
680
|
+
end
|
681
|
+
|
682
|
+
# Retrieves the version-specific meta data for this view.
|
683
|
+
def meta_data
|
684
|
+
@email.meta version
|
685
|
+
end
|
686
|
+
|
687
|
+
def prevent_ios_date_detection raw
|
688
|
+
|
689
|
+
# Currently always performed in email but may want a configuration setting
|
690
|
+
# that allows a creator to disable this default functionality.
|
691
|
+
enabled = email?
|
692
|
+
if enabled
|
693
|
+
|
694
|
+
# Prevent dates (e.g. "February 28") from getting turned into unsightly blue
|
695
|
+
# links on iOS by putting non-rendering whitespace within.
|
696
|
+
# http://www.industrydive.com/blog/how-to-fix-email-marketing-for-iphone-ipad/
|
697
|
+
Date::MONTHNAMES.select { |mon| !mon.blank? }.each do |mon|
|
698
|
+
|
699
|
+
# Look for full month names (e.g. February) and add a zero-width space
|
700
|
+
# afterwards which prevents iOS from detecting said date.
|
701
|
+
raw.gsub!(/#{mon}/, "#{mon}#{Renderer::Base::ZERO_WIDTH_SPACE}")
|
702
|
+
|
703
|
+
end
|
704
|
+
|
705
|
+
end
|
706
|
+
|
707
|
+
enabled
|
708
|
+
end
|
709
|
+
|
710
|
+
def render_each filtered
|
711
|
+
|
712
|
+
lines = []
|
713
|
+
|
714
|
+
filtered.split("\n").each do |line|
|
715
|
+
|
716
|
+
# Increment the line number as we read the file.
|
717
|
+
@line += 1
|
718
|
+
|
719
|
+
begin
|
720
|
+
line = Renderer.render(line, self)
|
721
|
+
rescue Exception => e
|
722
|
+
error e.message, { :trace => e.backtrace.first.gsub(/%2F/, '/') }
|
723
|
+
end
|
724
|
+
|
725
|
+
if text?
|
726
|
+
|
727
|
+
# No additional splitting should be performed on the text version of the email.
|
728
|
+
# Otherwise blank lines are lost.
|
729
|
+
lines << line
|
730
|
+
|
731
|
+
else
|
732
|
+
|
733
|
+
# Sometimes the renderer inserts additional new-lines so we need to split them out
|
734
|
+
# into individual lines if necessary. Push all of the lines onto the issue's line array.
|
735
|
+
lines += line.split(NEW_LINE)
|
736
|
+
|
737
|
+
end
|
738
|
+
|
739
|
+
end
|
740
|
+
|
741
|
+
lines
|
742
|
+
end
|
743
|
+
|
744
|
+
end
|
745
|
+
end
|