inkcite 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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