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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +110 -0
  4. data/Rakefile +8 -0
  5. data/assets/facebook-like.css +62 -0
  6. data/assets/facebook-like.js +59 -0
  7. data/assets/init/config.yml +97 -0
  8. data/assets/init/helpers.tsv +31 -0
  9. data/assets/init/source.html +60 -0
  10. data/assets/init/source.txt +6 -0
  11. data/bin/inkcite +6 -0
  12. data/inkcite.gemspec +42 -0
  13. data/lib/inkcite.rb +32 -0
  14. data/lib/inkcite/cli/base.rb +128 -0
  15. data/lib/inkcite/cli/build.rb +130 -0
  16. data/lib/inkcite/cli/init.rb +58 -0
  17. data/lib/inkcite/cli/preview.rb +30 -0
  18. data/lib/inkcite/cli/server.rb +123 -0
  19. data/lib/inkcite/cli/test.rb +61 -0
  20. data/lib/inkcite/email.rb +219 -0
  21. data/lib/inkcite/mailer.rb +140 -0
  22. data/lib/inkcite/minifier.rb +151 -0
  23. data/lib/inkcite/parser.rb +111 -0
  24. data/lib/inkcite/renderer.rb +177 -0
  25. data/lib/inkcite/renderer/base.rb +186 -0
  26. data/lib/inkcite/renderer/button.rb +168 -0
  27. data/lib/inkcite/renderer/div.rb +29 -0
  28. data/lib/inkcite/renderer/element.rb +82 -0
  29. data/lib/inkcite/renderer/footnote.rb +132 -0
  30. data/lib/inkcite/renderer/google_analytics.rb +35 -0
  31. data/lib/inkcite/renderer/image.rb +95 -0
  32. data/lib/inkcite/renderer/image_base.rb +82 -0
  33. data/lib/inkcite/renderer/in_browser.rb +38 -0
  34. data/lib/inkcite/renderer/like.rb +73 -0
  35. data/lib/inkcite/renderer/link.rb +243 -0
  36. data/lib/inkcite/renderer/litmus.rb +33 -0
  37. data/lib/inkcite/renderer/lorem.rb +39 -0
  38. data/lib/inkcite/renderer/mobile_image.rb +67 -0
  39. data/lib/inkcite/renderer/mobile_style.rb +40 -0
  40. data/lib/inkcite/renderer/mobile_toggle.rb +27 -0
  41. data/lib/inkcite/renderer/outlook_background.rb +48 -0
  42. data/lib/inkcite/renderer/partial.rb +31 -0
  43. data/lib/inkcite/renderer/preheader.rb +22 -0
  44. data/lib/inkcite/renderer/property.rb +39 -0
  45. data/lib/inkcite/renderer/responsive.rb +334 -0
  46. data/lib/inkcite/renderer/span.rb +21 -0
  47. data/lib/inkcite/renderer/table.rb +67 -0
  48. data/lib/inkcite/renderer/table_base.rb +149 -0
  49. data/lib/inkcite/renderer/td.rb +92 -0
  50. data/lib/inkcite/uploader.rb +173 -0
  51. data/lib/inkcite/util.rb +85 -0
  52. data/lib/inkcite/version.rb +3 -0
  53. data/lib/inkcite/view.rb +745 -0
  54. data/lib/inkcite/view/context.rb +38 -0
  55. data/lib/inkcite/view/media_query.rb +60 -0
  56. data/lib/inkcite/view/tag_stack.rb +38 -0
  57. data/test/email_spec.rb +16 -0
  58. data/test/parser_spec.rb +72 -0
  59. data/test/project/config.yml +98 -0
  60. data/test/project/helpers.tsv +56 -0
  61. data/test/project/images/inkcite.jpg +0 -0
  62. data/test/project/source.html +58 -0
  63. data/test/project/source.txt +6 -0
  64. data/test/renderer/button_spec.rb +45 -0
  65. data/test/renderer/div_spec.rb +101 -0
  66. data/test/renderer/element_spec.rb +31 -0
  67. data/test/renderer/footnote_spec.rb +57 -0
  68. data/test/renderer/image_spec.rb +82 -0
  69. data/test/renderer/link_spec.rb +84 -0
  70. data/test/renderer/mobile_image_spec.rb +27 -0
  71. data/test/renderer/mobile_style_spec.rb +37 -0
  72. data/test/renderer/td_spec.rb +126 -0
  73. data/test/renderer_spec.rb +28 -0
  74. data/test/view_spec.rb +15 -0
  75. metadata +333 -0
@@ -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
@@ -0,0 +1,3 @@
1
+ module Inkcite
2
+ VERSION = "1.0.0"
3
+ end
@@ -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